Skip to content

Commit 6f990c3

Browse files
Merge pull request #353 from DevLoversTeam/sl/feat/quiz
(SP: 3) [Frontend] Quiz Admin: Create & Publish Quizzes (JSON upload)
2 parents a5aace3 + d272741 commit 6f990c3

29 files changed

Lines changed: 6672 additions & 542 deletions

frontend/app/[locale]/admin/quiz/[id]/page.tsx

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { Metadata } from 'next';
22
import { notFound } from 'next/navigation';
33

44
import { QuizEditorList } from '@/components/admin/quiz/QuizEditorList';
5+
import { UploadMoreQuestions } from '@/components/admin/quiz/UploadMoreQuestions';
6+
import { QuizStatusControls } from '@/components/admin/quiz/QuizStatusControls';
7+
import { QuizMetadataEditor } from '@/components/admin/quiz/QuizMetadataEditor';
58
import { getAdminQuizFull } from '@/db/queries/quizzes/admin-quiz';
69
import { Link } from '@/i18n/routing';
710
import { issueCsrfToken } from '@/lib/security/csrf';
@@ -23,7 +26,17 @@ export default async function AdminQuizEditPage({
2326
const title =
2427
quiz.translations.en?.title ?? quiz.translations.uk?.title ?? quiz.slug;
2528

29+
const isDraft = quiz.status === 'draft';
30+
2631
const csrfToken = issueCsrfToken('admin:quiz:question:update');
32+
const csrfTokenDelete = isDraft
33+
? issueCsrfToken('admin:quiz:question:delete')
34+
: undefined;
35+
const csrfTokenAddQuestions = isDraft
36+
? issueCsrfToken('admin:quiz:questions:add')
37+
: undefined;
38+
const csrfTokenUpdate = issueCsrfToken('admin:quiz:update');
39+
2740

2841
return (
2942
<div className="mx-auto max-w-5xl px-6 py-8">
@@ -38,15 +51,62 @@ export default async function AdminQuizEditPage({
3851

3952
<div className="mb-6">
4053
<h1 className="text-foreground text-2xl font-bold">{title}</h1>
41-
<p className="text-muted-foreground mt-1 text-sm">
42-
{quiz.questions.length} questions &middot; slug: {quiz.slug}
43-
</p>
54+
<div className="mt-1 flex items-center gap-3">
55+
<span className="text-muted-foreground text-sm">
56+
{quiz.questions.length} questions &middot; slug: {quiz.slug}
57+
</span>
58+
<span
59+
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
60+
isDraft
61+
? 'bg-amber-500/10 text-amber-500'
62+
: 'bg-emerald-500/10 text-emerald-500'
63+
}`}
64+
>
65+
{isDraft ? 'Draft' : 'Ready'}
66+
</span>
67+
<span
68+
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
69+
quiz.isActive
70+
? 'bg-emerald-500/10 text-emerald-500'
71+
: 'bg-muted text-muted-foreground'
72+
}`}
73+
>
74+
{quiz.isActive ? 'Active' : 'Inactive'}
75+
</span>
76+
</div>
77+
</div>
78+
<div className="mb-6">
79+
<QuizStatusControls
80+
quizId={quiz.id}
81+
status={quiz.status}
82+
isActive={quiz.isActive}
83+
csrfToken={csrfTokenUpdate}
84+
/>
85+
</div>
86+
<div className="mb-6">
87+
<QuizMetadataEditor
88+
quizId={quiz.id}
89+
translations={quiz.translations}
90+
timeLimitSeconds={quiz.timeLimitSeconds}
91+
csrfToken={csrfTokenUpdate}
92+
/>
4493
</div>
4594

95+
{isDraft && csrfTokenAddQuestions && (
96+
<div className="mb-6">
97+
<UploadMoreQuestions
98+
quizId={quiz.id}
99+
csrfToken={csrfTokenAddQuestions}
100+
/>
101+
</div>
102+
)}
103+
46104
<QuizEditorList
47105
questions={quiz.questions}
48106
quizId={quiz.id}
49107
csrfToken={csrfToken}
108+
csrfTokenDelete={csrfTokenDelete}
109+
isDraft={isDraft}
50110
/>
51111
</div>
52112
);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Metadata } from 'next';
2+
3+
import { CreateQuizForm } from '@/components/admin/quiz/CreateQuizForm';
4+
import { getAdminCategoryList } from '@/db/queries/categories/admin-categories';
5+
import { Link } from '@/i18n/routing';
6+
import { issueCsrfToken } from '@/lib/security/csrf';
7+
8+
export const metadata: Metadata = {
9+
title: 'New Quiz | DevLovers',
10+
};
11+
12+
export default async function AdminQuizNewPage() {
13+
const categories = await getAdminCategoryList();
14+
const csrfTokenQuiz = issueCsrfToken('admin:quiz:create');
15+
const csrfTokenCategory = issueCsrfToken('admin:category:create');
16+
17+
return (
18+
<div className="mx-auto max-w-5xl px-6 py-8">
19+
<div className="mb-6">
20+
<Link
21+
href="/admin/quiz"
22+
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
23+
>
24+
&larr; Back to quizzes
25+
</Link>
26+
</div>
27+
28+
<h1 className="text-foreground mb-6 text-2xl font-bold">New Quiz</h1>
29+
30+
<CreateQuizForm
31+
categories={categories}
32+
csrfTokenQuiz={csrfTokenQuiz}
33+
csrfTokenCategory={csrfTokenCategory}
34+
/>
35+
</div>
36+
);
37+
}

frontend/app/[locale]/admin/quiz/page.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,36 @@ import { Metadata } from 'next';
22

33
import { QuizListTable } from '@/components/admin/quiz/QuizListTable';
44
import { getAdminQuizList } from '@/db/queries/quizzes/admin-quiz';
5+
import { Link } from '@/i18n/routing';
6+
import { issueCsrfToken } from '@/lib/security/csrf';
57

68
export const metadata: Metadata = {
79
title: 'Quiz Admin | DevLovers',
810
};
911

1012
export default async function AdminQuizPage() {
1113
const quizzes = await getAdminQuizList();
14+
const csrfTokenDelete = issueCsrfToken('admin:quiz:delete');
1215

1316
return (
1417
<div className="mx-auto max-w-5xl px-6 py-8">
15-
<h1 className="text-foreground text-2xl font-bold">Quizzes</h1>
16-
<p className="text-muted-foreground mt-1 text-sm">
17-
Manage quiz content, questions, and answers
18-
</p>
18+
<div className="flex items-center justify-between">
19+
<div>
20+
<h1 className="text-foreground text-2xl font-bold">Quizzes</h1>
21+
<p className="text-muted-foreground mt-1 text-sm">
22+
Manage quiz content, questions, and answers
23+
</p>
24+
</div>
25+
<Link
26+
href="/admin/quiz/new"
27+
className="bg-foreground text-background hover:bg-foreground/90 inline-flex items-center rounded-md px-4 py-2 text-sm font-medium transition-colors"
28+
>
29+
+ New Quiz
30+
</Link>
31+
</div>
1932

2033
<div className="mt-6">
21-
<QuizListTable quizzes={quizzes} />
34+
<QuizListTable quizzes={quizzes} csrfTokenDelete={csrfTokenDelete} />
2235
</div>
2336
</div>
2437
);
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { eq, max } from 'drizzle-orm';
2+
import { NextRequest, NextResponse } from 'next/server';
3+
4+
import { db } from '@/db';
5+
import { categories, categoryTranslations } from '@/db/schema/categories';
6+
import {
7+
AdminApiDisabledError,
8+
AdminForbiddenError,
9+
AdminUnauthorizedError,
10+
requireAdminApi,
11+
} from '@/lib/auth/admin';
12+
import { logError } from '@/lib/logging';
13+
import { requireAdminCsrf } from '@/lib/security/admin-csrf';
14+
import { guardBrowserSameOrigin } from '@/lib/security/origin';
15+
import { createCategorySchema } from '@/lib/validation/admin-quiz';
16+
17+
export const runtime = 'nodejs';
18+
19+
const LOCALES = ['en', 'uk', 'pl'] as const;
20+
21+
function noStoreJson(body: unknown, init?: { status?: number }) {
22+
const res = NextResponse.json(body, { status: init?.status ?? 200 });
23+
res.headers.set('Cache-Control', 'no-store');
24+
return res;
25+
}
26+
27+
export async function POST(request: NextRequest): Promise<NextResponse> {
28+
const blocked = guardBrowserSameOrigin(request);
29+
if (blocked) {
30+
blocked.headers.set('Cache-Control', 'no-store');
31+
return blocked;
32+
}
33+
34+
try {
35+
await requireAdminApi(request);
36+
37+
const csrfResult = requireAdminCsrf(request, 'admin:category:create');
38+
if (csrfResult) {
39+
csrfResult.headers.set('Cache-Control', 'no-store');
40+
return csrfResult;
41+
}
42+
43+
let rawBody: unknown;
44+
try {
45+
rawBody = await request.json();
46+
} catch {
47+
return noStoreJson({ error: 'Invalid JSON body', code: 'INVALID_BODY' }, { status: 400 });
48+
}
49+
50+
const parsed = createCategorySchema.safeParse(rawBody);
51+
if (!parsed.success) {
52+
return noStoreJson(
53+
{ error: 'Invalid payload', code: 'INVALID_PAYLOAD', details: parsed.error.format() },
54+
{ status: 400 }
55+
);
56+
}
57+
58+
const { slug, translations } = parsed.data;
59+
60+
// Auto displayOrder
61+
const [maxRow] = await db
62+
.select({ maxOrder: max(categories.displayOrder) })
63+
.from(categories);
64+
65+
const displayOrder = (maxRow?.maxOrder ?? 0) + 1;
66+
67+
// Insert category (onConflictDoNothing handles duplicate slug race)
68+
const rows = await db
69+
.insert(categories)
70+
.values({ slug, displayOrder })
71+
.onConflictDoNothing({ target: categories.slug })
72+
.returning({ id: categories.id });
73+
74+
if (rows.length === 0) {
75+
return noStoreJson(
76+
{ error: 'Category with this slug already exists', code: 'DUPLICATE_SLUG' },
77+
{ status: 409 }
78+
);
79+
}
80+
81+
const category = rows[0];
82+
83+
// Insert translations (cleanup orphan category on failure)
84+
try {
85+
await db.insert(categoryTranslations).values(
86+
LOCALES.map(locale => ({
87+
categoryId: category.id,
88+
locale,
89+
title: translations[locale].title,
90+
}))
91+
);
92+
} catch (translationError) {
93+
await db.delete(categories).where(eq(categories.id, category.id));
94+
throw translationError;
95+
}
96+
97+
return noStoreJson({
98+
success: true,
99+
category: { id: category.id, slug, title: translations.en.title },
100+
});
101+
} catch (error) {
102+
if (error instanceof AdminApiDisabledError) {
103+
return noStoreJson({ code: error.code }, { status: 403 });
104+
}
105+
if (error instanceof AdminUnauthorizedError) {
106+
return noStoreJson({ code: error.code }, { status: 401 });
107+
}
108+
if (error instanceof AdminForbiddenError) {
109+
return noStoreJson({ code: error.code }, { status: 403 });
110+
}
111+
112+
logError('admin_category_create_failed', error, {
113+
route: request.nextUrl.pathname,
114+
method: request.method,
115+
});
116+
117+
return noStoreJson({ error: 'Internal error', code: 'INTERNAL_ERROR' }, { status: 500 });
118+
}
119+
}

0 commit comments

Comments
 (0)