Initial commit

This commit is contained in:
Jetomit_Bio 2026-01-17 20:23:16 +01:00
parent 0d6faca84d
commit ea90efc902
35 changed files with 3835 additions and 136 deletions

9
.env.example Normal file
View File

@ -0,0 +1,9 @@
DB_HOST=
DB_PORT=3306
DB_USER=
DB_PASSWORD=
DB_NAME=
JWT_SECRET=
APP_URL=https://example.com

45
.gitignore vendored
View File

@ -1,41 +1,20 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # dependencies
/node_modules node_modules/
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing # environment variables
/coverage .env
.env.local
.env.production
# next.js # next build
/.next/ .next/
/out/ out/
# production # logs
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed) # OS files
.env* .DS_Store
Thumbs.db
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

31
installer/database.sql Normal file
View File

@ -0,0 +1,31 @@
CREATE DATABASE IF NOT EXISTS {{DB_NAME}}
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE {{DB_NAME}};
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS posts (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
content LONGTEXT NOT NULL,
author_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (author_id) REFERENCES users(id)
ON DELETE CASCADE
);
INSERT INTO users (username, email, password)
VALUES (
'admin',
'admin@example.com',
'$2a$12$HVH3CnQFDVzWZcgwuz1orejDZ3YUAm6xLHOqhfh5yssNcp2BNr4ky'
);

68
installer/install.sh Normal file
View File

@ -0,0 +1,68 @@
#!/bin/bash
set -e
echo "=== Surafino Installer ==="
read -p "Domain (example.com): " DOMAIN
read -p "DB Host: " DB_HOST
read -p "DB Port (3306): " DB_PORT
read -p "DB User: " DB_USER
read -sp "DB Password: " DB_PASSWORD
echo
read -p "DB Name: " DB_NAME
JWT_SECRET=$(openssl rand -hex 32)
echo "Installing system packages..."
apt update
apt install -y nginx certbot python3-certbot-nginx mariadb-client nodejs npm
echo "Installing node dependencies..."
npm install
npm run build
echo "Creating .env..."
cat > .env <<EOF
DB_HOST=$DB_HOST
DB_PORT=$DB_PORT
DB_USER=$DB_USER
DB_PASSWORD=$DB_PASSWORD
DB_NAME=$DB_NAME
JWT_SECRET=$JWT_SECRET
APP_URL=https://$DOMAIN
EOF
echo "Setting up database..."
sed \
-e "s/{{DB_NAME}}/$DB_NAME/g" \
installer/database.sql > /tmp/db.sql
mysql \
-h $DB_HOST \
-P $DB_PORT \
-u $DB_USER \
-p$DB_PASSWORD < /tmp/db.sql
echo "Configuring nginx..."
sed \
-e "s/{{DOMAIN}}/$DOMAIN/g" \
installer/nginx.conf.template > /etc/nginx/sites-available/surafino
ln -sf /etc/nginx/sites-available/surafino /etc/nginx/sites-enabled/surafino
nginx -t
systemctl reload nginx
echo "Installing PM2..."
npm install -g pm2
pm2 start npm --name surafino -- start
pm2 save
pm2 startup systemd -u root --hp /root
echo "Requesting SSL certificate..."
certbot --nginx -d $DOMAIN --non-interactive --agree-tos -m admin@$DOMAIN
echo "=== INSTALLATION COMPLETE ==="
echo "Admin login:"
echo " username: admin"
echo " password: admin"
echo "Please change password immediately!"

View File

@ -0,0 +1,13 @@
server {
listen 80;
server_name {{DOMAIN}};
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}

6
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

1146
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,12 +9,23 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/typography": "^0.5.19",
"@tiptap/extension-image": "^3.15.3",
"@tiptap/extension-link": "^3.15.3",
"@tiptap/react": "^3.15.3",
"@tiptap/starter-kit": "^3.15.3",
"bcrypt": "^6.0.0",
"bcryptjs": "^3.0.3",
"jsonwebtoken": "^9.0.3",
"mariadb": "^3.4.5",
"next": "16.1.3", "next": "16.1.3",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcrypt": "^6.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

View File

@ -0,0 +1,56 @@
import { pool } from "@/lib/db";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { username, password } = await req.json();
if (!username || !password) {
return NextResponse.json(
{ error: "Missing credentials" },
{ status: 400 }
);
}
const conn = await pool.getConnection();
const users = await conn.query(
"SELECT id, username, password, role FROM users WHERE username = ?",
[username]
);
conn.release();
const user = users[0];
if (!user) {
return NextResponse.json(
{ error: "Invalid username or password" },
{ status: 401 }
);
}
const ok = await bcrypt.compare(password, user.password);
if (!ok) {
return NextResponse.json(
{ error: "Invalid username or password" },
{ status: 401 }
);
}
const token = jwt.sign(
{ id: user.id, role: user.role },
process.env.JWT_SECRET!,
{ expiresIn: "2h" }
);
const res = NextResponse.json({ success: true });
// 🔐 nastav HTTP-only cookie
res.cookies.set("token", token, {
httpOnly: true,
path: "/",
maxAge: 60 * 60 * 2,
sameSite: "lax",
});
return res;
}

View File

@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function POST() {
const cookieStore = await cookies();
// zmaže JWT cookie
cookieStore.set({
name: "token",
value: "",
path: "/",
expires: new Date(0),
});
return NextResponse.json({ success: true });
}

View File

