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
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
node_modules/
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
# environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
# next build
|
||||
.next/
|
||||
out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
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"
|
||||
},
|
||||
"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",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^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";
|
||||
|
||||
/* ===============================
|
||||
COLOR VARIABLES
|
||||
=============================== */
|
||||
|
||||
/* LIGHT MODE (default) */
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* DARK MODE */
|
||||
body.dark {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
BASE STYLES
|
||||
=============================== */
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
background-color: var(--background);
|
||||
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";
|
||||
|
||||
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",
|
||||
};
|
||||
import Navbar from "@/components/Navbar";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<body>
|
||||
<Navbar />
|
||||
{children}
|
||||
</body>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
168
src/app/page.tsx
168
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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<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">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
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.
|
||||
<main className="max-w-7xl mx-auto px-6 py-20 space-y-32">
|
||||
{/* ================= HERO ================= */}
|
||||
<section className="text-center max-w-3xl mx-auto">
|
||||
<h1 className="text-5xl md:text-6xl font-extrabold tracking-tight">
|
||||
Welcome to Surafino
|
||||
</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{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&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"
|
||||
>
|
||||
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 className="mt-6 text-lg text-gray-600 dark:text-gray-400">
|
||||
A modern blog and news platform focused on technology,
|
||||
development and digital projects.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
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="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
<div className="mt-10 flex justify-center gap-4">
|
||||
<Link
|
||||
href="/news"
|
||||
className="px-6 py-3 rounded-xl bg-blue-600 text-white font-semibold hover:bg-blue-700 transition"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
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"
|
||||
Read articles
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/news"
|
||||
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"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
View news
|
||||
</Link>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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