Initial commit
This commit is contained in:
parent
0d6faca84d
commit
ea90efc902
9
.env.example
Normal file
9
.env.example
Normal 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
45
.gitignore
vendored
@ -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
31
installer/database.sql
Normal 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
68
installer/install.sh
Normal 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!"
|
||||||
13
installer/nginx.conf.template
Normal file
13
installer/nginx.conf.template
Normal 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
6
next-env.d.ts
vendored
Normal 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
1146
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -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",
|
||||||
|
|||||||
56
src/app/api/auth/login/route.ts
Normal file
56
src/app/api/auth/login/route.ts
Normal 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;
|
||||||
|
}
|
||||||
16
src/app/api/auth/logout/route.tsx
Normal file
16
src/app/api/auth/logout/route.tsx
Normal 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 });
|
||||||
|
}
|
||||||
37
src/app/api/auth/me/route.ts
Normal file
37
src/app/api/auth/me/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
src/app/api/manager/posts/[id]/route.ts
Normal file
180
src/app/api/manager/posts/[id]/route.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/app/api/manager/posts/route.ts
Normal file
117
src/app/api/manager/posts/route.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
193
src/app/api/manager/users/[id]/route.ts
Normal file
193
src/app/api/manager/users/[id]/route.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/app/api/manager/users/route.ts
Normal file
103
src/app/api/manager/users/route.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/app/api/test-db/route.ts
Normal file
10
src/app/api/test-db/route.ts
Normal 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);
|
||||||
|
}
|
||||||
42
src/app/api/upload/route.tsx
Normal file
42
src/app/api/upload/route.tsx
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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);
|
--background: #0a0a0a;
|
||||||
--font-sans: var(--font-geist-sans);
|
--foreground: #ededed;
|
||||||
--font-mono: var(--font-geist-mono);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* ===============================
|
||||||
:root {
|
BASE STYLES
|
||||||
--background: #0a0a0a;
|
=============================== */
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
142
src/app/manager/create/page.tsx
Normal file
142
src/app/manager/create/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
src/app/manager/edit/[id]/page.tsx
Normal file
171
src/app/manager/edit/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/app/manager/login/page.tsx
Normal file
72
src/app/manager/login/page.tsx
Normal 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
262
src/app/manager/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/app/manager/posts/route.ts
Normal file
12
src/app/manager/posts/route.ts
Normal 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);
|
||||||
|
}
|
||||||
102
src/app/manager/users/add/page.tsx
Normal file
102
src/app/manager/users/add/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src/app/manager/users/edit/[id]/page.tsx
Normal file
160
src/app/manager/users/edit/[id]/page.tsx
Normal 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
110
src/app/news/[id]/page.tsx
Normal 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
75
src/app/news/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
src/app/page.tsx
178
src/app/page.tsx
@ -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"
|
</h1>
|
||||||
width={100}
|
|
||||||
height={20}
|
<p className="mt-6 text-lg text-gray-600 dark:text-gray-400">
|
||||||
priority
|
A modern blog and news platform focused on technology,
|
||||||
/>
|
development and digital projects.
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
</p>
|
||||||
<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.
|
<div className="mt-10 flex justify-center gap-4">
|
||||||
</h1>
|
<Link
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
href="/news"
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
className="px-6 py-3 rounded-xl bg-blue-600 text-white font-semibold hover:bg-blue-700 transition"
|
||||||
<a
|
>
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
Read articles
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
</Link>
|
||||||
>
|
|
||||||
Templates
|
<Link
|
||||||
</a>{" "}
|
href="/news"
|
||||||
or the{" "}
|
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"
|
||||||
<a
|
>
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
View news
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
</Link>
|
||||||
>
|
</div>
|
||||||
Learning
|
</section>
|
||||||
</a>{" "}
|
|
||||||
center.
|
{/* ================= 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>
|
</p>
|
||||||
</div>
|
) : (
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
<div className="grid md:grid-cols-3 gap-8">
|
||||||
<a
|
{posts.map(post => (
|
||||||
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]"
|
<article
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
key={post.id}
|
||||||
target="_blank"
|
className="
|
||||||
rel="noopener noreferrer"
|
rounded-2xl border border-gray-200 dark:border-gray-700
|
||||||
>
|
p-6 hover:shadow-md transition
|
||||||
<Image
|
"
|
||||||
className="dark:invert"
|
>
|
||||||
src="/vercel.svg"
|
<h3 className="text-xl font-semibold">
|
||||||
alt="Vercel logomark"
|
{post.title}
|
||||||
width={16}
|
</h3>
|
||||||
height={16}
|
|
||||||
/>
|
<p className="mt-3 text-gray-600 dark:text-gray-400">
|
||||||
Deploy Now
|
{post.description}
|
||||||
</a>
|
</p>
|
||||||
<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]"
|
<Link
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
href={`/news/${post.id}`}
|
||||||
target="_blank"
|
className="inline-block mt-5 text-blue-600 hover:underline"
|
||||||
rel="noopener noreferrer"
|
>
|
||||||
>
|
Read more →
|
||||||
Documentation
|
</Link>
|
||||||
</a>
|
</article>
|
||||||
</div>
|
))}
|
||||||
</main>
|
</div>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/components/CookieBanner.tsx
Normal file
48
src/components/CookieBanner.tsx
Normal 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
147
src/components/Editor.tsx
Normal 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
116
src/components/Navbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/components/extensions/Video.ts
Normal file
48
src/components/extensions/Video.ts
Normal 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
24
src/lib/db.ts
Normal 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
34
src/middleware.ts
Normal 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*"],
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user