@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import jwt from "jsonwebtoken";
import { cookies } from "next/headers";
type JwtPayload = {
id: number;
username: string;
};
export async function GET() {
try {
const cookieStore = await cookies();
const token = cookieStore.get("token")?.value;
if (!token) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
const decoded = jwt.verify(
token,
process.env.JWT_SECRET!
) as JwtPayload;
return NextResponse.json({
id: decoded.id,
username: decoded.username,
});
} catch {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
}

View File

@ -0,0 +1,180 @@
import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
import jwt from "jsonwebtoken";
import { cookies } from "next/headers";
type Params = {
params: Promise<{ id: string }>;
};
type JwtPayload = {
id: number;
username: string;
};
/* =========================
AUTH HELPER
========================= */
async function requireAuth() {
const cookieStore = await cookies();
const token = cookieStore.get("token")?.value;
if (!token) {
throw new Error("UNAUTHORIZED");
}
return jwt.verify(
token,
process.env.JWT_SECRET!
) as JwtPayload;
}
/* =========================
GET LOAD POST
========================= */
export async function GET(
req: Request,
{ params }: Params
) {
let conn;
try {
await requireAuth();
const { id } = await params;
conn = await pool.getConnection();
const rows = await conn.query(
`
SELECT id, title, description, content
FROM posts
WHERE id = ?
`,
[id]
);
if (!rows[0]) {
return NextResponse.json(
{ error: "Post not found" },
{ status: 404 }
);
}
return NextResponse.json(rows[0]);
} catch (err) {
if ((err as Error).message === "UNAUTHORIZED") {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
console.error("Failed to load post:", err);
return NextResponse.json(
{ error: "Failed to load post" },
{ status: 500 }
);
} finally {
if (conn) conn.release();
}
}
/* =========================
PUT UPDATE POST
========================= */
export async function PUT(
req: Request,
{ params }: Params
) {
let conn;
try {
await requireAuth();
const { id } = await params;
const { title, description, content } =
await req.json();
if (!title || !content) {
return NextResponse.json(
{ error: "Missing fields" },
{ status: 400 }
);
}
conn = await pool.getConnection();
await conn.query(
`
UPDATE posts
SET title = ?, description = ?, content = ?
WHERE id = ?
`,
[
title,
description ?? "",
content,
id,
]
);
return NextResponse.json({ success: true });
} catch (err) {
if ((err as Error).message === "UNAUTHORIZED") {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
console.error("Failed to update post:", err);
return NextResponse.json(
{ error: "Failed to update post" },
{ status: 500 }
);
} finally {
if (conn) conn.release();
}
}
/* =========================
DELETE DELETE POST
========================= */
export async function DELETE(
req: Request,
{ params }: Params
) {
let conn;
try {
await requireAuth();
const { id } = await params;
conn = await pool.getConnection();
await conn.query(
`
DELETE FROM posts
WHERE id = ?
`,
[id]
);
return NextResponse.json({ success: true });
} catch (err) {
if ((err as Error).message === "UNAUTHORIZED") {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
console.error("Failed to delete post:", err);
return NextResponse.json(
{ error: "Failed to delete post" },
{ status: 500 }
);
} finally {
if (conn) conn.release();
}
}

View File

@ -0,0 +1,117 @@
import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
import jwt from "jsonwebtoken";
import { cookies } from "next/headers";
type JwtPayload = {
id: number;
username: string;
};
/* =========================
GET LIST POSTS
========================= */
export async function GET() {
let conn;
try {
conn = await pool.getConnection();
const rows = await conn.query(
`
SELECT
posts.id,
posts.title,
posts.created_at,
users.username AS author
FROM posts
JOIN users ON users.id = posts.author_id
ORDER BY posts.created_at DESC
`
);
return NextResponse.json(rows);
} catch (error) {
console.error("Failed to load manager posts:", error);
return NextResponse.json(
{ error: "Failed to load posts" },
{ status: 500 }
);
} finally {
if (conn) conn.release();
}
}
/* =========================
POST CREATE POST
========================= */
export async function POST(req: Request) {
let conn;
try {
/* =========================
AUTH (Next.js 16 FIX)
========================= */
const cookieStore = await cookies(); // 🔑 MUST BE AWAIT
const token = cookieStore.get("token")?.value;
if (!token) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
const decoded = jwt.verify(
token,
process.env.JWT_SECRET!
) as JwtPayload;
/* =========================
BODY
========================= */
const { title, description, content } = await req.json();
if (!title || !title.trim()) {
return NextResponse.json(
{ error: "Title is required" },
{ status: 400 }
);
}
/* =========================
INSERT
========================= */
conn = await pool.getConnection();
const result = await conn.query(
`
INSERT INTO posts (
title,
description,
content,
author_id,
created_at
) VALUES (?, ?, ?, ?, NOW())
`,
[
title,
description ?? "",
content ?? "",
decoded.id,
]
);
const insertId = Number(result.insertId);
return NextResponse.json({ id: insertId });
} catch (error) {
console.error("Failed to create post:", error);
return NextResponse.json(
{ error: "Failed to create post" },
{ status: 500 }
);
} finally {
if (conn) conn.release();
}
}

View File

@ -0,0 +1,193 @@
import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
import jwt from "jsonwebtoken";
import { cookies } from "next/headers";
import bcrypt from "bcryptjs";
type Params = {
params: Promise<{ id: string }>;
};
async function requireAuth() {
const cookieStore = await cookies();
const token = cookieStore.get("token")?.value;
if (!token) {
throw new Error("UNAUTHORIZED");
}
jwt.verify(token, process.env.JWT_SECRET!);
}
/* =========================
GET LOAD USER
========================= */
export async function DELETE(
req: Request,
{ params }: Params
) {
let conn;
try {
const cookieStore = await cookies();
const token = cookieStore.get("token")?.value;
if (!token) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
const decoded = jwt.verify(
token,
process.env.JWT_SECRET!
) as { id: number };
const { id } = await params;
const userIdToDelete = Number(id);
/* =========================
SECURITY: CANNOT DELETE SELF
========================= */
if (decoded.id === userIdToDelete) {
return NextResponse.json(
{ error: "You cannot delete yourself" },
{ status: 400 }
);
}
conn = await pool.getConnection();
await conn.query(
`
DELETE FROM users
WHERE id = ?
`,
[userIdToDelete]
);
return NextResponse.json({ success: true });
} catch (err) {
console.error("Failed to delete user:", err);
return NextResponse.json(
{ error: "Failed to delete user" },
{ status: 500 }
);
} finally {
if (conn) conn.release();
}
}
export async function GET(
req: Request,
{ params }: Params
) {
let conn;
try {
await requireAuth();
const { id } = await params;
conn = await pool.getConnection();
const rows = await conn.query(
`
SELECT id, username, email
FROM users
WHERE id = ?
`,
[id]
);
if (!rows[0]) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}
return NextResponse.json(rows[0]);
} catch (err) {
if ((err as Error).message === "UNAUTHORIZED") {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
console.error("Failed to load user:", err);
return NextResponse.json(
{ error: "Failed to load user" },
{ status: 500 }
);
} finally {
if (conn) conn.release();
}
}
/* =========================
PUT UPDATE USER
========================= */
export async function PUT(
req: Request,
{ params }: Params
) {
let conn;
try {
await requireAuth();
const { id } = await params;
const { username, email, password } =
await req.json();
if (!username || !email) {
return NextResponse.json(
{ error: "Missing fields" },
{ status: 400 }
);
}
conn = await pool.getConnection();
if (password) {
const hash = await bcrypt.hash(password, 10);
await conn.query(
`
UPDATE users
SET username = ?, email = ?, password = ?
WHERE id = ?
`,
[username, email, hash, id]
);
} else {
await conn.query(
`
UPDATE users
SET username = ?, email = ?
WHERE id = ?
`,
[username, email, id]
);
}
return NextResponse.json({ success: true });
} catch (err) {
if ((err as Error).message === "UNAUTHORIZED") {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
console.error("Failed to update user:", err);
return NextResponse.json(
{ error: "Failed to update user" },
{ status: 500 }
);
} finally {
if (conn) conn.release();
}
}

View File

@ -0,0 +1,103 @@
import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
import jwt from "jsonwebtoken";
import { cookies } from "next/headers";
import bcrypt from "bcryptjs";
async function requireAuth() {
const cookieStore = await cookies();
const token = cookieStore.get("token")?.value;
if (!token) {
throw new Error("UNAUTHORIZED");
}
jwt.verify(token, process.env.JWT_SECRET!);
}
/* =========================
GET LIST USERS
========================= */
export async function GET() {
let conn;
try {
await requireAuth();
conn = await pool.getConnection();
const users = await conn.query(
`
SELECT id, username, email
FROM users
ORDER BY id ASC
`
);
return NextResponse.json(users);
} catch (err) {
if ((err as Error).message === "UNAUTHORIZED") {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
console.error("Failed to load users:", err);
return NextResponse.json(
{ error: "Failed to load users" },
{ status: 500 }
);
} finally {
if (conn) conn.release();
}
}
/* =========================
POST CREATE USER
========================= */
export async function POST(req: Request) {
let conn;
try {
await requireAuth();
const { username, email, password } =
await req.json();
if (!username || !email || !password) {
return NextResponse.json(
{ error: "Missing fields" },
{ status: 400 }
);
}
const hash = await bcrypt.hash(password, 10);
conn = await pool.getConnection();
await conn.query(
`
INSERT INTO users (username, email, password)
VALUES (?, ?, ?)
`,
[username, email, hash]
);
return NextResponse.json({ success: true });
} catch (err) {
if ((err as Error).message === "UNAUTHORIZED") {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
console.error("Failed to create user:", err);
return NextResponse.json(
{ error: "Failed to create user" },
{ status: 500 }
);
} finally {
if (conn) conn.release();
}
}

View File

@ -0,0 +1,10 @@
import { pool } from "@/lib/db";
import { NextResponse } from "next/server";
export async function GET() {
const conn = await pool.getConnection();
const rows = await conn.query("SELECT 1 AS ok");
conn.release();
return NextResponse.json(rows);
}

View File

@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
export async function POST(req: Request) {
const formData = await req.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json(
{ error: "No file uploaded" },
{ status: 400 }
);
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const isVideo = file.type.startsWith("video/");
const folder = isVideo ? "videos" : "images";
const uploadDir = path.join(
process.cwd(),
"public/uploads",
folder
);
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const safeName = file.name.replace(/\s+/g, "_");
const filename = `${Date.now()}-${safeName}`;
const filepath = path.join(uploadDir, filename);
fs.writeFileSync(filepath, buffer);
return NextResponse.json({
url: `/uploads/${folder}/${filename}`,
type: isVideo ? "video" : "image",
});
}

View File

@ -1,26 +1,160 @@
@import "tailwindcss"; @import "tailwindcss";
/* ===============================
COLOR VARIABLES
=============================== */
/* LIGHT MODE (default) */
:root { :root {
--background: #ffffff; --background: #ffffff;
--foreground: #171717; --foreground: #171717;
} }
@theme inline { /* DARK MODE */
--color-background: var(--background); body.dark {
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a; --background: #0a0a0a;
--foreground: #ededed; --foreground: #ededed;
}
} }
/* ===============================
BASE STYLES
=============================== */
body { body {
background: var(--background); background-color: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Helvetica, Arial, sans-serif;
transition: background-color 0.2s ease, color 0.2s ease;
}
/* ===============================
TYPOGRAPHY (BLOG CONTENT)
=============================== */
/* Ensure prose uses correct colors */
.prose {
color: var(--foreground);
}
/* Headings */
.prose h1,
.prose h2,
.prose h3,
.prose h4,
.prose h5,
.prose h6 {
color: var(--foreground);
}
/* Paragraphs */
.prose p {
color: var(--foreground);
}
/* Strong / Bold */
.prose strong,
.prose b {
color: var(--foreground);
font-weight: 700;
}
/* Italic */
.prose em,
.prose i {
font-style: italic;
}
/* Links */
.prose a {
color: #2563eb; /* blue-600 */
text-decoration: underline;
}
body.dark .prose a {
color: #60a5fa; /* blue-400 */
}
/* Lists */
.prose ul,
.prose ol {
color: var(--foreground);
}
/* Images */
.prose img {
max-width: 100%;
height: auto;
border-radius: 12px;
margin: 1.5rem 0;
}
/* Videos */
.prose video,
.prose iframe {
max-width: 100%;
border-radius: 12px;
margin: 1.5rem 0;
}
/* Blockquotes */
.prose blockquote {
border-left: 4px solid #e5e7eb;
padding-left: 1rem;
color: var(--foreground);
}
body.dark .prose blockquote {
border-left-color: #374151;
}
.editor-btn {
padding: 0.3rem 0.7rem;
border: 1px solid #9ca3af; /* tmavší border */
border-radius: 6px;
font-size: 0.875rem;
background: #ffffff;
color: #111827; /* tmavý text */
cursor: pointer;
transition: background-color 0.15s, border-color 0.15s;
}
.editor-btn:hover {
background: #f3f4f6; /* jasný hover */
border-color: #6b7280;
}
/* ACTIVE STATE (keď je bold/italic zapnuté) */
.editor-btn.is-active {
background: #2563eb; /* blue */
color: #ffffff;
border-color: #2563eb;
}
/* DARK MODE */
.dark .editor-btn {
background: #374151;
color: #f9fafb;
border-color: #4b5563;
}
.dark .editor-btn:hover {
background: #4b5563;
}
.dark .editor-btn.is-active {
background: #2563eb;
border-color: #2563eb;
color: white;
}
video {
margin: 1.5rem 0;
border-radius: 8px;
}
img {
border-radius: 8px;
} }

View File

@ -1,32 +1,15 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import Navbar from "@/components/Navbar";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: {
children: React.ReactNode; children: React.ReactNode;
}>) { }) {
return ( return (
<html lang="en"> <html lang="en">
<body <body>
className={`${geistSans.variable} ${geistMono.variable} antialiased`} <Navbar />
>
{children} {children}
</body> </body>
</html> </html>

View File

@ -0,0 +1,142 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Editor from "@/components/Editor";
type Post = {
title: string;
description: string;
content: string;
};
export default function CreatePostPage() {
const router = useRouter();
const [post, setPost] = useState<Post>({
title: "",
description: "",
content: "",
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [saved, setSaved] = useState(false);
/* =========================
CREATE POST
========================= */
async function create() {
setError("");
setSaved(false);
if (!post.title.trim()) {
setError("Title is required");
return;
}
setLoading(true);
const res = await fetch("/api/manager/posts", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(post),
});
setLoading(false);
if (!res.ok) {
setError("Failed to create post");
return;
}
const data = await res.json();
// redirect to edit page
router.push(`/manager/edit/${data.id}`);
}
return (
<main className="max-w-5xl mx-auto px-6 py-16">
<h1 className="text-3xl font-bold mb-8">
Create new post
</h1>
{error && (
<p className="mb-4 text-red-600">
{error}
</p>
)}
{saved && (
<p className="mb-4 text-green-600">
Created successfully
</p>
)}
<div className="space-y-6">
{/* TITLE */}
<input
className="w-full p-3 border rounded"
placeholder="Title"
value={post.title}
onChange={e =>
setPost({
...post,
title: e.target.value,
})
}
/>
{/* DESCRIPTION */}
<textarea
className="w-full p-3 border rounded h-24"
placeholder="Description"
value={post.description}
onChange={e =>
setPost({
...post,
description: e.target.value,
})
}
/>
{/* CONTENT */}
<Editor
content={post.content}
onChange={html =>
setPost({
...post,
content: html,
})
}
/>
{/* ACTIONS */}
<div className="flex gap-4">
<button
onClick={create}
disabled={loading}
className="
px-6 py-2 rounded
bg-blue-600 text-white
hover:bg-blue-700
disabled:opacity-50
"
>
{loading ? "Creating..." : "Create"}
</button>
<button
onClick={() => router.push("/manager")}
className="px-6 py-2 border rounded"
>
Cancel
</button>
</div>
</div>
</main>
);
}

View File

@ -0,0 +1,171 @@
"use client";
import { useEffect, useState } from "react";
import Editor from "@/components/Editor";
type Post = {
title: string;
description: string;
content: string;
};
export default function EditPostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const [id, setId] = useState<string | null>(null);
const [post, setPost] = useState<Post>({
title: "",
description: "",
content: "",
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [saved, setSaved] = useState(false);
/* =========================
UNWRAP PARAMS (Next.js 16)
========================= */
useEffect(() => {
params.then(p => setId(p.id));
}, [params]);
/* =========================
LOAD POST
========================= */
useEffect(() => {
if (!id) return;
fetch(`/api/manager/posts/${id}`)
.then(res => {
if (!res.ok) {
throw new Error("Failed to load post");
}
return res.json();
})
.then(data => {
setPost({
title: data.title ?? "",
description: data.description ?? "",
content: data.content ?? "",
});
setLoading(false);
})
.catch(() => {
setError("Post not found");
setLoading(false);
});
}, [id]);
/* =========================
SAVE POST
========================= */
async function save() {
if (!id) return;
setError("");
setSaved(false);
const res = await fetch(`/api/manager/posts/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: post.title,
description: post.description,
content: post.content, // HTML s viac videami + obrázkami
}),
});
if (!res.ok) {
setError("Failed to save post");
return;
}
setSaved(true);
}
/* =========================
RENDER STATES
========================= */
if (loading) {
return <p className="p-10">Loading</p>;
}
if (error) {
return (
<p className="p-10 text-red-600">
{error}
</p>
);
}
return (
<main className="max-w-5xl mx-auto px-6 py-16">
<h1 className="text-3xl font-bold mb-8">
Edit post
</h1>
{saved && (
<p className="mb-4 text-green-600">
Saved successfully
</p>
)}
<div className="space-y-6">
{/* TITLE */}
<input
className="w-full p-3 border rounded"
placeholder="Title"
value={post.title}
onChange={e =>
setPost({
...post,
title: e.target.value,
})
}
/>
{/* DESCRIPTION */}
<textarea
className="w-full p-3 border rounded h-24"
placeholder="Description"
value={post.description}
onChange={e =>
setPost({
...post,
description: e.target.value,
})
}
/>
{/* CONTENT WYSIWYG EDITOR */}
<Editor
content={post.content}
onChange={html =>
setPost({
...post,
content: html, // môže obsahovať ľubovoľný počet <img> a <video>
})
}
/>
{/* SAVE */}
<button
onClick={save}
className="
px-6 py-2 rounded
bg-blue-600 text-white
hover:bg-blue-700
"
>
Save
</button>
</div>
</main>
);
}

View File

@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
export default function ManagerLogin() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
async function login(e: React.FormEvent) {
e.preventDefault();
setError("");
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || "Login failed");
return;
}
// 🔥 TERAZ už cookie EXISTUJE → normálna navigácia
window.location.href = "/manager";
}
return (
<main className="min-h-screen flex items-center justify-center">
<form
onSubmit={login}
className="w-full max-w-sm border rounded-xl p-6"
>
<h1 className="text-2xl font-bold mb-6">
Manager Login
</h1>
{error && (
<p className="mb-4 text-red-600">
{error}
</p>
)}
<input
className="w-full mb-3 p-2 border rounded"
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
required
/>
<input
className="w-full mb-4 p-2 border rounded"
type="password"
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
<button
type="submit"
className="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Login
</button>
</form>
</main>
);
}

262
src/app/manager/page.tsx Normal file
View File

@ -0,0 +1,262 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
type Post = {
id: number;
title: string;
};
type User = {
id: number;
username: string;
email: string;
};
export default function ManagerPage() {
const router = useRouter();
const [posts, setPosts] = useState<Post[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [me, setMe] = useState<{ id: number; username: string } | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
/* =========================
LOAD MANAGER DATA
========================= */
useEffect(() => {
Promise.all([
fetch("/api/manager/posts").then(res =>
res.ok ? res.json() : []
),
fetch("/api/manager/users").then(res =>
res.ok ? res.json() : []
),
fetch("/api/auth/me").then(res =>
res.ok ? res.json() : null
),
])
.then(([postsData, usersData, meData]) => {
setPosts(postsData);
setUsers(usersData);
setMe(meData);
setLoading(false);
})
.catch(err => {
console.error(err);
setError("Failed to load manager data");
setLoading(false);
});
}, []);
/* =========================
LOGOUT
========================= */
async function logout() {
await fetch("/api/auth/logout", {
method: "POST",
});
router.push("/manager/login");
}
/* =========================
DELETE POST
========================= */
async function deletePost(id: number) {
if (!confirm("Delete this post?")) return;
const res = await fetch(
`/api/manager/posts/${id}`,
{ method: "DELETE" }
);
if (!res.ok) {
alert("Failed to delete post");
return;
}
setPosts(posts.filter(p => p.id !== id));
}
/* =========================
DELETE USER
========================= */
async function deleteUser(id: number) {
if (!confirm("Delete this user?")) return;
const res = await fetch(
`/api/manager/users/${id}`,
{ method: "DELETE" }
);
const data = await res.json();
if (!res.ok) {
alert(data.error || "Failed to delete user");
return;
}
setUsers(users.filter(u => u.id !== id));
}
if (loading) {
return <p className="p-10">Loading</p>;
}
return (
<main className="max-w-6xl mx-auto px-6 py-16 space-y-16">
{/* =========================
HEADER
========================= */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">
Manager
</h1>
{me && (
<p className="text-sm text-gray-500">
Logged in as{" "}
<strong>{me.username}</strong>
</p>
)}
</div>
<div className="flex gap-3">
<button
onClick={() =>
router.push("/manager/create")
}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
Create post
</button>
<button
onClick={logout}
className="px-4 py-2 border rounded"
>
Logout
</button>
</div>
</div>
{error && (
<p className="text-red-600">{error}</p>
)}
{/* =========================
POSTS
========================= */}
<section>
<h2 className="text-xl font-semibold mb-4">
Posts
</h2>
{posts.length === 0 ? (
<p>No posts yet.</p>
) : (
<div className="space-y-3">
{posts.map(post => (
<div
key={post.id}
className="border rounded p-4 flex justify-between items-center"
>
<span>{post.title}</span>
<div className="flex gap-4">
<button
onClick={() =>
router.push(
`/manager/edit/${post.id}`
)
}
className="text-blue-600 hover:underline"
>
Edit
</button>
<button
onClick={() =>
deletePost(post.id)
}
className="text-red-600 hover:underline"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</section>
{/* =========================
USERS
========================= */}
<section>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">
Users
</h2>
<button
onClick={() =>
router.push("/manager/users/add")
}
className="px-4 py-2 border rounded"
>
Add user
</button>
</div>
{users.length === 0 ? (
<p>No users.</p>
) : (
<div className="space-y-3">
{users.map(user => (
<div
key={user.id}
className="border rounded p-4 flex justify-between items-center"
>
<div>
<p className="font-medium">
{user.username}
</p>
<p className="text-sm text-gray-500">
{user.email}
</p>
</div>
<div className="flex gap-4">
<button
onClick={() =>
router.push(
`/manager/users/edit/${user.id}`
)
}
className="text-blue-600 hover:underline"
>
Edit
</button>
<button
onClick={() =>
deleteUser(user.id)
}
className="text-red-600 hover:underline"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</section>
</main>
);
}

View File

@ -0,0 +1,12 @@
import { pool } from "@/lib/db";
import { NextResponse } from "next/server";
export async function GET() {
const conn = await pool.getConnection();
const posts = await conn.query(
"SELECT id, title FROM posts ORDER BY created_at DESC"
);
conn.release();
return NextResponse.json(posts);
}

View File

@ -0,0 +1,102 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function AddUserPage() {
const router = useRouter();
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function save() {
setError("");
if (!username || !email || !password) {
setError("All fields are required");
return;
}
setLoading(true);
const res = await fetch("/api/manager/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username,
email,
password,
}),
});
setLoading(false);
if (!res.ok) {
const data = await res.json();
setError(data.error || "Failed to create user");
return;
}
router.push("/manager");
}
return (
<main className="max-w-xl mx-auto px-6 py-16">
<h1 className="text-3xl font-bold mb-8">
Add user
</h1>
{error && (
<p className="mb-4 text-red-600">
{error}
</p>
)}
<div className="space-y-6">
<input
className="w-full p-3 border rounded"
placeholder="Username"
value={username}
onChange={e => setUsername(e.target.value)}
/>
<input
type="email"
className="w-full p-3 border rounded"
placeholder="Email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
<input
type="password"
className="w-full p-3 border rounded"
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<div className="flex gap-3">
<button
onClick={save}
disabled={loading}
className="px-6 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
Create user
</button>
<button
onClick={() => router.back()}
className="px-6 py-2 border rounded"
>
Cancel
</button>
</div>
</div>
</main>
);
}

View File

@ -0,0 +1,160 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
type User = {
username: string;
email: string;
};
export default function EditUserPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const router = useRouter();
const [id, setId] = useState<string | null>(null);
const [user, setUser] = useState<User>({
username: "",
email: "",
});
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [saved, setSaved] = useState(false);
/* =========================
UNWRAP PARAMS (Next 16)
========================= */
useEffect(() => {
params.then(p => setId(p.id));
}, [params]);
/* =========================
LOAD USER
========================= */
useEffect(() => {
if (!id) return;
fetch(`/api/manager/users/${id}`)
.then(res => {
if (!res.ok) {
throw new Error("Failed to load user");
}
return res.json();
})
.then(data => {
setUser({
username: data.username,
email: data.email,
});
setLoading(false);
})
.catch(() => {
setError("User not found");
setLoading(false);
});
}, [id]);
/* =========================
SAVE USER
========================= */
async function save() {
if (!id) return;
setError("");
setSaved(false);
const res = await fetch(`/api/manager/users/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...user,
password: password || undefined,
}),
});
if (!res.ok) {
setError("Failed to save user");
return;
}
setSaved(true);
setPassword("");
}
if (loading) {
return <p className="p-10">Loading</p>;
}
if (error) {
return <p className="p-10 text-red-600">{error}</p>;
}
return (
<main className="max-w-xl mx-auto px-6 py-16">
<h1 className="text-3xl font-bold mb-8">
Edit user
</h1>
{saved && (
<p className="mb-4 text-green-600">
User updated successfully
</p>
)}
<div className="space-y-6">
<input
className="w-full p-3 border rounded"
placeholder="Username"
value={user.username}
onChange={e =>
setUser({
...user,
username: e.target.value,
})
}
/>
<input
type="email"
className="w-full p-3 border rounded"
placeholder="Email"
value={user.email}
onChange={e =>
setUser({
...user,
email: e.target.value,
})
}
/>
<input
type="password"
className="w-full p-3 border rounded"
placeholder="New password (optional)"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<div className="flex gap-3">
<button
onClick={save}
className="px-6 py-2 bg-blue-600 text-white rounded"
>
Save
</button>
<button
onClick={() => router.back()}
className="px-6 py-2 border rounded"
>
Cancel
</button>
</div>
</div>
</main>
);
}

110
src/app/news/[id]/page.tsx Normal file
View File

@ -0,0 +1,110 @@
import { pool } from "@/lib/db";
import { notFound } from "next/navigation";
type Post = {
id: number;
title: string;
description: string;
content: string;
created_at: string;
author: string | null;
};
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
let post: Post | null = null;
try {
const conn = await pool.getConnection();
const rows = await conn.query(
`
SELECT
p.id,
p.title,
p.description,
p.content,
p.created_at,
u.username AS author
FROM posts p
LEFT JOIN users u ON p.author_id = u.id
WHERE p.id = ?
`,
[id]
);
conn.release();
post = rows[0] ?? null;
} catch (err) {
console.error("Failed to load post:", err);
}
if (!post) {
notFound();
}
return (
<main className="max-w-[1400px] mx-auto px-5 py-16">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_360px] gap-5">
{/* ================= LEFT: ARTICLE CONTENT ================= */}
<article className="prose dark:prose-invert max-w-none">
<div
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
{/* ================= RIGHT: META PANEL ================= */}
<aside
className="
border border-gray-200 dark:border-gray-700
rounded-xl p-6
h-fit
lg:sticky
lg:bottom-[10px]
"
>
{/* TITLE */}
<h1 className="text-2xl font-bold mb-6">
{post.title}
</h1>
{/* AUTHOR */}
<div className="mb-4">
<p className="font-semibold">
Author
</p>
<p>
{post.author ?? "Unknown"}
</p>
</div>
{/* DESCRIPTION */}
<div className="mb-4">
<p className="font-semibold">
Description
</p>
<p>
{post.description}
</p>
</div>
{/* PUBLISHED DATE */}
<div>
<p className="font-semibold">
Published
</p>
<p>
{new Date(post.created_at).toLocaleDateString()}
</p>
</div>
</aside>
</div>
</main>
);
}

75
src/app/news/page.tsx Normal file
View File

@ -0,0 +1,75 @@
import Link from "next/link";
import { pool } from "@/lib/db";
type Post = {
id: number;
title: string;
description: string;
created_at: string;
};
export default async function NewsPage() {
let posts: Post[] = [];
try {
const conn = await pool.getConnection();
posts = await conn.query(
`
SELECT id, title, description, created_at
FROM posts
ORDER BY created_at DESC
`
);
conn.release();
} catch (err) {
console.error("Failed to load posts:", err);
}
return (
<main className="max-w-7xl mx-auto px-6 py-20">
{/* HEADER */}
<header className="mb-16">
<h1 className="text-4xl font-extrabold">
News
</h1>
<p className="mt-4 text-gray-600 dark:text-gray-400 max-w-2xl">
All articles and updates published on Surafino.
</p>
</header>
{/* POSTS */}
{posts.length === 0 ? (
<p className="text-gray-500">
No posts available.
</p>
) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts.map(post => (
<article
key={post.id}
className="
rounded-2xl border border-gray-200 dark:border-gray-700
p-6 hover:shadow-md transition
"
>
<h2 className="text-xl font-semibold">
{post.title}
</h2>
<p className="mt-3 text-gray-600 dark:text-gray-400">
{post.description}
</p>
<Link
href={`/news/${post.id}`}
className="inline-block mt-5 text-blue-600 hover:underline"
>
Read article
</Link>
</article>
))}
</div>
)}
</main>
);
}

View File

@ -1,65 +1,123 @@
import Image from "next/image"; import Link from "next/link";
import { pool } from "@/lib/db";
type Post = {
id: number;
title: string;
description: string;
created_at: string;
};
export default async function HomePage() {
let posts: Post[] = [];
try {
const conn = await pool.getConnection();
posts = await conn.query(
`
SELECT id, title, description, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 3
`
);
conn.release();
} catch (err) {
console.error("Failed to load posts:", err);
}
export default function Home() {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <main className="max-w-7xl mx-auto px-6 py-20 space-y-32">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> {/* ================= HERO ================= */}
<Image <section className="text-center max-w-3xl mx-auto">
className="dark:invert" <h1 className="text-5xl md:text-6xl font-extrabold tracking-tight">
src="/next.svg" Welcome to Surafino
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1> </h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "} <p className="mt-6 text-lg text-gray-600 dark:text-gray-400">
<a A modern blog and news platform focused on technology,
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" development and digital projects.
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p> </p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> <div className="mt-10 flex justify-center gap-4">
<a <Link
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]" href="/news"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" className="px-6 py-3 rounded-xl bg-blue-600 text-white font-semibold hover:bg-blue-700 transition"
target="_blank"
rel="noopener noreferrer"
> >
<Image Read articles
className="dark:invert" </Link>
src="/vercel.svg"
alt="Vercel logomark" <Link
width={16} href="/news"
height={16} className="px-6 py-3 rounded-xl border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 transition"
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
> >
Documentation View news
</a> </Link>
</div> </div>
</section>
{/* ================= LATEST POSTS ================= */}
<section>
<div className="flex items-center justify-between mb-10">
<h2 className="text-3xl font-bold">
Latest posts
</h2>
<Link
href="/news"
className="text-blue-600 hover:underline"
>
View more
</Link>
</div>
{posts.length === 0 ? (
<p className="text-gray-500">
No posts have been published yet.
</p>
) : (
<div className="grid md:grid-cols-3 gap-8">
{posts.map(post => (
<article
key={post.id}
className="
rounded-2xl border border-gray-200 dark:border-gray-700
p-6 hover:shadow-md transition
"
>
<h3 className="text-xl font-semibold">
{post.title}
</h3>
<p className="mt-3 text-gray-600 dark:text-gray-400">
{post.description}
</p>
<Link
href={`/news/${post.id}`}
className="inline-block mt-5 text-blue-600 hover:underline"
>
Read more
</Link>
</article>
))}
</div>
)}
</section>
{/* ================= ABOUT ================= */}
<section className="max-w-3xl mx-auto text-center">
<h2 className="text-3xl font-bold mb-6">
About Surafino
</h2>
<p className="text-gray-700 dark:text-gray-400 leading-relaxed">
Surafino is a personal blog and news platform built with modern
web technologies. It allows publishing articles with rich content,
managing posts through a private admin panel, and supports
light and dark mode for better readability.
</p>
</section>
</main> </main>
</div>
); );
} }

