Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 63 additions & 3 deletions frontend/app/[locale]/admin/quiz/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { Metadata } from 'next';
import { notFound } from 'next/navigation';

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

const isDraft = quiz.status === 'draft';

const csrfToken = issueCsrfToken('admin:quiz:question:update');
const csrfTokenDelete = isDraft
? issueCsrfToken('admin:quiz:question:delete')
: undefined;
const csrfTokenAddQuestions = isDraft
? issueCsrfToken('admin:quiz:questions:add')
: undefined;
const csrfTokenUpdate = issueCsrfToken('admin:quiz:update');


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

<div className="mb-6">
<h1 className="text-foreground text-2xl font-bold">{title}</h1>
<p className="text-muted-foreground mt-1 text-sm">
{quiz.questions.length} questions &middot; slug: {quiz.slug}
</p>
<div className="mt-1 flex items-center gap-3">
<span className="text-muted-foreground text-sm">
{quiz.questions.length} questions &middot; slug: {quiz.slug}
</span>
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
isDraft
? 'bg-amber-500/10 text-amber-500'
: 'bg-emerald-500/10 text-emerald-500'
}`}
>
{isDraft ? 'Draft' : 'Ready'}
</span>
<span
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
quiz.isActive
? 'bg-emerald-500/10 text-emerald-500'
: 'bg-muted text-muted-foreground'
}`}
>
{quiz.isActive ? 'Active' : 'Inactive'}
</span>
</div>
</div>
<div className="mb-6">
<QuizStatusControls
quizId={quiz.id}
status={quiz.status}
isActive={quiz.isActive}
csrfToken={csrfTokenUpdate}
/>
</div>
<div className="mb-6">
<QuizMetadataEditor
quizId={quiz.id}
translations={quiz.translations}
timeLimitSeconds={quiz.timeLimitSeconds}
csrfToken={csrfTokenUpdate}
/>
</div>

{isDraft && csrfTokenAddQuestions && (
<div className="mb-6">
<UploadMoreQuestions
quizId={quiz.id}
csrfToken={csrfTokenAddQuestions}
/>
</div>
)}

<QuizEditorList
questions={quiz.questions}
quizId={quiz.id}
csrfToken={csrfToken}
csrfTokenDelete={csrfTokenDelete}
isDraft={isDraft}
/>
</div>
);
Expand Down
37 changes: 37 additions & 0 deletions frontend/app/[locale]/admin/quiz/new/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Metadata } from 'next';

import { CreateQuizForm } from '@/components/admin/quiz/CreateQuizForm';
import { getAdminCategoryList } from '@/db/queries/categories/admin-categories';
import { Link } from '@/i18n/routing';
import { issueCsrfToken } from '@/lib/security/csrf';

export const metadata: Metadata = {
title: 'New Quiz | DevLovers',
};

export default async function AdminQuizNewPage() {
const categories = await getAdminCategoryList();
const csrfTokenQuiz = issueCsrfToken('admin:quiz:create');
const csrfTokenCategory = issueCsrfToken('admin:category:create');

return (
<div className="mx-auto max-w-5xl px-6 py-8">
<div className="mb-6">
<Link
href="/admin/quiz"
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
>
&larr; Back to quizzes
</Link>
</div>

<h1 className="text-foreground mb-6 text-2xl font-bold">New Quiz</h1>

<CreateQuizForm
categories={categories}
csrfTokenQuiz={csrfTokenQuiz}
csrfTokenCategory={csrfTokenCategory}
/>
</div>
);
}
23 changes: 18 additions & 5 deletions frontend/app/[locale]/admin/quiz/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,36 @@ import { Metadata } from 'next';

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

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

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

