diff --git a/build.sh b/build.sh
index 75e9d97..3d9a7ae 100644
--- a/build.sh
+++ b/build.sh
@@ -1,2 +1,2 @@
-APP_VERSION=1.0.0
+APP_VERSION=1.0.1
docker buildx build --platform linux/arm64 -f nextjs/Dockerfile -t cfc-web:$APP_VERSION .
\ No newline at end of file
diff --git a/infra/db/admin_auth.sql b/infra/db/admin_auth.sql
new file mode 100644
index 0000000..e177865
--- /dev/null
+++ b/infra/db/admin_auth.sql
@@ -0,0 +1,42 @@
+-- Admin users and sessions for the Next.js admin area.
+-- Run after catherine_league schema exists: mysql ... < admin_auth.sql
+
+USE `catherine_league`;
+
+-- Passwords stored as bcrypt hashes (e.g. from bcryptjs), never plaintext.
+CREATE TABLE IF NOT EXISTS `admin_users` (
+ `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `email` VARCHAR(255) NOT NULL,
+ `password_hash` VARCHAR(255) NOT NULL,
+ `is_approved` TINYINT(1) NOT NULL DEFAULT 0,
+ `is_admin` TINYINT(1) NOT NULL DEFAULT 0,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_admin_users_email` (`email`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Opaque session tokens: store SHA-256 hex of the cookie value; never store raw tokens.
+CREATE TABLE IF NOT EXISTS `admin_sessions` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `user_id` INT UNSIGNED NOT NULL,
+ `token_hash` CHAR(64) NOT NULL,
+ `expires_at` DATETIME NOT NULL,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_admin_sessions_token_hash` (`token_hash`),
+ KEY `idx_admin_sessions_user_id` (`user_id`),
+ KEY `idx_admin_sessions_expires_at` (`expires_at`),
+ CONSTRAINT `fk_admin_sessions_user`
+ FOREIGN KEY (`user_id`) REFERENCES `admin_users` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Seed: first admin (change password immediately in production).
+-- Default password: ChangeMeOnFirstLogin!
+INSERT INTO `admin_users` (`email`, `password_hash`, `is_approved`, `is_admin`)
+SELECT
+ 'admin@localhost',
+ '$2b$12$H6Y71zX/nmefp33e0MaMaOOBGSiVwxVE3L.Ie3Pfq1/6QZdLR7bTa',
+ 1,
+ 1
+WHERE NOT EXISTS (SELECT 1 FROM `admin_users` WHERE `email` = 'admin@localhost' LIMIT 1);
diff --git a/nextjs/.env.example b/nextjs/.env.example
index e268349..21b57ec 100644
--- a/nextjs/.env.example
+++ b/nextjs/.env.example
@@ -3,3 +3,5 @@ DB_ENDPOINT=localhost
DB_PORT=3306
DB_USER=
DB_PASS=
+
+# After DB is up, apply admin tables (see infra/db/admin_auth.sql)
diff --git a/nextjs/package.json b/nextjs/package.json
index 5596895..555eab5 100644
--- a/nextjs/package.json
+++ b/nextjs/package.json
@@ -9,6 +9,7 @@
"lint": "next lint"
},
"dependencies": {
+ "bcryptjs": "^3.0.3",
"dotenv": "^16.4.5",
"i18next": "^23.10.1",
"mysql2": "^3.11.0",
@@ -20,6 +21,7 @@
"semantic-ui-css": "^2.5.0"
},
"devDependencies": {
+ "@types/bcryptjs": "^2.4.6",
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
diff --git a/nextjs/src/app/about/page.tsx b/nextjs/src/app/(site)/about/page.tsx
similarity index 100%
rename from nextjs/src/app/about/page.tsx
rename to nextjs/src/app/(site)/about/page.tsx
diff --git a/nextjs/src/app/archive/page.tsx b/nextjs/src/app/(site)/archive/page.tsx
similarity index 100%
rename from nextjs/src/app/archive/page.tsx
rename to nextjs/src/app/(site)/archive/page.tsx
diff --git a/nextjs/src/app/contact/page.tsx b/nextjs/src/app/(site)/contact/page.tsx
similarity index 100%
rename from nextjs/src/app/contact/page.tsx
rename to nextjs/src/app/(site)/contact/page.tsx
diff --git a/nextjs/src/app/guide/page.tsx b/nextjs/src/app/(site)/guide/page.tsx
similarity index 100%
rename from nextjs/src/app/guide/page.tsx
rename to nextjs/src/app/(site)/guide/page.tsx
diff --git a/nextjs/src/app/(site)/layout.tsx b/nextjs/src/app/(site)/layout.tsx
new file mode 100644
index 0000000..8070e5a
--- /dev/null
+++ b/nextjs/src/app/(site)/layout.tsx
@@ -0,0 +1,7 @@
+import type { ReactNode } from 'react';
+
+import { SiteLayout } from '@/components/SiteLayout';
+
+export default function SiteChromeLayout({ children }: { children: ReactNode }) {
+ return {children} ;
+}
diff --git a/nextjs/src/app/page.tsx b/nextjs/src/app/(site)/page.tsx
similarity index 83%
rename from nextjs/src/app/page.tsx
rename to nextjs/src/app/(site)/page.tsx
index 2e3d61d..fe86754 100644
--- a/nextjs/src/app/page.tsx
+++ b/nextjs/src/app/(site)/page.tsx
@@ -3,18 +3,18 @@ import style from '@/styles/web.module.scss';
export default function HomePage() {
return (
-
+
diff --git a/nextjs/src/app/players/page.tsx b/nextjs/src/app/(site)/players/page.tsx
similarity index 100%
rename from nextjs/src/app/players/page.tsx
rename to nextjs/src/app/(site)/players/page.tsx
diff --git a/nextjs/src/app/tournaments/[tournament_key]/players/page.tsx b/nextjs/src/app/(site)/tournaments/[tournament_key]/players/page.tsx
similarity index 100%
rename from nextjs/src/app/tournaments/[tournament_key]/players/page.tsx
rename to nextjs/src/app/(site)/tournaments/[tournament_key]/players/page.tsx
diff --git a/nextjs/src/app/tournaments/[tournament_key]/scoreboard/page.tsx b/nextjs/src/app/(site)/tournaments/[tournament_key]/scoreboard/page.tsx
similarity index 100%
rename from nextjs/src/app/tournaments/[tournament_key]/scoreboard/page.tsx
rename to nextjs/src/app/(site)/tournaments/[tournament_key]/scoreboard/page.tsx
diff --git a/nextjs/src/app/tournaments/page.tsx b/nextjs/src/app/(site)/tournaments/page.tsx
similarity index 100%
rename from nextjs/src/app/tournaments/page.tsx
rename to nextjs/src/app/(site)/tournaments/page.tsx
diff --git a/nextjs/src/app/admin/(dashboard)/archive/ArchiveAdminClient.tsx b/nextjs/src/app/admin/(dashboard)/archive/ArchiveAdminClient.tsx
new file mode 100644
index 0000000..01d5c53
--- /dev/null
+++ b/nextjs/src/app/admin/(dashboard)/archive/ArchiveAdminClient.tsx
@@ -0,0 +1,147 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+
+import Link from 'next/link';
+
+import style from '@/styles/admin.module.scss';
+
+type ArchiveRow = {
+ id: number;
+ title: string;
+ youtube_id: string;
+};
+
+export function ArchiveAdminClient() {
+ const [items, setItems] = useState
([]);
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [title, setTitle] = useState('');
+ const [youtubeId, setYoutubeId] = useState('');
+
+ const load = useCallback(async () => {
+ setError('');
+ const res = await fetch('/api/admin/archive', { credentials: 'include' });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError((data as { error?: string }).error ?? 'Failed to load');
+ setLoading(false);
+ return;
+ }
+ setItems((data as { items: ArchiveRow[] }).items ?? []);
+ setLoading(false);
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ function updateLocal(id: number, patch: Partial) {
+ setItems((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
+ }
+
+ async function save(row: ArchiveRow) {
+ setError('');
+ const res = await fetch(`/api/admin/archive/${row.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ title: row.title, youtube_id: row.youtube_id }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError((data as { error?: string }).error ?? 'Save failed');
+ return;
+ }
+ await load();
+ }
+
+ async function remove(id: number) {
+ if (!window.confirm('Delete?')) return;
+ setError('');
+ const res = await fetch(`/api/admin/archive/${id}`, { method: 'DELETE', credentials: 'include' });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ setError((data as { error?: string }).error ?? 'Delete failed');
+ return;
+ }
+ await load();
+ }
+
+ async function add(e: React.FormEvent) {
+ e.preventDefault();
+ setError('');
+ const res = await fetch('/api/admin/archive', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ title, youtube_id: youtubeId }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError((data as { error?: string }).error ?? 'Create failed');
+ return;
+ }
+ setTitle('');
+ setYoutubeId('');
+ await load();
+ }
+
+ return (
+
+
+ ← Dashboard
+
+
Archive
+ {error ?
{error}
: null}
+ {loading ?
Loading…
: null}
+ {!loading &&
+ items.map((row) => (
+
+ ))}
+
+
+
+ New entry
+
+
+
+ );
+}
diff --git a/nextjs/src/app/admin/(dashboard)/archive/page.tsx b/nextjs/src/app/admin/(dashboard)/archive/page.tsx
new file mode 100644
index 0000000..162492e
--- /dev/null
+++ b/nextjs/src/app/admin/(dashboard)/archive/page.tsx
@@ -0,0 +1,8 @@
+import { requireAdmin } from '@/lib/auth/requireAdmin';
+
+import { ArchiveAdminClient } from './ArchiveAdminClient';
+
+export default async function AdminArchivePage() {
+ await requireAdmin();
+ return ;
+}
diff --git a/nextjs/src/app/admin/(dashboard)/guide/GuideAdminClient.tsx b/nextjs/src/app/admin/(dashboard)/guide/GuideAdminClient.tsx
new file mode 100644
index 0000000..ee3262e
--- /dev/null
+++ b/nextjs/src/app/admin/(dashboard)/guide/GuideAdminClient.tsx
@@ -0,0 +1,170 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+
+import Link from 'next/link';
+
+import style from '@/styles/admin.module.scss';
+
+type GuideRow = {
+ id: number;
+ title: string;
+ description: string | null;
+ youtube_id: string;
+};
+
+export function GuideAdminClient() {
+ const [items, setItems] = useState([]);
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [youtubeId, setYoutubeId] = useState('');
+
+ const load = useCallback(async () => {
+ setError('');
+ const res = await fetch('/api/admin/guide', { credentials: 'include' });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError((data as { error?: string }).error ?? 'Failed to load');
+ setLoading(false);
+ return;
+ }
+ setItems((data as { items: GuideRow[] }).items ?? []);
+ setLoading(false);
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ function updateLocal(id: number, patch: Partial) {
+ setItems((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
+ }
+
+ async function save(row: GuideRow) {
+ setError('');
+ const res = await fetch(`/api/admin/guide/${row.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ title: row.title,
+ description: row.description ?? '',
+ youtube_id: row.youtube_id,
+ }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError((data as { error?: string }).error ?? 'Save failed');
+ return;
+ }
+ await load();
+ }
+
+ async function remove(id: number) {
+ if (!window.confirm('Delete this entry?')) return;
+ setError('');
+ const res = await fetch(`/api/admin/guide/${id}`, { method: 'DELETE', credentials: 'include' });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ setError((data as { error?: string }).error ?? 'Delete failed');
+ return;
+ }
+ await load();
+ }
+
+ async function add(e: React.FormEvent) {
+ e.preventDefault();
+ setError('');
+ const res = await fetch('/api/admin/guide', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ title, description, youtube_id: youtubeId }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError((data as { error?: string }).error ?? 'Create failed');
+ return;
+ }
+ setTitle('');
+ setDescription('');
+ setYoutubeId('');
+ await load();
+ }
+
+ return (
+
+
+ ← Dashboard
+
+
Guide
+ {error ?
{error}
: null}
+ {loading ?
Loading…
: null}
+ {!loading &&
+ items.map((row) => (
+
+
+ title
+ updateLocal(row.id, { title: e.target.value })}
+ />
+
+
+ description
+
+
+ youtube_id
+ updateLocal(row.id, { youtube_id: e.target.value })}
+ />
+
+
+ save(row)}>
+ Save
+
+ remove(row.id)}>
+ Delete
+
+
+
+ ))}
+
+
+
+ New entry
+
+
+
+ );
+}
diff --git a/nextjs/src/app/admin/(dashboard)/guide/page.tsx b/nextjs/src/app/admin/(dashboard)/guide/page.tsx
new file mode 100644
index 0000000..31b9edb
--- /dev/null
+++ b/nextjs/src/app/admin/(dashboard)/guide/page.tsx
@@ -0,0 +1,8 @@
+import { requireAdmin } from '@/lib/auth/requireAdmin';
+
+import { GuideAdminClient } from './GuideAdminClient';
+
+export default async function AdminGuidePage() {
+ await requireAdmin();
+ return ;
+}
diff --git a/nextjs/src/app/admin/(dashboard)/layout.tsx b/nextjs/src/app/admin/(dashboard)/layout.tsx
new file mode 100644
index 0000000..b21ff83
--- /dev/null
+++ b/nextjs/src/app/admin/(dashboard)/layout.tsx
@@ -0,0 +1,8 @@
+import type { ReactNode } from 'react';
+
+import { requireLogin } from '@/lib/auth/requireAdmin';
+
+export default async function AdminDashboardLayout({ children }: { children: ReactNode }) {
+ await requireLogin();
+ return <>{children}>;
+}
diff --git a/nextjs/src/app/admin/(dashboard)/page.tsx b/nextjs/src/app/admin/(dashboard)/page.tsx
new file mode 100644
index 0000000..2ccb58b
--- /dev/null
+++ b/nextjs/src/app/admin/(dashboard)/page.tsx
@@ -0,0 +1,49 @@
+import Link from 'next/link';
+
+import { LogoutButton } from '@/components/admin/LogoutButton';
+import { getSessionUser } from '@/lib/auth/session';
+import style from '@/styles/admin.module.scss';
+
+export default async function AdminDashboardPage() {
+ const user = await getSessionUser();
+ if (!user) {
+ return null;
+ }
+
+ return (
+
+
Admin
+
+ Signed in as {user.email}
+ {user.is_admin ? ' (administrator)' : ''}
+
+ {!user.is_admin && (
+
+ You do not have permission to edit content. Ask an administrator to grant admin access.
+
+ )}
+ {user.is_admin && (
+
+
+ Users (approval & roles)
+
+
+ Players
+
+
+ Guide
+
+
+ Archive
+
+
+ Q&A
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/nextjs/src/app/admin/(dashboard)/players/PlayersAdminClient.tsx b/nextjs/src/app/admin/(dashboard)/players/PlayersAdminClient.tsx
new file mode 100644
index 0000000..72475a9
--- /dev/null
+++ b/nextjs/src/app/admin/(dashboard)/players/PlayersAdminClient.tsx
@@ -0,0 +1,184 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+
+import Link from 'next/link';
+
+import style from '@/styles/admin.module.scss';
+
+type Char = { id: number; name_id: string; name: string; name_jp: string };
+type PlayerRow = {
+ id: number;
+ player_key: string;
+ player_name: string;
+ description: string | null;
+ image: string | null;
+ character_id: string | null;
+ name_id?: string;
+ name_jp?: string;
+};
+
+export function PlayersAdminClient() {
+ const [players, setPlayers] = useState([]);
+ const [characters, setCharacters] = useState([]);
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(true);
+
+ const [playerKey, setPlayerKey] = useState('');
+ const [playerName, setPlayerName] = useState('');
+ const [description, setDescription] = useState('');
+ const [image, setImage] = useState('');
+ const [characterId, setCharacterId] = useState('');
+
+ const load = useCallback(async () => {
+ setError('');
+ const [pr, cr] = await Promise.all([
+ fetch('/api/admin/players', { credentials: 'include' }),
+ fetch('/api/admin/characters', { credentials: 'include' }),
+ ]);
+ const pj = await pr.json().catch(() => ({}));
+ const cj = await cr.json().catch(() => ({}));
+ if (!pr.ok) {
+ setError((pj as { error?: string }).error ?? 'Failed to load players');
+ setLoading(false);
+ return;
+ }
+ if (!cr.ok) {
+ setError((cj as { error?: string }).error ?? 'Failed to load characters');
+ setLoading(false);
+ return;
+ }
+ setPlayers((pj as { players: PlayerRow[] }).players ?? []);
+ setCharacters((cj as { characters: Char[] }).characters ?? []);
+ setLoading(false);
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ async function addPlayer(e: React.FormEvent) {
+ e.preventDefault();
+ setError('');
+ const res = await fetch('/api/admin/players', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ player_key: playerKey,
+ player_name: playerName,
+ description,
+ image: image || null,
+ character_id: characterId,
+ }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError((data as { error?: string }).error ?? 'Create failed');
+ return;
+ }
+ setPlayerKey('');
+ setPlayerName('');
+ setDescription('');
+ setImage('');
+ setCharacterId('');
+ await load();
+ }
+
+ async function remove(id: number) {
+ if (!window.confirm('Delete this player?')) return;
+ setError('');
+ const res = await fetch(`/api/admin/players/${id}`, { method: 'DELETE', credentials: 'include' });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError((data as { error?: string }).error ?? 'Delete failed');
+ return;
+ }
+ await load();
+ }
+
+ return (
+
+
+ ← Dashboard
+
+
Players
+ {error ?
{error}
: null}
+ {loading ?
Loading…
: null}
+ {!loading && (
+ <>
+
+
+
+ Key
+ Name
+ Character
+
+
+
+
+ {players.map((p) => (
+
+ {p.player_key}
+ {p.player_name}
+ {p.name_jp ?? p.character_id}
+
+ remove(p.id)}>
+ Delete
+
+
+
+ ))}
+
+
+
+
+
+ Add player
+
+
+
+ player_key
+ setPlayerKey(e.target.value)} required />
+
+
+ player_name
+ setPlayerName(e.target.value)} required />
+
+
+ description
+ setDescription(e.target.value)}
+ />
+
+
+ image URL (optional)
+ setImage(e.target.value)} />
+
+
+ character
+ setCharacterId(e.target.value)}
+ required
+ >
+ —
+ {characters.map((c) => (
+
+ {c.name_jp} ({c.name_id})
+
+ ))}
+
+
+
+ Add
+
+
+ >
+ )}
+
+ );
+}
diff --git a/nextjs/src/app/admin/(dashboard)/players/page.tsx b/nextjs/src/app/admin/(dashboard)/players/page.tsx
new file mode 100644
index 0000000..e5610d2
--- /dev/null
+++ b/nextjs/src/app/admin/(dashboard)/players/page.tsx
@@ -0,0 +1,8 @@
+import { requireAdmin } from '@/lib/auth/requireAdmin';
+
+import { PlayersAdminClient } from './PlayersAdminClient';
+
+export default async function AdminPlayersPage() {
+ await requireAdmin();
+ return ;
+}
diff --git a/nextjs/src/app/admin/(dashboard)/qa/QaAdminClient.tsx b/nextjs/src/app/admin/(dashboard)/qa/QaAdminClient.tsx
new file mode 100644
index 0000000..1498a28
--- /dev/null
+++ b/nextjs/src/app/admin/(dashboard)/qa/QaAdminClient.tsx
@@ -0,0 +1,157 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+
+import Link from 'next/link';
+
+import style from '@/styles/admin.module.scss';
+
+type ContactRow = {
+ id: number;
+ question: string;
+ answer: string;
+};
+
+export function QaAdminClient() {
+ const [items, setItems] = useState([]);
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [question, setQuestion] = useState('');
+ const [answer, setAnswer] = useState('');
+
+ const load = useCallback(async () => {
+ setError('');
+ const res = await fetch('/api/admin/contact', { credentials: 'include' });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError((data as { error?: string }).error ?? 'Failed to load');
+ setLoading(false);
+ return;
+ }
+ setItems((data as { items: ContactRow[] }).items ?? []);
+ setLoading(false);
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ function updateLocal(id: number, patch: Partial) {
+ setItems((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
+ }
+
+ async function save(row: ContactRow) {
+ setError('');
+ const res = await fetch(`/api/admin/contact/${row.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ question: row.question, answer: row.answer }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError((data as { error?: string }).error ?? 'Save failed');
+ return;
+ }
+ await load();
+ }
+
+ async function remove(id: number) {
+ if (!window.confirm('Delete?')) return;
+ setError('');
+ const res = await fetch(`/api/admin/contact/${id}`, { method: 'DELETE', credentials: 'include' });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ setError((data as { error?: string }).error ?? 'Delete failed');
+ return;
+ }
+ await load();
+ }
+
+ async function add(e: React.FormEvent) {
+ e.preventDefault();
+ setError('');
+ const res = await fetch('/api/admin/contact', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ question, answer }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError((data as { error?: string }).error ?? 'Create failed');
+ return;
+ }
+ setQuestion('');
+ setAnswer('');
+ await load();
+ }
+
+ return (
+
+
+ ← Dashboard
+
+
Q&A
+ {error ?
{error}
: null}
+ {loading ?
Loading…
: null}
+ {!loading &&
+ items.map((row) => (
+
+
+ question
+ updateLocal(row.id, { question: e.target.value })}
+ />
+
+
+ answer
+ updateLocal(row.id, { answer: e.target.value })}
+ />
+
+
+ save(row)}>
+ Save
+
+ remove(row.id)}>
+ Delete
+
+
+
+ ))}
+
+
+
+ New Q&A
+
+
+
+ question
+ setQuestion(e.target.value)}
+ required
+ />
+
+
+ answer
+ setAnswer(e.target.value)}
+ required
+ />
+
+
+ Add
+
+
+
+ );
+}
diff --git a/nextjs/src/app/admin/(dashboard)/qa/page.tsx b/nextjs/src/app/admin/(dashboard)/qa/page.tsx
new file mode 100644
index 0000000..3e25cad
--- /dev/null
+++ b/nextjs/src/app/admin/(dashboard)/qa/page.tsx
@@ -0,0 +1,8 @@
+import { requireAdmin } from '@/lib/auth/requireAdmin';
+
+import { QaAdminClient } from './QaAdminClient';
+
+export default async function AdminQaPage() {
+ await requireAdmin();
+ return ;
+}
diff --git a/nextjs/src/app/admin/(dashboard)/users/UsersAdminClient.tsx b/nextjs/src/app/admin/(dashboard)/users/UsersAdminClient.tsx
new file mode 100644
index 0000000..ff719cb
--- /dev/null
+++ b/nextjs/src/app/admin/(dashboard)/users/UsersAdminClient.tsx
@@ -0,0 +1,100 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+
+import Link from 'next/link';
+
+import style from '@/styles/admin.module.scss';
+
+type UserRow = {
+ id: number;
+ email: string;
+ is_approved: number | boolean;
+ is_admin: number | boolean;
+ created_at: string;
+};
+
+export function UsersAdminClient() {
+ const [users, setUsers] = useState([]);
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(true);
+
+ const load = useCallback(async () => {
+ setError('');
+ const res = await fetch('/api/admin/users', { credentials: 'include' });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError((data as { error?: string }).error ?? 'Failed to load');
+ setLoading(false);
+ return;
+ }
+ setUsers((data as { users: UserRow[] }).users ?? []);
+ setLoading(false);
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ async function patch(id: number, body: { is_approved?: boolean; is_admin?: boolean }) {
+ setError('');
+ const res = await fetch(`/api/admin/users/${id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(body),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError((data as { error?: string }).error ?? 'Update failed');
+ return;
+ }
+ await load();
+ }
+
+ return (
+
+
+ ← Dashboard
+
+
Users
+
Approve new accounts and grant admin role.
+ {error ?
{error}
: null}
+ {loading ?
Loading…
: null}
+ {!loading && (
+
+ )}
+
+ );
+}
diff --git a/nextjs/src/app/admin/(dashboard)/users/page.tsx b/nextjs/src/app/admin/(dashboard)/users/page.tsx
new file mode 100644
index 0000000..1b314c3
--- /dev/null
+++ b/nextjs/src/app/admin/(dashboard)/users/page.tsx
@@ -0,0 +1,8 @@
+import { requireAdmin } from '@/lib/auth/requireAdmin';
+
+import { UsersAdminClient } from './UsersAdminClient';
+
+export default async function AdminUsersPage() {
+ await requireAdmin();
+ return ;
+}
diff --git a/nextjs/src/app/admin/layout.tsx b/nextjs/src/app/admin/layout.tsx
new file mode 100644
index 0000000..8e2d25b
--- /dev/null
+++ b/nextjs/src/app/admin/layout.tsx
@@ -0,0 +1,25 @@
+import type { Metadata } from 'next';
+import type { ReactNode } from 'react';
+
+import Link from 'next/link';
+
+import style from '@/styles/admin.module.scss';
+
+export const metadata: Metadata = {
+ title: 'Admin — Catherine League',
+ robots: 'noindex, nofollow',
+};
+
+export default function AdminRootLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+ ← Site
+ Login
+ Register
+ Dashboard
+
+ {children}
+
+ );
+}
diff --git a/nextjs/src/app/admin/login/page.tsx b/nextjs/src/app/admin/login/page.tsx
new file mode 100644
index 0000000..ba96880
--- /dev/null
+++ b/nextjs/src/app/admin/login/page.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+
+import Link from 'next/link';
+
+import style from '@/styles/admin.module.scss';
+
+export default function AdminLoginPage() {
+ const router = useRouter();
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ async function onSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+ try {
+ const res = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password }),
+ credentials: 'include',
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError((data as { error?: string }).error ?? 'Login failed');
+ setLoading(false);
+ return;
+ }
+ router.push('/admin');
+ router.refresh();
+ } catch {
+ setError('Network error');
+ }
+ setLoading(false);
+ }
+
+ return (
+
+
Admin login
+
+ No account? Register — an administrator must approve you before you can sign in.
+
+
+
+ Email
+ setEmail(e.target.value)}
+ required
+ />
+
+
+ Password
+ setPassword(e.target.value)}
+ required
+ />
+
+ {error ? {error}
: null}
+
+ {loading ? 'Signing in…' : 'Sign in'}
+
+
+
+ );
+}
diff --git a/nextjs/src/app/admin/no-access/page.tsx b/nextjs/src/app/admin/no-access/page.tsx
new file mode 100644
index 0000000..f4fd328
--- /dev/null
+++ b/nextjs/src/app/admin/no-access/page.tsx
@@ -0,0 +1,18 @@
+import Link from 'next/link';
+
+import style from '@/styles/admin.module.scss';
+
+export default function AdminNoAccessPage() {
+ return (
+
+
No admin access
+
+ Your account is approved, but you do not have administrator privileges for content management. Ask an existing admin to
+ grant you the admin role.
+
+
+ Dashboard
+
+
+ );
+}
diff --git a/nextjs/src/app/admin/pending/page.tsx b/nextjs/src/app/admin/pending/page.tsx
new file mode 100644
index 0000000..08accc1
--- /dev/null
+++ b/nextjs/src/app/admin/pending/page.tsx
@@ -0,0 +1,18 @@
+import Link from 'next/link';
+
+import style from '@/styles/admin.module.scss';
+
+export default function AdminPendingPage() {
+ return (
+
+
Account pending
+
+ Your account has not been approved yet, or approval was revoked. You cannot use the admin area until an administrator
+ approves you.
+
+
+ Back to login
+
+
+ );
+}
diff --git a/nextjs/src/app/admin/register/page.tsx b/nextjs/src/app/admin/register/page.tsx
new file mode 100644
index 0000000..35d5e1a
--- /dev/null
+++ b/nextjs/src/app/admin/register/page.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+import { useState } from 'react';
+
+import Link from 'next/link';
+
+import style from '@/styles/admin.module.scss';
+
+export default function AdminRegisterPage() {
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+ const [success, setSuccess] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ async function onSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError('');
+ setSuccess('');
+ setLoading(true);
+ try {
+ const res = await fetch('/api/auth/register', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError((data as { error?: string }).error ?? 'Registration failed');
+ setLoading(false);
+ return;
+ }
+ setSuccess((data as { message?: string }).message ?? 'Registered.');
+ setEmail('');
+ setPassword('');
+ } catch {
+ setError('Network error');
+ }
+ setLoading(false);
+ }
+
+ return (
+
+
Register
+
+ After registering, wait for an administrator to approve your account. Then you can{' '}
+ sign in.
+
+
+
+ Email
+ setEmail(e.target.value)}
+ required
+ />
+
+
+ Password (min. 8 characters)
+ setPassword(e.target.value)}
+ minLength={8}
+ required
+ />
+
+ {error ? {error}
: null}
+ {success ? {success}
: null}
+
+ {loading ? 'Submitting…' : 'Register'}
+
+
+
+ );
+}
diff --git a/nextjs/src/app/api/admin/archive/[id]/route.ts b/nextjs/src/app/api/admin/archive/[id]/route.ts
new file mode 100644
index 0000000..a44eab6
--- /dev/null
+++ b/nextjs/src/app/api/admin/archive/[id]/route.ts
@@ -0,0 +1,63 @@
+import { NextResponse } from 'next/server';
+import type { RowDataPacket } from 'mysql2';
+
+import { requireAdminApi } from '@/lib/auth/apiAuth';
+import { getPool } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+type Ctx = { params: Promise<{ id: string }> };
+
+export async function PATCH(request: Request, context: Ctx) {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const { id: idParam } = await context.params;
+ const id = parseInt(idParam, 10);
+ if (Number.isNaN(id)) {
+ return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
+ }
+
+ const body = await request.json();
+ const updates: string[] = [];
+ const values: unknown[] = [];
+
+ if (body.title !== undefined) {
+ updates.push('title = ?');
+ values.push(String(body.title).trim());
+ }
+ if (body.youtube_id !== undefined) {
+ updates.push('youtube_id = ?');
+ values.push(String(body.youtube_id).trim());
+ }
+
+ if (updates.length === 0) {
+ return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
+ }
+
+ values.push(id);
+ const pool = getPool();
+ await pool.query(`UPDATE archive SET ${updates.join(', ')} WHERE id = ?`, values);
+
+ const [rows] = await pool.query('SELECT * FROM archive WHERE id = ?', [id]);
+ return NextResponse.json({ item: rows[0] });
+}
+
+export async function DELETE(_request: Request, context: Ctx) {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const { id: idParam } = await context.params;
+ const id = parseInt(idParam, 10);
+ if (Number.isNaN(id)) {
+ return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
+ }
+
+ const pool = getPool();
+ await pool.query('DELETE FROM archive WHERE id = ?', [id]);
+ return NextResponse.json({ ok: true });
+}
diff --git a/nextjs/src/app/api/admin/archive/route.ts b/nextjs/src/app/api/admin/archive/route.ts
new file mode 100644
index 0000000..ec20e6c
--- /dev/null
+++ b/nextjs/src/app/api/admin/archive/route.ts
@@ -0,0 +1,37 @@
+import { NextResponse } from 'next/server';
+import type { RowDataPacket } from 'mysql2';
+
+import { requireAdminApi } from '@/lib/auth/apiAuth';
+import { getPool } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET() {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const pool = getPool();
+ const [rows] = await pool.query('SELECT * FROM archive ORDER BY id ASC');
+ return NextResponse.json({ items: rows });
+}
+
+export async function POST(request: Request) {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const body = await request.json();
+ const title = String(body.title ?? '').trim();
+ const youtubeId = String(body.youtube_id ?? '').trim();
+
+ if (!title || !youtubeId) {
+ return NextResponse.json({ error: 'title and youtube_id required' }, { status: 400 });
+ }
+
+ const pool = getPool();
+ await pool.query('INSERT INTO archive (title, youtube_id) VALUES (?, ?)', [title, youtubeId]);
+ return NextResponse.json({ ok: true });
+}
diff --git a/nextjs/src/app/api/admin/characters/route.ts b/nextjs/src/app/api/admin/characters/route.ts
new file mode 100644
index 0000000..8bc125a
--- /dev/null
+++ b/nextjs/src/app/api/admin/characters/route.ts
@@ -0,0 +1,20 @@
+import { NextResponse } from 'next/server';
+import type { RowDataPacket } from 'mysql2';
+
+import { requireAdminApi } from '@/lib/auth/apiAuth';
+import { getPool } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET() {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const pool = getPool();
+ const [rows] = await pool.query(
+ 'SELECT id, name_id, name, name_jp FROM characters ORDER BY id ASC'
+ );
+ return NextResponse.json({ characters: rows });
+}
diff --git a/nextjs/src/app/api/admin/contact/[id]/route.ts b/nextjs/src/app/api/admin/contact/[id]/route.ts
new file mode 100644
index 0000000..f368b5e
--- /dev/null
+++ b/nextjs/src/app/api/admin/contact/[id]/route.ts
@@ -0,0 +1,63 @@
+import { NextResponse } from 'next/server';
+import type { RowDataPacket } from 'mysql2';
+
+import { requireAdminApi } from '@/lib/auth/apiAuth';
+import { getPool } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+type Ctx = { params: Promise<{ id: string }> };
+
+export async function PATCH(request: Request, context: Ctx) {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const { id: idParam } = await context.params;
+ const id = parseInt(idParam, 10);
+ if (Number.isNaN(id)) {
+ return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
+ }
+
+ const body = await request.json();
+ const updates: string[] = [];
+ const values: unknown[] = [];
+
+ if (body.question !== undefined) {
+ updates.push('question = ?');
+ values.push(String(body.question).trim());
+ }
+ if (body.answer !== undefined) {
+ updates.push('answer = ?');
+ values.push(String(body.answer).trim());
+ }
+
+ if (updates.length === 0) {
+ return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
+ }
+
+ values.push(id);
+ const pool = getPool();
+ await pool.query(`UPDATE contact SET ${updates.join(', ')} WHERE id = ?`, values);
+
+ const [rows] = await pool.query('SELECT * FROM contact WHERE id = ?', [id]);
+ return NextResponse.json({ item: rows[0] });
+}
+
+export async function DELETE(_request: Request, context: Ctx) {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const { id: idParam } = await context.params;
+ const id = parseInt(idParam, 10);
+ if (Number.isNaN(id)) {
+ return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
+ }
+
+ const pool = getPool();
+ await pool.query('DELETE FROM contact WHERE id = ?', [id]);
+ return NextResponse.json({ ok: true });
+}
diff --git a/nextjs/src/app/api/admin/contact/route.ts b/nextjs/src/app/api/admin/contact/route.ts
new file mode 100644
index 0000000..ff9556b
--- /dev/null
+++ b/nextjs/src/app/api/admin/contact/route.ts
@@ -0,0 +1,37 @@
+import { NextResponse } from 'next/server';
+import type { RowDataPacket } from 'mysql2';
+
+import { requireAdminApi } from '@/lib/auth/apiAuth';
+import { getPool } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET() {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const pool = getPool();
+ const [rows] = await pool.query('SELECT * FROM contact ORDER BY id ASC');
+ return NextResponse.json({ items: rows });
+}
+
+export async function POST(request: Request) {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const body = await request.json();
+ const question = String(body.question ?? '').trim();
+ const answer = String(body.answer ?? '').trim();
+
+ if (!question || !answer) {
+ return NextResponse.json({ error: 'question and answer required' }, { status: 400 });
+ }
+
+ const pool = getPool();
+ await pool.query('INSERT INTO contact (question, answer) VALUES (?, ?)', [question, answer]);
+ return NextResponse.json({ ok: true });
+}
diff --git a/nextjs/src/app/api/admin/guide/[id]/route.ts b/nextjs/src/app/api/admin/guide/[id]/route.ts
new file mode 100644
index 0000000..116c58f
--- /dev/null
+++ b/nextjs/src/app/api/admin/guide/[id]/route.ts
@@ -0,0 +1,67 @@
+import { NextResponse } from 'next/server';
+import type { RowDataPacket } from 'mysql2';
+
+import { requireAdminApi } from '@/lib/auth/apiAuth';
+import { getPool } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+type Ctx = { params: Promise<{ id: string }> };
+
+export async function PATCH(request: Request, context: Ctx) {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const { id: idParam } = await context.params;
+ const id = parseInt(idParam, 10);
+ if (Number.isNaN(id)) {
+ return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
+ }
+
+ const body = await request.json();
+ const updates: string[] = [];
+ const values: unknown[] = [];
+
+ if (body.title !== undefined) {
+ updates.push('title = ?');
+ values.push(String(body.title).trim());
+ }
+ if (body.description !== undefined) {
+ updates.push('description = ?');
+ values.push(String(body.description));
+ }
+ if (body.youtube_id !== undefined) {
+ updates.push('youtube_id = ?');
+ values.push(String(body.youtube_id).trim());
+ }
+
+ if (updates.length === 0) {
+ return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
+ }
+
+ values.push(id);
+ const pool = getPool();
+ await pool.query(`UPDATE guide SET ${updates.join(', ')} WHERE id = ?`, values);
+
+ const [rows] = await pool.query('SELECT * FROM guide WHERE id = ?', [id]);
+ return NextResponse.json({ item: rows[0] });
+}
+
+export async function DELETE(_request: Request, context: Ctx) {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const { id: idParam } = await context.params;
+ const id = parseInt(idParam, 10);
+ if (Number.isNaN(id)) {
+ return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
+ }
+
+ const pool = getPool();
+ await pool.query('DELETE FROM guide WHERE id = ?', [id]);
+ return NextResponse.json({ ok: true });
+}
diff --git a/nextjs/src/app/api/admin/guide/route.ts b/nextjs/src/app/api/admin/guide/route.ts
new file mode 100644
index 0000000..71feee1
--- /dev/null
+++ b/nextjs/src/app/api/admin/guide/route.ts
@@ -0,0 +1,41 @@
+import { NextResponse } from 'next/server';
+import type { RowDataPacket } from 'mysql2';
+
+import { requireAdminApi } from '@/lib/auth/apiAuth';
+import { getPool } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET() {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const pool = getPool();
+ const [rows] = await pool.query('SELECT * FROM guide ORDER BY id ASC');
+ return NextResponse.json({ items: rows });
+}
+
+export async function POST(request: Request) {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const body = await request.json();
+ const title = String(body.title ?? '').trim();
+ const description = String(body.description ?? '');
+ const youtubeId = String(body.youtube_id ?? '').trim();
+
+ if (!title || !youtubeId) {
+ return NextResponse.json({ error: 'title and youtube_id required' }, { status: 400 });
+ }
+
+ const pool = getPool();
+ await pool.query(
+ 'INSERT INTO guide (title, description, youtube_id) VALUES (?, ?, ?)',
+ [title, description, youtubeId]
+ );
+ return NextResponse.json({ ok: true });
+}
diff --git a/nextjs/src/app/api/admin/players/[id]/route.ts b/nextjs/src/app/api/admin/players/[id]/route.ts
new file mode 100644
index 0000000..505aa30
--- /dev/null
+++ b/nextjs/src/app/api/admin/players/[id]/route.ts
@@ -0,0 +1,124 @@
+import { NextResponse } from 'next/server';
+import type { RowDataPacket } from 'mysql2';
+
+import { requireAdminApi } from '@/lib/auth/apiAuth';
+import { getPool } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+type Ctx = { params: Promise<{ id: string }> };
+
+export async function GET(_request: Request, context: Ctx) {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const { id: idParam } = await context.params;
+ const id = parseInt(idParam, 10);
+ if (Number.isNaN(id)) {
+ return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
+ }
+
+ const pool = getPool();
+ const [rows] = await pool.query(
+ `SELECT p.id, p.player_key, p.player_name, p.description, p.image, p.character_id,
+ c.name_id, c.name, c.name_jp
+ FROM players p
+ LEFT JOIN characters c ON c.id = p.character_id
+ WHERE p.id = ?`,
+ [id]
+ );
+
+ if (rows.length === 0) {
+ return NextResponse.json({ error: 'Not found' }, { status: 404 });
+ }
+
+ return NextResponse.json({ player: rows[0] });
+}
+
+export async function PATCH(request: Request, context: Ctx) {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const { id: idParam } = await context.params;
+ const id = parseInt(idParam, 10);
+ if (Number.isNaN(id)) {
+ return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
+ }
+
+ const body = await request.json();
+ const updates: string[] = [];
+ const values: unknown[] = [];
+
+ if (body.player_key !== undefined) {
+ updates.push('player_key = ?');
+ values.push(String(body.player_key).trim());
+ }
+ if (body.player_name !== undefined) {
+ updates.push('player_name = ?');
+ values.push(String(body.player_name).trim());
+ }
+ if (body.description !== undefined) {
+ updates.push('description = ?');
+ values.push(String(body.description));
+ }
+ if (body.image !== undefined) {
+ updates.push('image = ?');
+ values.push(body.image == null ? null : String(body.image));
+ }
+ if (body.character_id !== undefined) {
+ updates.push('character_id = ?');
+ values.push(String(parseInt(String(body.character_id), 10)));
+ }
+
+ if (updates.length === 0) {
+ return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
+ }
+
+ values.push(id);
+ const pool = getPool();
+ await pool.query(`UPDATE players SET ${updates.join(', ')} WHERE id = ?`, values);
+
+ const [rows] = await pool.query(
+ `SELECT p.id, p.player_key, p.player_name, p.description, p.image, p.character_id,
+ c.name_id, c.name, c.name_jp
+ FROM players p
+ LEFT JOIN characters c ON c.id = p.character_id
+ WHERE p.id = ?`,
+ [id]
+ );
+
+ return NextResponse.json({ player: rows[0] });
+}
+
+export async function DELETE(_request: Request, context: Ctx) {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const { id: idParam } = await context.params;
+ const id = parseInt(idParam, 10);
+ if (Number.isNaN(id)) {
+ return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
+ }
+
+ const pool = getPool();
+ try {
+ await pool.query('DELETE FROM players WHERE id = ?', [id]);
+ } catch (e: unknown) {
+ const code = (e as { errno?: number })?.errno;
+ if (code === 1451) {
+ return NextResponse.json(
+ { error: 'Cannot delete player: referenced by tournament or other records' },
+ { status: 409 }
+ );
+ }
+ throw e;
+ }
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/nextjs/src/app/api/admin/players/route.ts b/nextjs/src/app/api/admin/players/route.ts
new file mode 100644
index 0000000..caaa4b9
--- /dev/null
+++ b/nextjs/src/app/api/admin/players/route.ts
@@ -0,0 +1,52 @@
+import { NextResponse } from 'next/server';
+import type { RowDataPacket } from 'mysql2';
+
+import { requireAdminApi } from '@/lib/auth/apiAuth';
+import { getPool } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET() {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const pool = getPool();
+ const [rows] = await pool.query(
+ `SELECT p.id, p.player_key, p.player_name, p.description, p.image, p.character_id,
+ c.name_id, c.name, c.name_jp
+ FROM players p
+ LEFT JOIN characters c ON c.id = p.character_id
+ ORDER BY p.id ASC`
+ );
+
+ return NextResponse.json({ players: rows });
+}
+
+export async function POST(request: Request) {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const body = await request.json();
+ const playerKey = String(body.player_key ?? '').trim();
+ const playerName = String(body.player_name ?? '').trim();
+ const description = String(body.description ?? '');
+ const image = body.image != null ? String(body.image) : null;
+ const characterId = parseInt(String(body.character_id), 10);
+
+ if (!playerKey || !playerName || Number.isNaN(characterId)) {
+ return NextResponse.json({ error: 'player_key, player_name, character_id required' }, { status: 400 });
+ }
+
+ const pool = getPool();
+ await pool.query(
+ `INSERT INTO players (player_key, player_name, description, image, character_id)
+ VALUES (?, ?, ?, ?, ?)`,
+ [playerKey, playerName, description, image, String(characterId)]
+ );
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/nextjs/src/app/api/admin/users/[id]/route.ts b/nextjs/src/app/api/admin/users/[id]/route.ts
new file mode 100644
index 0000000..43dd672
--- /dev/null
+++ b/nextjs/src/app/api/admin/users/[id]/route.ts
@@ -0,0 +1,63 @@
+import { NextResponse } from 'next/server';
+import type { RowDataPacket } from 'mysql2';
+
+import { requireAdminApi } from '@/lib/auth/apiAuth';
+import { getPool } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+type Ctx = { params: Promise<{ id: string }> };
+
+export async function PATCH(request: Request, context: Ctx) {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const { id: idParam } = await context.params;
+ const id = parseInt(idParam, 10);
+ if (Number.isNaN(id)) {
+ return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
+ }
+
+ const body = await request.json();
+ const isApproved = typeof body.is_approved === 'boolean' ? body.is_approved : undefined;
+ const isAdmin = typeof body.is_admin === 'boolean' ? body.is_admin : undefined;
+
+ if (isApproved === undefined && isAdmin === undefined) {
+ return NextResponse.json({ error: 'No changes' }, { status: 400 });
+ }
+
+ const pool = getPool();
+
+ if (id === auth.user.id && isApproved === false) {
+ return NextResponse.json({ error: 'Cannot revoke your own approval' }, { status: 400 });
+ }
+ if (id === auth.user.id && isAdmin === false) {
+ return NextResponse.json({ error: 'Cannot remove your own admin role' }, { status: 400 });
+ }
+
+ const updates: string[] = [];
+ const values: unknown[] = [];
+ if (isApproved !== undefined) {
+ updates.push('is_approved = ?');
+ values.push(isApproved ? 1 : 0);
+ }
+ if (isAdmin !== undefined) {
+ updates.push('is_admin = ?');
+ values.push(isAdmin ? 1 : 0);
+ }
+ values.push(id);
+
+ await pool.query(
+ `UPDATE admin_users SET ${updates.join(', ')} WHERE id = ?`,
+ values
+ );
+
+ const [rows] = await pool.query(
+ 'SELECT id, email, is_approved, is_admin, created_at FROM admin_users WHERE id = ?',
+ [id]
+ );
+
+ return NextResponse.json({ user: rows[0] ?? null });
+}
diff --git a/nextjs/src/app/api/admin/users/route.ts b/nextjs/src/app/api/admin/users/route.ts
new file mode 100644
index 0000000..e7be522
--- /dev/null
+++ b/nextjs/src/app/api/admin/users/route.ts
@@ -0,0 +1,23 @@
+import { NextResponse } from 'next/server';
+import type { RowDataPacket } from 'mysql2';
+
+import { requireAdminApi } from '@/lib/auth/apiAuth';
+import { getPool } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET() {
+ const auth = await requireAdminApi();
+ if (!auth.ok) {
+ return auth.response;
+ }
+
+ const pool = getPool();
+ const [rows] = await pool.query(
+ `SELECT id, email, is_approved, is_admin, created_at
+ FROM admin_users
+ ORDER BY created_at DESC`
+ );
+
+ return NextResponse.json({ users: rows });
+}
diff --git a/nextjs/src/app/api/auth/login/route.ts b/nextjs/src/app/api/auth/login/route.ts
new file mode 100644
index 0000000..a21001f
--- /dev/null
+++ b/nextjs/src/app/api/auth/login/route.ts
@@ -0,0 +1,58 @@
+import { NextResponse } from 'next/server';
+
+import { verifyPassword } from '@/lib/auth/password';
+import { createSession } from '@/lib/auth/session';
+import { getPool } from '@/lib/db';
+import type { RowDataPacket } from 'mysql2';
+
+export const dynamic = 'force-dynamic';
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
+ const password = typeof body.password === 'string' ? body.password : '';
+
+ if (!email || !password) {
+ return NextResponse.json({ error: 'Email and password required' }, { status: 400 });
+ }
+
+ const pool = getPool();
+ const [rows] = await pool.query(
+ 'SELECT id, email, password_hash, is_approved, is_admin FROM admin_users WHERE email = ?',
+ [email]
+ );
+
+ if (rows.length === 0) {
+ return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 });
+ }
+
+ const row = rows[0];
+ const ok = verifyPassword(password, row.password_hash as string);
+ if (!ok) {
+ return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 });
+ }
+
+ if (!row.is_approved) {
+ return NextResponse.json(
+ { error: 'Your account is not approved yet.', code: 'PENDING_APPROVAL' },
+ { status: 403 }
+ );
+ }
+
+ await createSession(row.id as number);
+
+ return NextResponse.json({
+ ok: true,
+ user: {
+ id: row.id,
+ email: row.email,
+ is_approved: Boolean(row.is_approved),
+ is_admin: Boolean(row.is_admin),
+ },
+ });
+ } catch (e) {
+ console.error(e);
+ return NextResponse.json({ error: 'Login failed' }, { status: 500 });
+ }
+}
diff --git a/nextjs/src/app/api/auth/logout/route.ts b/nextjs/src/app/api/auth/logout/route.ts
new file mode 100644
index 0000000..1a9ec58
--- /dev/null
+++ b/nextjs/src/app/api/auth/logout/route.ts
@@ -0,0 +1,10 @@
+import { NextResponse } from 'next/server';
+
+import { destroySession } from '@/lib/auth/session';
+
+export const dynamic = 'force-dynamic';
+
+export async function POST() {
+ await destroySession();
+ return NextResponse.json({ ok: true });
+}
diff --git a/nextjs/src/app/api/auth/me/route.ts b/nextjs/src/app/api/auth/me/route.ts
new file mode 100644
index 0000000..3784810
--- /dev/null
+++ b/nextjs/src/app/api/auth/me/route.ts
@@ -0,0 +1,20 @@
+import { NextResponse } from 'next/server';
+
+import { getSessionUser } from '@/lib/auth/session';
+
+export const dynamic = 'force-dynamic';
+
+export async function GET() {
+ const user = await getSessionUser();
+ if (!user) {
+ return NextResponse.json({ user: null }, { status: 401 });
+ }
+ return NextResponse.json({
+ user: {
+ id: user.id,
+ email: user.email,
+ is_approved: user.is_approved,
+ is_admin: user.is_admin,
+ },
+ });
+}
diff --git a/nextjs/src/app/api/auth/register/route.ts b/nextjs/src/app/api/auth/register/route.ts
new file mode 100644
index 0000000..ec309bc
--- /dev/null
+++ b/nextjs/src/app/api/auth/register/route.ts
@@ -0,0 +1,37 @@
+import { NextResponse } from 'next/server';
+
+import { hashPassword } from '@/lib/auth/password';
+import { getPool } from '@/lib/db';
+
+export const dynamic = 'force-dynamic';
+
+export async function POST(request: Request) {
+ try {
+ const body = await request.json();
+ const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
+ const password = typeof body.password === 'string' ? body.password : '';
+
+ if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ return NextResponse.json({ error: 'Valid email is required' }, { status: 400 });
+ }
+ if (password.length < 8) {
+ return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
+ }
+
+ const passwordHash = hashPassword(password);
+ const pool = getPool();
+ await pool.query(
+ `INSERT INTO admin_users (email, password_hash, is_approved, is_admin) VALUES (?, ?, 0, 0)`,
+ [email, passwordHash]
+ );
+
+ return NextResponse.json({ ok: true, message: 'Registration submitted. Wait for an administrator to approve your account.' });
+ } catch (e: unknown) {
+ const code = (e as { code?: string })?.code;
+ if (code === 'ER_DUP_ENTRY') {
+ return NextResponse.json({ error: 'Email already registered' }, { status: 409 });
+ }
+ console.error(e);
+ return NextResponse.json({ error: 'Registration failed' }, { status: 500 });
+ }
+}
diff --git a/nextjs/src/app/layout.tsx b/nextjs/src/app/layout.tsx
index 370c6f1..e77c7d9 100644
--- a/nextjs/src/app/layout.tsx
+++ b/nextjs/src/app/layout.tsx
@@ -3,7 +3,6 @@ import { Indie_Flower, M_PLUS_Rounded_1c } from 'next/font/google';
import type { ReactNode } from 'react';
import { I18nProvider } from '@/components/I18nProvider';
-import { SiteLayout } from '@/components/SiteLayout';
import 'semantic-ui-css/semantic.min.css';
import './globals.css';
@@ -37,9 +36,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return (
-
- {children}
-
+ {children}
);
diff --git a/nextjs/src/components/admin/LogoutButton.tsx b/nextjs/src/components/admin/LogoutButton.tsx
new file mode 100644
index 0000000..750076e
--- /dev/null
+++ b/nextjs/src/components/admin/LogoutButton.tsx
@@ -0,0 +1,18 @@
+'use client';
+
+import style from '@/styles/admin.module.scss';
+
+export function LogoutButton() {
+ return (
+ {
+ await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
+ window.location.href = '/admin/login';
+ }}
+ >
+ Log out
+
+ );
+}
diff --git a/nextjs/src/lib/auth/apiAuth.ts b/nextjs/src/lib/auth/apiAuth.ts
new file mode 100644
index 0000000..bd284e6
--- /dev/null
+++ b/nextjs/src/lib/auth/apiAuth.ts
@@ -0,0 +1,20 @@
+import { NextResponse } from 'next/server';
+
+import type { AdminUserRow } from '@/lib/auth/session';
+import { getSessionUser } from '@/lib/auth/session';
+
+export async function requireAdminApi(): Promise<
+ { ok: true; user: AdminUserRow } | { ok: false; response: NextResponse }
+> {
+ const user = await getSessionUser();
+ if (!user) {
+ return { ok: false, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
+ }
+ if (!user.is_approved) {
+ return { ok: false, response: NextResponse.json({ error: 'Pending approval' }, { status: 403 }) };
+ }
+ if (!user.is_admin) {
+ return { ok: false, response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
+ }
+ return { ok: true, user };
+}
diff --git a/nextjs/src/lib/auth/password.ts b/nextjs/src/lib/auth/password.ts
new file mode 100644
index 0000000..51118da
--- /dev/null
+++ b/nextjs/src/lib/auth/password.ts
@@ -0,0 +1,11 @@
+import bcrypt from 'bcryptjs';
+
+const SALT_ROUNDS = 12;
+
+export function hashPassword(plain: string): string {
+ return bcrypt.hashSync(plain, SALT_ROUNDS);
+}
+
+export function verifyPassword(plain: string, passwordHash: string): boolean {
+ return bcrypt.compareSync(plain, passwordHash);
+}
diff --git a/nextjs/src/lib/auth/requireAdmin.ts b/nextjs/src/lib/auth/requireAdmin.ts
new file mode 100644
index 0000000..8eb5626
--- /dev/null
+++ b/nextjs/src/lib/auth/requireAdmin.ts
@@ -0,0 +1,22 @@
+import { redirect } from 'next/navigation';
+
+import { getSessionUser } from '@/lib/auth/session';
+
+export async function requireLogin(): Promise>>> {
+ const user = await getSessionUser();
+ if (!user) {
+ redirect('/admin/login');
+ }
+ if (!user.is_approved) {
+ redirect('/admin/pending');
+ }
+ return user;
+}
+
+export async function requireAdmin(): Promise>>> {
+ const user = await requireLogin();
+ if (!user.is_admin) {
+ redirect('/admin/no-access');
+ }
+ return user;
+}
diff --git a/nextjs/src/lib/auth/session.ts b/nextjs/src/lib/auth/session.ts
new file mode 100644
index 0000000..4dc825f
--- /dev/null
+++ b/nextjs/src/lib/auth/session.ts
@@ -0,0 +1,88 @@
+import { createHash, randomBytes } from 'crypto';
+import type { RowDataPacket } from 'mysql2';
+import { cookies } from 'next/headers';
+
+import { getPool } from '@/lib/db';
+
+export const SESSION_COOKIE = 'admin_session';
+const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 7; // 7 days
+
+export type AdminUserRow = {
+ id: number;
+ email: string;
+ password_hash: string;
+ is_approved: boolean;
+ is_admin: boolean;
+};
+
+function hashToken(token: string): string {
+ return createHash('sha256').update(token, 'utf8').digest('hex');
+}
+
+export async function createSession(userId: number): Promise {
+ const token = randomBytes(32).toString('hex');
+ const tokenHash = hashToken(token);
+ const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_SEC * 1000);
+
+ const pool = getPool();
+ await pool.query(
+ `INSERT INTO admin_sessions (user_id, token_hash, expires_at) VALUES (?, ?, ?)`,
+ [userId, tokenHash, expiresAt]
+ );
+
+ const cookieStore = await cookies();
+ cookieStore.set(SESSION_COOKIE, token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax',
+ maxAge: SESSION_MAX_AGE_SEC,
+ path: '/',
+ });
+
+ return token;
+}
+
+export async function destroySession(): Promise {
+ const cookieStore = await cookies();
+ const token = cookieStore.get(SESSION_COOKIE)?.value;
+ if (token) {
+ const pool = getPool();
+ await pool.query(
+ 'DELETE FROM admin_sessions WHERE token_hash = ?',
+ [hashToken(token)]
+ );
+ }
+ cookieStore.delete(SESSION_COOKIE);
+}
+
+export async function getSessionUser(): Promise {
+ const cookieStore = await cookies();
+ const token = cookieStore.get(SESSION_COOKIE)?.value;
+ if (!token) {
+ return null;
+ }
+
+ const tokenHash = hashToken(token);
+ const pool = getPool();
+ const [rows] = await pool.query(
+ `SELECT u.id, u.email, u.password_hash, u.is_approved, u.is_admin
+ FROM admin_sessions s
+ JOIN admin_users u ON u.id = s.user_id
+ WHERE s.token_hash = ? AND s.expires_at > NOW()`,
+ [tokenHash]
+ );
+
+ if (rows.length === 0) {
+ cookieStore.delete(SESSION_COOKIE);
+ return null;
+ }
+
+ const row = rows[0] as Record;
+ return {
+ id: row.id as number,
+ email: row.email as string,
+ password_hash: row.password_hash as string,
+ is_approved: Boolean(row.is_approved),
+ is_admin: Boolean(row.is_admin),
+ };
+}
diff --git a/nextjs/src/styles/admin.module.scss b/nextjs/src/styles/admin.module.scss
new file mode 100644
index 0000000..c66d1a5
--- /dev/null
+++ b/nextjs/src/styles/admin.module.scss
@@ -0,0 +1,141 @@
+.wrap {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 24px 16px 48px;
+ font-family: system-ui, sans-serif;
+ color: #1a1a1a;
+}
+
+.card {
+ background: #fff;
+ border: 1px solid #e0e0e0;
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 20px;
+}
+
+.title {
+ margin: 0 0 8px;
+ font-size: 1.5rem;
+}
+
+.sub {
+ color: #666;
+ margin: 0 0 16px;
+ font-size: 0.95rem;
+}
+
+.form {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ max-width: 360px;
+}
+
+.label {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 0.9rem;
+}
+
+.input {
+ padding: 8px 10px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ font-size: 1rem;
+}
+
+.textarea {
+ min-height: 100px;
+ font-family: inherit;
+}
+
+.btn {
+ padding: 10px 16px;
+ border: none;
+ border-radius: 4px;
+ background: #2563eb;
+ color: #fff;
+ font-size: 1rem;
+ cursor: pointer;
+ &:hover {
+ background: #1d4ed8;
+ }
+ &:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+}
+
+.btnDanger {
+ background: #dc2626;
+ &:hover {
+ background: #b91c1c;
+ }
+}
+
+.btnGhost {
+ background: #f3f4f6;
+ color: #111;
+ &:hover {
+ background: #e5e7eb;
+ }
+}
+
+.error {
+ color: #b91c1c;
+ font-size: 0.9rem;
+}
+
+.success {
+ color: #15803d;
+ font-size: 0.9rem;
+}
+
+.nav {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-bottom: 24px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.nav a {
+ color: #2563eb;
+ text-decoration: none;
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9rem;
+}
+
+.table th,
+.table td {
+ border: 1px solid #e5e7eb;
+ padding: 8px;
+ text-align: left;
+ vertical-align: top;
+}
+
+.table th {
+ background: #f9fafb;
+}
+
+.rowActions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.hr {
+ border: none;
+ border-top: 1px solid #e5e7eb;
+ margin: 16px 0;
+}
diff --git a/nextjs/src/styles/web.module.scss b/nextjs/src/styles/web.module.scss
index c8a6b1a..387ccce 100644
--- a/nextjs/src/styles/web.module.scss
+++ b/nextjs/src/styles/web.module.scss
@@ -460,6 +460,14 @@
background-size: contain;
background-repeat: none;
}
+.evojapan2026 {
+ width: 500px;
+ height: 471px;
+ margin: auto;
+ background-image: url('https://static.catherine-fc.com/media/evo_logo_2026_shadow_drop.png');
+ background-size: contain;
+ background-repeat: none;
+}
.evojapan2023kaisaikettei {
width: 846px;
height: 285px;