View File

@ -0,0 +1,48 @@
"use client";
import { useEffect, useState } from "react";
export default function CookieBanner() {
const [visible, setVisible] = useState(false);
useEffect(() => {
const accepted = document.cookie.includes("cookiesAccepted=true");
if (!accepted) setVisible(true);
}, []);
function acceptCookies() {
document.cookie = "cookiesAccepted=true; path=/; max-age=31536000";
setVisible(false);
}
if (!visible) return null;
return (
<div className="
fixed bottom-4 left-4 right-4
max-w-3xl mx-auto
rounded-xl p-4 shadow-lg
border
bg-white text-gray-800 border-gray-300
dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700
flex flex-col md:flex-row gap-4
items-center justify-between
">
<p className="text-sm">
This website uses cookies to store theme preferences.
</p>
<button
onClick={acceptCookies}
className="
px-5 py-2 rounded-lg
bg-blue-600 text-white
hover:bg-blue-700
transition
"
>
Accept
</button>
</div>
);
}

147
src/components/Editor.tsx Normal file
View File

@ -0,0 +1,147 @@
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
import { Video } from "@/components/extensions/Video";
export default function Editor({
content,
onChange,
}: {
content: string;
onChange: (html: string) => void;
}) {
const editor = useEditor({
extensions: [
StarterKit,
Image,
Link.configure({ openOnClick: false }),
Video,
],
content,
immediatelyRender: false, // Next.js 16 SSR fix
onUpdate({ editor }) {
onChange(editor.getHTML());
},
});
if (!editor) return null;
async function upload(file: File) {
const data = new FormData();
data.append("file", file);
const res = await fetch("/api/upload", {
method: "POST",
body: data,
});
if (!res.ok) {
throw new Error("Upload failed");
}
return res.json() as Promise<{
url: string;
type: "image" | "video";
}>;
}
async function addImage(file: File) {
const { url } = await upload(file);
editor.chain().focus().setImage({ src: url }).run();
}
async function addVideo(file: File) {
const { url } = await upload(file);
editor
.chain()
.focus()
.insertContent([
{
type: "video",
attrs: {
src: url,
type: file.type,
},
},
{ type: "paragraph" },
])
.run();
}
return (
<div className="border rounded-lg bg-white dark:bg-gray-900">
{/* TOOLBAR */}
<div
className="
flex flex-wrap gap-2 p-2 border-b
bg-gray-100 text-gray-900
dark:bg-gray-800 dark:text-gray-100
"
>
<button
type="button"
onClick={() => editor.chain().focus().toggleBold().run()}
className="editor-btn"
>
Bold
</button>
<button
type="button"
onClick={() => editor.chain().focus().toggleItalic().run()}
className="editor-btn"
>
Italic
</button>
<button
type="button"
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
className="editor-btn"
>
H2
</button>
<label className="editor-btn cursor-pointer">
Image
<input
type="file"
hidden
accept="image/*"
onChange={e =>
e.target.files && addImage(e.target.files[0])
}
/>
</label>
<label className="editor-btn cursor-pointer">
Video
<input
type="file"
hidden
accept="video/mp4,video/webm"
onChange={e =>
e.target.files && addVideo(e.target.files[0])
}
/>
</label>
</div>
{/* CONTENT */}
<EditorContent
editor={editor}
className="
p-4 min-h-[300px] outline-none
bg-white text-gray-900
dark:bg-gray-900 dark:text-gray-100
"
/>
</div>
);
}