return (
<div className="mx-auto max-w-5xl px-6 py-8">
<h1 className="text-foreground text-2xl font-bold">Quizzes</h1>
<p className="text-muted-foreground mt-1 text-sm">
Manage quiz content, questions, and answers
</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-foreground text-2xl font-bold">Quizzes</h1>
<p className="text-muted-foreground mt-1 text-sm">
Manage quiz content, questions, and answers
</p>
</div>
<Link
href="/admin/quiz/new"
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"
>
+ New Quiz
</Link>
</div>

<div className="mt-6">
<QuizListTable quizzes={quizzes} />
<QuizListTable quizzes={quizzes} csrfTokenDelete={csrfTokenDelete} />
</div>
</div>
);
Expand Down
119 changes: 119 additions & 0 deletions frontend/app/api/admin/categories/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { eq, max } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';

import { db } from '@/db';
import { categories, categoryTranslations } from '@/db/schema/categories';
import {
AdminApiDisabledError,
AdminForbiddenError,
AdminUnauthorizedError,
requireAdminApi,
} from '@/lib/auth/admin';
import { logError } from '@/lib/logging';
import { requireAdminCsrf } from '@/lib/security/admin-csrf';
import { guardBrowserSameOrigin } from '@/lib/security/origin';
import { createCategorySchema } from '@/lib/validation/admin-quiz';

export const runtime = 'nodejs';

const LOCALES = ['en', 'uk', 'pl'] as const;

function noStoreJson(body: unknown, init?: { status?: number }) {
const res = NextResponse.json(body, { status: init?.status ?? 200 });
res.headers.set('Cache-Control', 'no-store');
return res;
}

export async function POST(request: NextRequest): Promise<NextResponse> {
const blocked = guardBrowserSameOrigin(request);
if (blocked) {
blocked.headers.set('Cache-Control', 'no-store');
return blocked;
}

try {
await requireAdminApi(request);

const csrfResult = requireAdminCsrf(request, 'admin:category:create');
if (csrfResult) {
csrfResult.headers.set('Cache-Control', 'no-store');
return csrfResult;
}

let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return noStoreJson({ error: 'Invalid JSON body', code: 'INVALID_BODY' }, { status: 400 });
}

const parsed = createCategorySchema.safeParse(rawBody);
if (!parsed.success) {
return noStoreJson(
{ error: 'Invalid payload', code: 'INVALID_PAYLOAD', details: parsed.error.format() },
{ status: 400 }
);
}

const { slug, translations } = parsed.data;

// Auto displayOrder
const [maxRow] = await db
.select({ maxOrder: max(categories.displayOrder) })
.from(categories);

const displayOrder = (maxRow?.maxOrder ?? 0) + 1;

// Insert category (onConflictDoNothing handles duplicate slug race)
const rows = await db
.insert(categories)
.values({ slug, displayOrder })
.onConflictDoNothing({ target: categories.slug })
.returning({ id: categories.id });

if (rows.length === 0) {
return noStoreJson(
{ error: 'Category with this slug already exists', code: 'DUPLICATE_SLUG' },
{ status: 409 }
);
}

const category = rows[0];

// Insert translations (cleanup orphan category on failure)
try {
await db.insert(categoryTranslations).values(
LOCALES.map(locale => ({
categoryId: category.id,
locale,
title: translations[locale].title,
}))
);
} catch (translationError) {
await db.delete(categories).where(eq(categories.id, category.id));
throw translationError;
}

return noStoreJson({
success: true,
category: { id: category.id, slug, title: translations.en.title },
});
} catch (error) {
if (error instanceof AdminApiDisabledError) {
return noStoreJson({ code: error.code }, { status: 403 });
}
if (error instanceof AdminUnauthorizedError) {
return noStoreJson({ code: error.code }, { status: 401 });
}
if (error instanceof AdminForbiddenError) {
return noStoreJson({ code: error.code }, { status: 403 });
}

logError('admin_category_create_failed', error, {
route: request.nextUrl.pathname,
method: request.method,
});

return noStoreJson({ error: 'Internal error', code: 'INTERNAL_ERROR' }, { status: 500 });
}
}
Loading