116
src/components/Navbar.tsx Normal file
View File

@ -0,0 +1,116 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
export default function Navbar() {
const [dark, setDark] = useState(false);
const [open, setOpen] = useState(false);
// Load theme from cookie on first render
useEffect(() => {
const match = document.cookie.match(/theme=(dark|light)/);
const isDark = match?.[1] === "dark";
setDark(isDark);
document.body.classList.toggle("dark", isDark);
}, []);
// Toggle theme and save to cookie
function toggleTheme() {
const newTheme = !dark;
setDark(newTheme);
document.body.classList.toggle("dark", newTheme);
document.cookie = `theme=${newTheme ? "dark" : "light"}; path=/; max-age=31536000`;
}
return (
<header
className="sticky top-0 z-50 border-b"
style={{
background: "var(--background)",
color: "var(--foreground)",
borderColor: dark ? "#2a2a2a" : "#e5e7eb",
}}
>
<nav className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
{/* LOGO */}
<Link
href="/"
className="text-xl font-bold tracking-wide"
>
Surafino
</Link>
{/* DESKTOP MENU */}
<div className="hidden md:flex items-center gap-8">
<Link
href="/"
className="hover:underline"
>
Home
</Link>
<Link
href="/news"
className="hover:underline"
>
News
</Link>
{/* THEME TOGGLE */}
<button
onClick={toggleTheme}
aria-label="Toggle theme"
className="p-2 rounded-lg border transition"
style={{
borderColor: dark ? "#3a3a3a" : "#d1d5db",
}}
>
{dark ? "☀️" : "🌙"}
</button>
</div>
{/* MOBILE BUTTON */}
<button
className="md:hidden text-2xl"
onClick={() => setOpen(!open)}
aria-label="Open menu"
>
</button>
</nav>
{/* MOBILE MENU */}
{open && (
<div
className="md:hidden px-6 py-4 space-y-4 border-t"
style={{
background: "var(--background)",
color: "var(--foreground)",
borderColor: dark ? "#2a2a2a" : "#e5e7eb",
}}
>
<Link href="/" onClick={() => setOpen(false)} className="block">
Home
</Link>
<Link href="/news" onClick={() => setOpen(false)} className="block">
News
</Link>
<button
onClick={() => {
toggleTheme();
setOpen(false);
}}
className="block"
>
{dark ? "Light mode" : "Dark mode"}
</button>
</div>
)}
</header>
);
}

View File

@ -0,0 +1,48 @@
import { Node, mergeAttributes } from "@tiptap/core";
export const Video = Node.create({
name: "video",
group: "block",
draggable: true,
addAttributes() {
return {
src: {
default: null,
},
type: {
default: "video/mp4",
},
};
},
parseHTML() {
return [
{
tag: "video",
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"video",
mergeAttributes(
{
controls: "true",
style: "max-width:100%;",
},
HTMLAttributes
),
[
"source",
{
src: HTMLAttributes.src,
type: HTMLAttributes.type,
},
],
];
},
});

24
src/lib/db.ts Normal file
View File

@ -0,0 +1,24 @@
import mariadb from "mariadb";
declare global {
// eslint-disable-next-line no-var
var mariadbPool: mariadb.Pool | undefined;
}
export const pool =
global.mariadbPool ??
mariadb.createPool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
connectionLimit: 5,
connectTimeout: 5000,
acquireTimeout: 5000,
});
if (!global.mariadbPool) {
global.mariadbPool = pool;
}

34
src/middleware.ts Normal file
View File

@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(req: NextRequest) {
const token = req.cookies.get("token")?.value;
const { pathname } = req.nextUrl;
// Chránené routy
if (pathname.startsWith("/manager")) {
// povolíme iba login stránku bez tokenu
if (!token && pathname !== "/manager/login") {
const loginUrl = new URL(
"/manager/login",
req.url
);
return NextResponse.redirect(loginUrl);
}
// ak je prihlásený a ide na login → hoď ho do managera
if (token && pathname === "/manager/login") {
const managerUrl = new URL(
"/manager",
req.url
);
return NextResponse.redirect(managerUrl);
}
}
return NextResponse.next();
}
export const config = {
matcher: ["/manager/:path*"],
};