Phase 3: admin magic-link auth and event setup screens
- /login sends a Supabase magic link (admin emails only); /auth/confirm completes sign-in; proxy.ts guards /admin and every server action re-checks with requireAdmin(), since actions are plain POST endpoints. - /admin lists, creates, duplicates and deletes events. - /admin/events/[eventId] edits teams (colour, order) and games with criteria, presets and notes. All input goes through zod schemas that mirror the database constraints so errors read as plain sentences. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,3 +40,13 @@ don't rely on memory for SDK method names (esp. `@supabase/ssr` and Realtime bro
|
|||||||
## Commands
|
## Commands
|
||||||
- `npm run dev` / `npm run build` / `npm run test` / `npm run lint`
|
- `npm run dev` / `npm run build` / `npm run test` / `npm run lint`
|
||||||
- Migrations live in `supabase/migrations/`. Apply via Supabase CLI or paste into SQL editor.
|
- Migrations live in `supabase/migrations/`. Apply via Supabase CLI or paste into SQL editor.
|
||||||
|
|
||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||||
|
|
||||||
|
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||||
|
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Next.js (App Router) on Vercel · Supabase (Postgres + Auth + Realtime Broadcast
|
|||||||
| `PUT /api/judge/[token]/scores` | judge | Upsert one score `{teamId, criterionId, value}` |
|
| `PUT /api/judge/[token]/scores` | judge | Upsert one score `{teamId, criterionId, value}` |
|
||||||
| `POST /api/judge-open/[openToken]` | judge | `{name}` → `{judgeToken}` |
|
| `POST /api/judge-open/[openToken]` | judge | `{name}` → `{judgeToken}` |
|
||||||
|
|
||||||
Admin mutations = server actions. Middleware protects `/admin/*`: must be logged in AND email in `ADMIN_EMAILS`.
|
Admin mutations = server actions. `src/proxy.ts` (Next.js 16's name for middleware) protects `/admin/*`: must be logged in AND email in `ADMIN_EMAILS`. Every server action re-checks via `requireAdmin()`.
|
||||||
|
|
||||||
## Supabase clients (`src/lib/supabase/`)
|
## Supabase clients (`src/lib/supabase/`)
|
||||||
- `browser.ts` — anon key, used for Realtime subscribe + admin auth session.
|
- `browser.ts` — anon key, used for Realtime subscribe + admin auth session.
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ human a short "how to test this manually" checklist. Don't start the next phase
|
|||||||
**Done when:** tests green, 100% branch coverage on the scoring module.
|
**Done when:** tests green, 100% branch coverage on the scoring module.
|
||||||
|
|
||||||
## Phase 3 — Admin auth + setup screens
|
## Phase 3 — Admin auth + setup screens
|
||||||
- `/login` magic link; middleware guards `/admin/*` with `ADMIN_EMAILS`.
|
- `/login` magic link; `proxy.ts` guards `/admin/*` with `ADMIN_EMAILS`.
|
||||||
- Events list + create/duplicate/delete.
|
- Events list + create/duplicate/delete.
|
||||||
- Event setup: teams CRUD (colour picker, reorder), games builder with criteria + presets, notes.
|
- Event setup: teams CRUD (colour picker, reorder), games builder with criteria + presets, notes.
|
||||||
**Done when:** I can build a "Worship Game Night" with 4 teams and a Karaoke game with 5 criteria (1–5) and a Guess the Lyric game with 1 number criterion.
|
**Done when:** I can build a "Worship Game Night" with 4 teams and a Karaoke game with 5 criteria (1–5) and a Guess the Lyric game with 1 number criterion.
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { requireAdmin } from "@/lib/auth";
|
||||||
|
import { criteriaPresets, type PresetKey } from "@/lib/presets";
|
||||||
|
import { idSchema } from "@/lib/tokens";
|
||||||
|
import {
|
||||||
|
eventSchema,
|
||||||
|
firstError,
|
||||||
|
gameSchema,
|
||||||
|
teamSchema,
|
||||||
|
type GameDraft,
|
||||||
|
} from "@/lib/validation/admin";
|
||||||
|
|
||||||
|
export type ActionResult = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
|
const ok: ActionResult = { ok: true };
|
||||||
|
const fail = (error: string): ActionResult => ({ ok: false, error });
|
||||||
|
const dbFail = (what: string, message: string) => fail(`${what} failed: ${message}. Try again.`);
|
||||||
|
|
||||||
|
const validIds = (...ids: string[]) => ids.every((id) => idSchema.safeParse(id).success);
|
||||||
|
const BAD_ID = fail("That item no longer exists. Reload the page.");
|
||||||
|
|
||||||
|
const eventPath = (eventId: string) => `/admin/events/${eventId}`;
|
||||||
|
|
||||||
|
// New teams cycle through these so they're distinguishable without any effort.
|
||||||
|
const TEAM_COLORS = ["#E63946", "#3A86FF", "#2EC4B6", "#FFB703", "#8338EC", "#FB5607", "#06D6A0", "#FF006E"];
|
||||||
|
|
||||||
|
/** Returns the row order after moving `id` one place up or down. */
|
||||||
|
function moved<T extends { id: string }>(rows: T[], id: string, direction: "up" | "down"): T[] {
|
||||||
|
const from = rows.findIndex((row) => row.id === id);
|
||||||
|
const to = direction === "up" ? from - 1 : from + 1;
|
||||||
|
if (from < 0 || to < 0 || to >= rows.length) return rows;
|
||||||
|
const next = [...rows];
|
||||||
|
[next[from], next[to]] = [next[to], next[from]];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- events ----------
|
||||||
|
|
||||||
|
export async function createEvent(_previous: ActionResult | null, formData: FormData): Promise<ActionResult> {
|
||||||
|
const { supabase, userId } = await requireAdmin();
|
||||||
|
const parsed = eventSchema.safeParse({
|
||||||
|
name: String(formData.get("name") ?? ""),
|
||||||
|
eventDate: String(formData.get("eventDate") ?? ""),
|
||||||
|
});
|
||||||
|
if (!parsed.success) return fail(firstError(parsed.error));
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("events")
|
||||||
|
.insert({ owner_id: userId, name: parsed.data.name, event_date: parsed.data.eventDate || null })
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
if (error) return dbFail("Creating the event", error.message);
|
||||||
|
|
||||||
|
revalidatePath("/admin");
|
||||||
|
redirect(eventPath(data.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEvent(
|
||||||
|
eventId: string,
|
||||||
|
input: { name: string; eventDate: string },
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const { supabase } = await requireAdmin();
|
||||||
|
if (!validIds(eventId)) return BAD_ID;
|
||||||
|
const parsed = eventSchema.safeParse(input);
|
||||||
|
if (!parsed.success) return fail(firstError(parsed.error));
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("events")
|
||||||
|
.update({ name: parsed.data.name, event_date: parsed.data.eventDate || null })
|
||||||
|
.eq("id", eventId);
|
||||||
|
if (error) return dbFail("Saving the event", error.message);
|
||||||
|
|
||||||
|
revalidatePath("/admin");
|
||||||
|
revalidatePath(eventPath(eventId));
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEvent(eventId: string): Promise<ActionResult> {
|
||||||
|
const { supabase } = await requireAdmin();
|
||||||
|
if (!validIds(eventId)) return BAD_ID;
|
||||||
|
|
||||||
|
const { error } = await supabase.from("events").delete().eq("id", eventId);
|
||||||
|
if (error) return dbFail("Deleting the event", error.message);
|
||||||
|
|
||||||
|
revalidatePath("/admin");
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies teams, games and criteria — not scores, judges or adjustments. */
|
||||||
|
export async function duplicateEvent(eventId: string): Promise<ActionResult> {
|
||||||
|
const { supabase, userId } = await requireAdmin();
|
||||||
|
if (!validIds(eventId)) return BAD_ID;
|
||||||
|
|
||||||
|
const [source, teams, games] = await Promise.all([
|
||||||
|
supabase.from("events").select("name, event_date").eq("id", eventId).maybeSingle(),
|
||||||
|
supabase.from("teams").select("name, color, sort_order").eq("event_id", eventId),
|
||||||
|
supabase.from("games").select("*, criteria(*)").eq("event_id", eventId),
|
||||||
|
]);
|
||||||
|
const loadError = source.error ?? teams.error ?? games.error;
|
||||||
|
if (loadError) return dbFail("Copying the event", loadError.message);
|
||||||
|
if (!source.data) return BAD_ID;
|
||||||
|
|
||||||
|
const copy = await supabase
|
||||||
|
.from("events")
|
||||||
|
.insert({
|
||||||
|
owner_id: userId,
|
||||||
|
name: `${source.data.name} (copy)`.slice(0, 120),
|
||||||
|
event_date: source.data.event_date,
|
||||||
|
})
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
if (copy.error) return dbFail("Copying the event", copy.error.message);
|
||||||
|
const newEventId = copy.data.id;
|
||||||
|
|
||||||
|
if (teams.data?.length) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("teams")
|
||||||
|
.insert(teams.data.map((team) => ({ ...team, event_id: newEventId })));
|
||||||
|
if (error) return dbFail("Copying the teams", error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const game of games.data ?? []) {
|
||||||
|
const error = await insertGameCopy(supabase, game, newEventId, game.title, game.sort_order);
|
||||||
|
if (error) return dbFail("Copying the games", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin");
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- teams ----------
|
||||||
|
|
||||||
|
export async function addTeam(eventId: string, name: string): Promise<ActionResult> {
|
||||||
|
const { supabase } = await requireAdmin();
|
||||||
|
if (!validIds(eventId)) return BAD_ID;
|
||||||
|
|
||||||
|
const existing = await supabase.from("teams").select("sort_order").eq("event_id", eventId);
|
||||||
|
if (existing.error) return dbFail("Adding the team", existing.error.message);
|
||||||
|
const count = existing.data.length;
|
||||||
|
|
||||||
|
const parsed = teamSchema.safeParse({ name, color: TEAM_COLORS[count % TEAM_COLORS.length] });
|
||||||
|
if (!parsed.success) return fail(firstError(parsed.error));
|
||||||
|
|
||||||
|
const { error } = await supabase.from("teams").insert({
|
||||||
|
event_id: eventId,
|
||||||
|
name: parsed.data.name,
|
||||||
|
color: parsed.data.color,
|
||||||
|
sort_order: Math.max(-1, ...existing.data.map((team) => team.sort_order)) + 1,
|
||||||
|
});
|
||||||
|
if (error) return dbFail("Adding the team", error.message);
|
||||||
|
|
||||||
|
revalidatePath(eventPath(eventId));
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTeam(
|
||||||
|
eventId: string,
|
||||||
|
teamId: string,
|
||||||
|
input: { name: string; color: string },
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const { supabase } = await requireAdmin();
|
||||||
|
if (!validIds(eventId, teamId)) return BAD_ID;
|
||||||
|
const parsed = teamSchema.safeParse(input);
|
||||||
|
if (!parsed.success) return fail(firstError(parsed.error));
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("teams")
|
||||||
|
.update(parsed.data)
|
||||||
|
.eq("id", teamId)
|
||||||
|
.eq("event_id", eventId);
|
||||||
|
if (error) return dbFail("Saving the team", error.message);
|
||||||
|
|
||||||
|
revalidatePath(eventPath(eventId));
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTeam(eventId: string, teamId: string): Promise<ActionResult> {
|
||||||
|
const { supabase } = await requireAdmin();
|
||||||
|
if (!validIds(eventId, teamId)) return BAD_ID;
|
||||||
|
|
||||||
|
const { error } = await supabase.from("teams").delete().eq("id", teamId).eq("event_id", eventId);
|
||||||
|
if (error) return dbFail("Deleting the team", error.message);
|
||||||
|
|
||||||
|
revalidatePath(eventPath(eventId));
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveTeam(
|
||||||
|
eventId: string,
|
||||||
|
teamId: string,
|
||||||
|
direction: "up" | "down",
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const { supabase } = await requireAdmin();
|
||||||
|
if (!validIds(eventId, teamId)) return BAD_ID;
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("teams")
|
||||||
|
.select("id, sort_order")
|
||||||
|
.eq("event_id", eventId)
|
||||||
|
.order("sort_order")
|
||||||
|
.order("created_at");
|
||||||
|
if (error) return dbFail("Reordering", error.message);
|
||||||
|
|
||||||
|
// Rewrite every position so gaps or duplicates in sort_order heal themselves.
|
||||||
|
const results = await Promise.all(
|
||||||
|
moved(data, teamId, direction).map((team, index) =>
|
||||||
|
team.sort_order === index
|
||||||
|
? null
|
||||||
|
: supabase.from("teams").update({ sort_order: index }).eq("id", team.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const failed = results.find((result) => result?.error);
|
||||||
|
if (failed?.error) return dbFail("Reordering", failed.error.message);
|
||||||
|
|
||||||
|
revalidatePath(eventPath(eventId));
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- games ----------
|
||||||
|
|
||||||
|
type Supabase = Awaited<ReturnType<typeof requireAdmin>>["supabase"];
|
||||||
|
type GameRowWithCriteria = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof loadGame>>["data"]
|
||||||
|
>;
|
||||||
|
|
||||||
|
function loadGame(supabase: Supabase, eventId: string, gameId: string) {
|
||||||
|
return supabase
|
||||||
|
.from("games")
|
||||||
|
.select("*, criteria(*)")
|
||||||
|
.eq("id", gameId)
|
||||||
|
.eq("event_id", eventId)
|
||||||
|
.maybeSingle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inserts a fresh copy of a game (new tokens, status upcoming, no scores). Returns an error message or null. */
|
||||||
|
async function insertGameCopy(
|
||||||
|
supabase: Supabase,
|
||||||
|
game: GameRowWithCriteria,
|
||||||
|
eventId: string,
|
||||||
|
title: string,
|
||||||
|
sortOrder: number,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const copy = await supabase
|
||||||
|
.from("games")
|
||||||
|
.insert({
|
||||||
|
event_id: eventId,
|
||||||
|
title,
|
||||||
|
description: game.description,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
scoring_mode: game.scoring_mode,
|
||||||
|
points_mode: game.points_mode,
|
||||||
|
multiplier: game.multiplier,
|
||||||
|
placement_points: game.placement_points,
|
||||||
|
admin_notes: game.admin_notes,
|
||||||
|
judge_notes: game.judge_notes,
|
||||||
|
})
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
if (copy.error) return copy.error.message;
|
||||||
|
|
||||||
|
if (game.criteria.length) {
|
||||||
|
const { error } = await supabase.from("criteria").insert(
|
||||||
|
game.criteria.map((criterion) => ({
|
||||||
|
game_id: copy.data.id,
|
||||||
|
label: criterion.label,
|
||||||
|
description: criterion.description,
|
||||||
|
input_type: criterion.input_type,
|
||||||
|
min_value: criterion.min_value,
|
||||||
|
max_value: criterion.max_value,
|
||||||
|
step: criterion.step,
|
||||||
|
weight: criterion.weight,
|
||||||
|
sort_order: criterion.sort_order,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
if (error) return error.message;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nextGameOrder(supabase: Supabase, eventId: string) {
|
||||||
|
const { data } = await supabase.from("games").select("sort_order").eq("event_id", eventId);
|
||||||
|
return Math.max(-1, ...(data ?? []).map((game) => game.sort_order)) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addGame(eventId: string, presetKey: PresetKey): Promise<ActionResult> {
|
||||||
|
const { supabase } = await requireAdmin();
|
||||||
|
if (!validIds(eventId) || !Object.hasOwn(criteriaPresets, presetKey)) return BAD_ID;
|
||||||
|
|
||||||
|
const game = await supabase
|
||||||
|
.from("games")
|
||||||
|
.insert({
|
||||||
|
event_id: eventId,
|
||||||
|
title: "New game",
|
||||||
|
sort_order: await nextGameOrder(supabase, eventId),
|
||||||
|
// Karaoke is the one preset that only makes sense with judges.
|
||||||
|
scoring_mode: presetKey === "karaoke" ? "judged" : "direct",
|
||||||
|
})
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
if (game.error) return dbFail("Adding the game", game.error.message);
|
||||||
|
|
||||||
|
const { error } = await supabase.from("criteria").insert(
|
||||||
|
criteriaPresets[presetKey].criteria.map((criterion, index) => ({
|
||||||
|
game_id: game.data.id,
|
||||||
|
label: criterion.label,
|
||||||
|
description: criterion.description || null,
|
||||||
|
input_type: criterion.inputType,
|
||||||
|
min_value: criterion.min,
|
||||||
|
max_value: criterion.max,
|
||||||
|
step: criterion.step,
|
||||||
|
weight: criterion.weight,
|
||||||
|
sort_order: index,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
if (error) return dbFail("Adding the criteria", error.message);
|
||||||
|
|
||||||
|
revalidatePath(eventPath(eventId));
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveGame(eventId: string, gameId: string, input: GameDraft): Promise<ActionResult> {
|
||||||
|
const { supabase } = await requireAdmin();
|
||||||
|
if (!validIds(eventId, gameId)) return BAD_ID;
|
||||||
|
const parsed = gameSchema.safeParse(input);
|
||||||
|
if (!parsed.success) return fail(firstError(parsed.error));
|
||||||
|
const game = parsed.data;
|
||||||
|
|
||||||
|
const updated = await supabase
|
||||||
|
.from("games")
|
||||||
|
.update({
|
||||||
|
title: game.title,
|
||||||
|
description: game.description || null,
|
||||||
|
scoring_mode: game.scoringMode,
|
||||||
|
points_mode: game.pointsMode,
|
||||||
|
multiplier: game.multiplier,
|
||||||
|
placement_points: game.placementPoints,
|
||||||
|
admin_notes: game.adminNotes || null,
|
||||||
|
judge_notes: game.judgeNotes || null,
|
||||||
|
})
|
||||||
|
.eq("id", gameId)
|
||||||
|
.eq("event_id", eventId)
|
||||||
|
.select("id");
|
||||||
|
if (updated.error) return dbFail("Saving the game", updated.error.message);
|
||||||
|
if (updated.data.length === 0) return BAD_ID;
|
||||||
|
|
||||||
|
// Criteria: delete the ones removed in the editor (their scores cascade), then
|
||||||
|
// update kept ones in place so their scores survive, and insert new ones.
|
||||||
|
const existing = await supabase.from("criteria").select("id").eq("game_id", gameId);
|
||||||
|
if (existing.error) return dbFail("Saving the criteria", existing.error.message);
|
||||||
|
const existingIds = new Set(existing.data.map((criterion) => criterion.id));
|
||||||
|
const keptIds = new Set(game.criteria.map((criterion) => criterion.id).filter(Boolean));
|
||||||
|
const removedIds = [...existingIds].filter((id) => !keptIds.has(id));
|
||||||
|
|
||||||
|
if (removedIds.length) {
|
||||||
|
const { error } = await supabase.from("criteria").delete().in("id", removedIds);
|
||||||
|
if (error) return dbFail("Removing criteria", error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
game.criteria.map((criterion, index) => {
|
||||||
|
const row = {
|
||||||
|
game_id: gameId,
|
||||||
|
label: criterion.label,
|
||||||
|
description: criterion.description || null,
|
||||||
|
input_type: criterion.inputType,
|
||||||
|
min_value: criterion.min,
|
||||||
|
max_value: criterion.max,
|
||||||
|
step: criterion.step,
|
||||||
|
weight: criterion.weight,
|
||||||
|
sort_order: index,
|
||||||
|
};
|
||||||
|
return criterion.id && existingIds.has(criterion.id)
|
||||||
|
? supabase.from("criteria").update(row).eq("id", criterion.id).eq("game_id", gameId)
|
||||||
|
: supabase.from("criteria").insert(row);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const failed = results.find((result) => result.error);
|
||||||
|
if (failed?.error) return dbFail("Saving the criteria", failed.error.message);
|
||||||
|
|
||||||
|
revalidatePath(eventPath(eventId));
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function duplicateGame(eventId: string, gameId: string): Promise<ActionResult> {
|
||||||
|
const { supabase } = await requireAdmin();
|
||||||
|
if (!validIds(eventId, gameId)) return BAD_ID;
|
||||||
|
|
||||||
|
const source = await loadGame(supabase, eventId, gameId);
|
||||||
|
if (source.error) return dbFail("Copying the game", source.error.message);
|
||||||
|
if (!source.data) return BAD_ID;
|
||||||
|
|
||||||
|
const error = await insertGameCopy(
|
||||||
|
supabase,
|
||||||
|
source.data,
|
||||||
|
eventId,
|
||||||
|
`${source.data.title} (copy)`.slice(0, 120),
|
||||||
|
await nextGameOrder(supabase, eventId),
|
||||||
|
);
|
||||||
|
if (error) return dbFail("Copying the game", error);
|
||||||
|
|
||||||
|
revalidatePath(eventPath(eventId));
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGame(eventId: string, gameId: string): Promise<ActionResult> {
|
||||||
|
const { supabase } = await requireAdmin();
|
||||||
|
if (!validIds(eventId, gameId)) return BAD_ID;
|
||||||
|
|
||||||
|
const { error } = await supabase.from("games").delete().eq("id", gameId).eq("event_id", eventId);
|
||||||
|
if (error) return dbFail("Deleting the game", error.message);
|
||||||
|
|
||||||
|
revalidatePath(eventPath(eventId));
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveGame(
|
||||||
|
eventId: string,
|
||||||
|
gameId: string,
|
||||||
|
direction: "up" | "down",
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const { supabase } = await requireAdmin();
|
||||||
|
if (!validIds(eventId, gameId)) return BAD_ID;
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("games")
|
||||||
|
.select("id, sort_order")
|
||||||
|
.eq("event_id", eventId)
|
||||||
|
.order("sort_order")
|
||||||
|
.order("created_at");
|
||||||
|
if (error) return dbFail("Reordering", error.message);
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
moved(data, gameId, direction).map((game, index) =>
|
||||||
|
game.sort_order === index
|
||||||
|
? null
|
||||||
|
: supabase.from("games").update({ sort_order: index }).eq("id", game.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const failed = results.find((result) => result?.error);
|
||||||
|
if (failed?.error) return dbFail("Reordering", failed.error.message);
|
||||||
|
|
||||||
|
revalidatePath(eventPath(eventId));
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { EventDetailsForm } from "@/components/admin/event-details-form";
|
||||||
|
import { GamesEditor } from "@/components/admin/games-editor";
|
||||||
|
import { TeamsEditor } from "@/components/admin/teams-editor";
|
||||||
|
import { requireAdmin } from "@/lib/auth";
|
||||||
|
import { loadEventSetup } from "@/lib/queries/event";
|
||||||
|
import { idSchema } from "@/lib/tokens";
|
||||||
|
|
||||||
|
export default async function EventSetupPage(props: PageProps<"/admin/events/[eventId]">) {
|
||||||
|
const { supabase } = await requireAdmin();
|
||||||
|
const { eventId } = await props.params;
|
||||||
|
if (!idSchema.safeParse(eventId).success) notFound();
|
||||||
|
|
||||||
|
const setup = await loadEventSetup(supabase, eventId);
|
||||||
|
if (!setup) notFound();
|
||||||
|
const { event, teams, games } = setup;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div>
|
||||||
|
<Link href="/admin" className="text-sm underline underline-offset-4">
|
||||||
|
← All game nights
|
||||||
|
</Link>
|
||||||
|
<h1 className="mt-1 font-display text-4xl font-bold">{event.name}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-ink/15 bg-white p-4">
|
||||||
|
<h2 className="text-xl font-bold">Details</h2>
|
||||||
|
<EventDetailsForm
|
||||||
|
key={event.updated_at}
|
||||||
|
eventId={event.id}
|
||||||
|
name={event.name}
|
||||||
|
eventDate={event.event_date ?? ""}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-ink/15 bg-white p-4">
|
||||||
|
<h2 className="text-xl font-bold">Teams</h2>
|
||||||
|
<TeamsEditor eventId={event.id} teams={teams} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-bold">Games</h2>
|
||||||
|
<GamesEditor eventId={event.id} games={games} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { signOut } from "@/app/login/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { requireAdmin } from "@/lib/auth";
|
||||||
|
import { publicEnv } from "@/lib/env.public";
|
||||||
|
|
||||||
|
export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
|
||||||
|
const { email } = await requireAdmin();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh">
|
||||||
|
<header className="border-b border-ink/15 bg-white">
|
||||||
|
<div className="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-2 px-4 py-2">
|
||||||
|
<Link href="/admin" className="font-display text-2xl font-bold">
|
||||||
|
{publicEnv.NEXT_PUBLIC_APP_NAME}
|
||||||
|
</Link>
|
||||||
|
<form action={signOut} className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-ink/70">{email}</span>
|
||||||
|
<Button type="submit" variant="secondary" size="sm">
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="mx-auto max-w-5xl px-4 py-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { CreateEventForm } from "@/components/admin/create-event-form";
|
||||||
|
import { EventRowActions } from "@/components/admin/event-row-actions";
|
||||||
|
import { requireAdmin } from "@/lib/auth";
|
||||||
|
|
||||||
|
const dateFormat = new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeZone: "UTC" });
|
||||||
|
|
||||||
|
export default async function AdminHome() {
|
||||||
|
const { supabase } = await requireAdmin();
|
||||||
|
const { data: events, error } = await supabase
|
||||||
|
.from("events")
|
||||||
|
.select("id, name, event_date")
|
||||||
|
.order("created_at", { ascending: false });
|
||||||
|
if (error) throw new Error(`Could not load events: ${error.message}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<section>
|
||||||
|
<h1 className="font-display text-4xl font-bold">Your game nights</h1>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="mt-3 text-lg">No game nights yet. Create your first one below.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-4 flex flex-col gap-3">
|
||||||
|
{events.map((event) => (
|
||||||
|
<li
|
||||||
|
key={event.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-ink/15 bg-white p-4"
|
||||||
|
>
|
||||||
|
<Link href={`/admin/events/${event.id}`} className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-xl font-bold underline-offset-4 hover:underline">
|
||||||
|
{event.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-ink/70">
|
||||||
|
{event.event_date ? dateFormat.format(new Date(event.event_date)) : "No date set"}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<EventRowActions eventId={event.id} eventName={event.name} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-ink/15 bg-white p-4">
|
||||||
|
<h2 className="text-xl font-bold">New game night</h2>
|
||||||
|
<CreateEventForm />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
const otpTypeSchema = z.enum(["magiclink", "email", "signup", "invite", "recovery", "email_change"]);
|
||||||
|
|
||||||
|
// Landing point of the magic link. Handles both link styles Supabase can send:
|
||||||
|
// ?code=... (default email template, PKCE) and ?token_hash=...&type=... (custom template).
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = request.nextUrl;
|
||||||
|
const code = searchParams.get("code");
|
||||||
|
const tokenHash = searchParams.get("token_hash");
|
||||||
|
const type = otpTypeSchema.safeParse(searchParams.get("type"));
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
let signedIn = false;
|
||||||
|
if (code) {
|
||||||
|
const { error } = await supabase.auth.exchangeCodeForSession(code);
|
||||||
|
signedIn = !error;
|
||||||
|
} else if (tokenHash && type.success) {
|
||||||
|
const { error } = await supabase.auth.verifyOtp({ token_hash: tokenHash, type: type.data });
|
||||||
|
signedIn = !error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = signedIn ? "/admin" : "/login";
|
||||||
|
url.search = signedIn ? "" : "?error=link";
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { serverEnv } from "@/lib/env.server";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
export type LoginState = { status: "idle" | "sent" | "error"; message: string };
|
||||||
|
|
||||||
|
export async function sendMagicLink(_previous: LoginState, formData: FormData): Promise<LoginState> {
|
||||||
|
const parsed = z.email().safeParse(String(formData.get("email") ?? "").trim().toLowerCase());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { status: "error", message: "That doesn't look like an email address. Check it and try again." };
|
||||||
|
}
|
||||||
|
const email = parsed.data;
|
||||||
|
|
||||||
|
// Checked before calling Supabase so strangers can't create accounts or trigger emails.
|
||||||
|
if (!serverEnv.ADMIN_EMAILS.includes(email)) {
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
message: "This email isn't on the admin list. Add it to ADMIN_EMAILS and restart the app.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.auth.signInWithOtp({
|
||||||
|
email,
|
||||||
|
options: { emailRedirectTo: `${serverEnv.NEXT_PUBLIC_SITE_URL}/auth/confirm` },
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
const message =
|
||||||
|
error.status === 429
|
||||||
|
? "Too many sign-in emails were sent. Wait a few minutes and try again."
|
||||||
|
: `The sign-in email couldn't be sent (${error.message}). Try again.`;
|
||||||
|
return { status: "error", message };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "sent",
|
||||||
|
message: `Sign-in link sent to ${email}. Open it in this same browser.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signOut() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Field, inputClass } from "@/components/ui/field";
|
||||||
|
|
||||||
|
import { sendMagicLink, type LoginState } from "./actions";
|
||||||
|
|
||||||
|
const initialState: LoginState = { status: "idle", message: "" };
|
||||||
|
|
||||||
|
export function LoginForm({ initialError }: { initialError: string | null }) {
|
||||||
|
const [state, formAction, pending] = useActionState(sendMagicLink, initialState);
|
||||||
|
const error = state.status === "error" ? state.message : state.status === "idle" ? initialError : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="flex flex-col gap-4">
|
||||||
|
<Field label="Email">
|
||||||
|
<input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Sending…" : "Send sign-in link"}
|
||||||
|
</Button>
|
||||||
|
<p role="status" aria-live="polite" className="min-h-6">
|
||||||
|
{state.status === "sent" && <span className="font-bold text-ink">{state.message}</span>}
|
||||||
|
{error && <span className="font-bold text-alert">{error}</span>}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { publicEnv } from "@/lib/env.public";
|
||||||
|
|
||||||
|
import { LoginForm } from "./login-form";
|
||||||
|
|
||||||
|
const errors: Record<string, string> = {
|
||||||
|
link: "That sign-in link didn't work. It may have expired or been opened in a different browser. Send a new one.",
|
||||||
|
"not-admin": "You're signed in, but this email isn't on the admin list.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function LoginPage(props: PageProps<"/login">) {
|
||||||
|
const { error } = await props.searchParams;
|
||||||
|
const initialError = typeof error === "string" ? (errors[error] ?? null) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-dvh max-w-md flex-col justify-center gap-6 p-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-5xl font-bold">{publicEnv.NEXT_PUBLIC_APP_NAME}</h1>
|
||||||
|
<p className="mt-2 text-lg">Sign in as host. We'll email you a link — no password.</p>
|
||||||
|
</div>
|
||||||
|
<LoginForm initialError={initialError} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
|
||||||
|
import { createEvent } from "@/app/admin/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Field, inputClass } from "@/components/ui/field";
|
||||||
|
|
||||||
|
import { ErrorText } from "./error-text";
|
||||||
|
|
||||||
|
export function CreateEventForm() {
|
||||||
|
const [state, formAction, pending] = useActionState(createEvent, null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="mt-3 flex flex-wrap items-end gap-3">
|
||||||
|
<Field label="Name" className="min-w-56 flex-1">
|
||||||
|
<input name="name" required maxLength={120} className={inputClass} placeholder="Worship Game Night" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Date (optional)">
|
||||||
|
<input name="eventDate" type="date" className={inputClass} />
|
||||||
|
</Field>
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Creating…" : "Create game night"}
|
||||||
|
</Button>
|
||||||
|
<div className="basis-full">
|
||||||
|
<ErrorText>{state && !state.ok ? state.error : null}</ErrorText>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function ErrorText({ children }: { children: string | null }) {
|
||||||
|
return (
|
||||||
|
<p role="alert" className="font-bold text-alert empty:hidden">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { updateEvent } from "@/app/admin/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Field, inputClass } from "@/components/ui/field";
|
||||||
|
|
||||||
|
import { ErrorText } from "./error-text";
|
||||||
|
import { useAction } from "./use-action";
|
||||||
|
|
||||||
|
export function EventDetailsForm(props: { eventId: string; name: string; eventDate: string }) {
|
||||||
|
const [name, setName] = useState(props.name);
|
||||||
|
const [eventDate, setEventDate] = useState(props.eventDate);
|
||||||
|
const { pending, error, run } = useAction();
|
||||||
|
const dirty = name !== props.name || eventDate !== props.eventDate;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="mt-3 flex flex-wrap items-end gap-3"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
run(() => updateEvent(props.eventId, { name, eventDate }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Name" className="min-w-56 flex-1">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Date (optional)">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={eventDate}
|
||||||
|
onChange={(event) => setEventDate(event.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Button type="submit" variant="secondary" disabled={pending || !dirty}>
|
||||||
|
{pending ? "Saving…" : dirty ? "Save details" : "Saved"}
|
||||||
|
</Button>
|
||||||
|
<div className="basis-full">
|
||||||
|
<ErrorText>{error}</ErrorText>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { deleteEvent, duplicateEvent } from "@/app/admin/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
import { ErrorText } from "./error-text";
|
||||||
|
import { useAction } from "./use-action";
|
||||||
|
|
||||||
|
export function EventRowActions({ eventId, eventName }: { eventId: string; eventName: string }) {
|
||||||
|
const { pending, error, run } = useAction();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => run(() => duplicateEvent(eventId))}
|
||||||
|
>
|
||||||
|
Duplicate
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Delete "${eventName}" and all its teams, games and scores? This can't be undone.`)) {
|
||||||
|
run(() => deleteEvent(eventId));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<ErrorText>{error}</ErrorText>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { saveGame } from "@/app/admin/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Field, inputClass, textareaClass } from "@/components/ui/field";
|
||||||
|
import { criteriaPresets, type PresetKey } from "@/lib/presets";
|
||||||
|
import type { GameWithCriteria } from "@/lib/queries/event";
|
||||||
|
import { parsePlacementPoints, type CriterionDraft, type GameDraft } from "@/lib/validation/admin";
|
||||||
|
|
||||||
|
import { ErrorText } from "./error-text";
|
||||||
|
import { useAction } from "./use-action";
|
||||||
|
|
||||||
|
// A stable React key per criterion row, including rows that aren't saved yet.
|
||||||
|
type CriterionRow = CriterionDraft & { key: string };
|
||||||
|
type Draft = Omit<GameDraft, "criteria" | "placementPoints"> & {
|
||||||
|
placementPoints: string;
|
||||||
|
criteria: CriterionRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let nextKey = 0;
|
||||||
|
const newKey = () => `new-${nextKey++}`;
|
||||||
|
|
||||||
|
const blankCriterion = (): CriterionRow => ({
|
||||||
|
key: newKey(),
|
||||||
|
label: "",
|
||||||
|
description: "",
|
||||||
|
inputType: "blocks",
|
||||||
|
min: 1,
|
||||||
|
max: 5,
|
||||||
|
step: 1,
|
||||||
|
weight: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
function toDraft(game: GameWithCriteria): Draft {
|
||||||
|
return {
|
||||||
|
title: game.title,
|
||||||
|
description: game.description ?? "",
|
||||||
|
scoringMode: game.scoring_mode,
|
||||||
|
pointsMode: game.points_mode,
|
||||||
|
multiplier: game.multiplier,
|
||||||
|
placementPoints: game.placement_points.join(", "),
|
||||||
|
adminNotes: game.admin_notes ?? "",
|
||||||
|
judgeNotes: game.judge_notes ?? "",
|
||||||
|
criteria: game.criteria.map((criterion) => ({
|
||||||
|
key: criterion.id,
|
||||||
|
id: criterion.id,
|
||||||
|
label: criterion.label,
|
||||||
|
description: criterion.description ?? "",
|
||||||
|
inputType: criterion.input_type,
|
||||||
|
min: criterion.min_value,
|
||||||
|
max: criterion.max_value,
|
||||||
|
step: criterion.step,
|
||||||
|
weight: criterion.weight,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const presetKeys = Object.keys(criteriaPresets) as PresetKey[];
|
||||||
|
|
||||||
|
export function GameForm({ eventId, game }: { eventId: string; game: GameWithCriteria }) {
|
||||||
|
const [draft, setDraft] = useState(() => toDraft(game));
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const { pending, error, run } = useAction();
|
||||||
|
|
||||||
|
const change = (patch: Partial<Draft>) => {
|
||||||
|
setDraft((current) => ({ ...current, ...patch }));
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
|
const changeCriterion = (key: string, patch: Partial<CriterionDraft>) =>
|
||||||
|
change({
|
||||||
|
criteria: draft.criteria.map((criterion) =>
|
||||||
|
criterion.key === key ? { ...criterion, ...patch } : criterion,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const moveCriterion = (index: number, offset: -1 | 1) => {
|
||||||
|
const criteria = [...draft.criteria];
|
||||||
|
[criteria[index], criteria[index + offset]] = [criteria[index + offset], criteria[index]];
|
||||||
|
change({ criteria });
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPreset = (key: PresetKey) => {
|
||||||
|
const hasSaved = draft.criteria.some((criterion) => criterion.id);
|
||||||
|
if (
|
||||||
|
hasSaved &&
|
||||||
|
!window.confirm("Replace this game's criteria with the preset? Scores for the old criteria are deleted when you save.")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
change({
|
||||||
|
criteria: criteriaPresets[key].criteria.map((criterion) => ({ ...criterion, key: newKey() })),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = () =>
|
||||||
|
run(
|
||||||
|
() =>
|
||||||
|
saveGame(eventId, game.id, {
|
||||||
|
...draft,
|
||||||
|
placementPoints: parsePlacementPoints(draft.placementPoints),
|
||||||
|
criteria: draft.criteria.map((row) => {
|
||||||
|
const criterion: CriterionDraft & { key?: string } = { ...row };
|
||||||
|
delete criterion.key;
|
||||||
|
return criterion;
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
() => setDirty(false),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-5"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
save();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Title">
|
||||||
|
<input
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(event) => change({ title: event.target.value })}
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Description (optional)">
|
||||||
|
<input
|
||||||
|
value={draft.description}
|
||||||
|
onChange={(event) => change({ description: event.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Who scores"
|
||||||
|
hint={draft.scoringMode === "judged" ? "Judges score from their phones via links." : "You enter the scores yourself."}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
value={draft.scoringMode}
|
||||||
|
onChange={(event) => change({ scoringMode: event.target.value as Draft["scoringMode"] })}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="direct">Direct — host enters scores</option>
|
||||||
|
<option value="judged">Judged — judges score via links</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Leaderboard points"
|
||||||
|
hint={
|
||||||
|
draft.pointsMode === "placement"
|
||||||
|
? "Teams are ranked, then get the placement points below."
|
||||||
|
: "Teams get their criteria total as points."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
value={draft.pointsMode}
|
||||||
|
onChange={(event) => change({ pointsMode: event.target.value as Draft["pointsMode"] })}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="raw">Raw — criteria total</option>
|
||||||
|
<option value="placement">Placement — points by rank</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Multiplier" hint="Points are multiplied by this. Use 2 for a double-points game.">
|
||||||
|
<NumberInput value={draft.multiplier} min={0} step="any" onChange={(multiplier) => change({ multiplier })} />
|
||||||
|
</Field>
|
||||||
|
{draft.pointsMode === "placement" && (
|
||||||
|
<Field label="Placement points" hint="1st, 2nd, 3rd… separated by commas. Teams beyond the list get 0.">
|
||||||
|
<input
|
||||||
|
value={draft.placementPoints}
|
||||||
|
onChange={(event) => change({ placementPoints: event.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="10, 7, 5, 3"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="flex flex-col gap-3">
|
||||||
|
<legend className="text-lg font-bold">Criteria</legend>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-bold">Replace with preset:</span>
|
||||||
|
{presetKeys.map((key) => (
|
||||||
|
<Button key={key} variant="secondary" size="sm" onClick={() => applyPreset(key)}>
|
||||||
|
{criteriaPresets[key].label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className="flex flex-col gap-3">
|
||||||
|
{draft.criteria.map((criterion, index) => (
|
||||||
|
<li key={criterion.key} className="rounded-md border border-ink/15 bg-chalk p-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[2fr_3fr]">
|
||||||
|
<Field label="Label">
|
||||||
|
<input
|
||||||
|
value={criterion.label}
|
||||||
|
onChange={(event) => changeCriterion(criterion.key, { label: event.target.value })}
|
||||||
|
required
|
||||||
|
maxLength={60}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Pitch"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Description (shown to whoever scores)">
|
||||||
|
<input
|
||||||
|
value={criterion.description}
|
||||||
|
onChange={(event) => changeCriterion(criterion.key, { description: event.target.value })}
|
||||||
|
maxLength={300}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap items-end gap-3">
|
||||||
|
<Field label="Input">
|
||||||
|
<select
|
||||||
|
value={criterion.inputType}
|
||||||
|
onChange={(event) =>
|
||||||
|
changeCriterion(criterion.key, {
|
||||||
|
inputType: event.target.value as CriterionDraft["inputType"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="blocks">Blocks (tap a number)</option>
|
||||||
|
<option value="number">Number (type or +/−)</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Min" className="w-20">
|
||||||
|
<NumberInput value={criterion.min} step="any" onChange={(min) => changeCriterion(criterion.key, { min })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Max" className="w-20">
|
||||||
|
<NumberInput value={criterion.max} step="any" onChange={(max) => changeCriterion(criterion.key, { max })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Step" className="w-20">
|
||||||
|
<NumberInput value={criterion.step} step="any" onChange={(step) => changeCriterion(criterion.key, { step })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Weight" className="w-20">
|
||||||
|
<NumberInput value={criterion.weight} min={0} step="any" onChange={(weight) => changeCriterion(criterion.key, { weight })} />
|
||||||
|
</Field>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`Move ${criterion.label || "criterion"} up`}
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => moveCriterion(index, -1)}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`Move ${criterion.label || "criterion"} down`}
|
||||||
|
disabled={index === draft.criteria.length - 1}
|
||||||
|
onClick={() => moveCriterion(index, 1)}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
disabled={draft.criteria.length === 1}
|
||||||
|
onClick={() =>
|
||||||
|
change({ criteria: draft.criteria.filter((other) => other.key !== criterion.key) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={draft.criteria.length >= 20}
|
||||||
|
onClick={() => change({ criteria: [...draft.criteria, blankCriterion()] })}
|
||||||
|
>
|
||||||
|
Add criterion
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Notes for judges" hint="Judges see this on their page.">
|
||||||
|
<textarea
|
||||||
|
value={draft.judgeNotes}
|
||||||
|
onChange={(event) => change({ judgeNotes: event.target.value })}
|
||||||
|
rows={3}
|
||||||
|
className={textareaClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Notes for you" hint="Only you see this, in the control room.">
|
||||||
|
<textarea
|
||||||
|
value={draft.adminNotes}
|
||||||
|
onChange={(event) => change({ adminNotes: event.target.value })}
|
||||||
|
rows={3}
|
||||||
|
className={textareaClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button type="submit" disabled={pending || !dirty}>
|
||||||
|
{pending ? "Saving…" : "Save game"}
|
||||||
|
</Button>
|
||||||
|
<span role="status" className="text-sm font-bold">
|
||||||
|
{dirty ? "Unsaved changes" : "Saved ✓"}
|
||||||
|
</span>
|
||||||
|
<ErrorText>{error}</ErrorText>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Numeric input that reports NaN while empty, so validation can say "must be a number". */
|
||||||
|
function NumberInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
min?: number;
|
||||||
|
step?: number | "any";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={Number.isNaN(value) ? "" : value}
|
||||||
|
onChange={(event) => onChange(event.target.value === "" ? Number.NaN : Number(event.target.value))}
|
||||||
|
className={inputClass}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { addGame, deleteGame, duplicateGame, moveGame } from "@/app/admin/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { inputClass } from "@/components/ui/field";
|
||||||
|
import { criteriaPresets, type PresetKey } from "@/lib/presets";
|
||||||
|
import type { GameWithCriteria } from "@/lib/queries/event";
|
||||||
|
|
||||||
|
import { ErrorText } from "./error-text";
|
||||||
|
import { GameForm } from "./game-form";
|
||||||
|
import { useAction } from "./use-action";
|
||||||
|
|
||||||
|
const presetKeys = Object.keys(criteriaPresets) as PresetKey[];
|
||||||
|
|
||||||
|
export function GamesEditor({ eventId, games }: { eventId: string; games: GameWithCriteria[] }) {
|
||||||
|
const [preset, setPreset] = useState<PresetKey>("karaoke");
|
||||||
|
const { pending, error, run } = useAction();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex flex-col gap-3">
|
||||||
|
{games.length === 0 && <p>No games yet. Pick a starting point below and add one.</p>}
|
||||||
|
<ol className="flex flex-col gap-3">
|
||||||
|
{games.map((game, index) => (
|
||||||
|
<GameCard
|
||||||
|
key={game.id}
|
||||||
|
eventId={eventId}
|
||||||
|
game={game}
|
||||||
|
number={index + 1}
|
||||||
|
isFirst={index === 0}
|
||||||
|
isLast={index === games.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-ink/15 bg-white p-4">
|
||||||
|
<label className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-bold">Start from</span>
|
||||||
|
<select
|
||||||
|
value={preset}
|
||||||
|
onChange={(event) => setPreset(event.target.value as PresetKey)}
|
||||||
|
className={`${inputClass} w-auto`}
|
||||||
|
>
|
||||||
|
{presetKeys.map((key) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{criteriaPresets[key].label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<Button disabled={pending} onClick={() => run(() => addGame(eventId, preset))}>
|
||||||
|
Add game
|
||||||
|
</Button>
|
||||||
|
<ErrorText>{error}</ErrorText>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GameCard({
|
||||||
|
eventId,
|
||||||
|
game,
|
||||||
|
number,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
}: {
|
||||||
|
eventId: string;
|
||||||
|
game: GameWithCriteria;
|
||||||
|
number: number;
|
||||||
|
isFirst: boolean;
|
||||||
|
isLast: boolean;
|
||||||
|
}) {
|
||||||
|
const { pending, error, run } = useAction();
|
||||||
|
const criteriaCount = game.criteria.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="rounded-lg border border-ink/15 bg-white">
|
||||||
|
<details>
|
||||||
|
<summary className="flex min-h-11 cursor-pointer flex-wrap items-center gap-x-3 gap-y-1 p-4">
|
||||||
|
<span className="text-xl font-bold">
|
||||||
|
{number}. {game.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-ink/70">
|
||||||
|
{game.scoring_mode === "judged" ? "Judged" : "Direct"} ·{" "}
|
||||||
|
{game.points_mode === "placement" ? "placement points" : "raw points"} · {criteriaCount}{" "}
|
||||||
|
{criteriaCount === 1 ? "criterion" : "criteria"}
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 border-t border-ink/15 p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending || isFirst}
|
||||||
|
onClick={() => run(() => moveGame(eventId, game.id, "up"))}
|
||||||
|
>
|
||||||
|
↑ Move up
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending || isLast}
|
||||||
|
onClick={() => run(() => moveGame(eventId, game.id, "down"))}
|
||||||
|
>
|
||||||
|
↓ Move down
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => run(() => duplicateGame(eventId, game.id))}
|
||||||
|
>
|
||||||
|
Duplicate
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Delete "${game.title}" and its scores? This can't be undone.`)) {
|
||||||
|
run(() => deleteGame(eventId, game.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<ErrorText>{error}</ErrorText>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Re-mount after each save so the form picks up ids of newly created criteria. */}
|
||||||
|
<GameForm key={game.updated_at} eventId={eventId} game={game} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { addTeam, deleteTeam, moveTeam, updateTeam } from "@/app/admin/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { inputClass } from "@/components/ui/field";
|
||||||
|
import type { Tables } from "@/lib/database.types";
|
||||||
|
|
||||||
|
import { ErrorText } from "./error-text";
|
||||||
|
import { useAction } from "./use-action";
|
||||||
|
|
||||||
|
type Team = Tables<"teams">;
|
||||||
|
|
||||||
|
export function TeamsEditor({ eventId, teams }: { eventId: string; teams: Team[] }) {
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const { pending, error, run } = useAction();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex flex-col gap-3">
|
||||||
|
{teams.length === 0 && <p>No teams yet. Add the first one below.</p>}
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{teams.map((team, index) => (
|
||||||
|
<TeamRow
|
||||||
|
// Re-mount when saved values change so local edits reset to the server's copy.
|
||||||
|
key={`${team.id}:${team.name}:${team.color}`}
|
||||||
|
eventId={eventId}
|
||||||
|
team={team}
|
||||||
|
isFirst={index === 0}
|
||||||
|
isLast={index === teams.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="flex flex-wrap items-center gap-2"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
run(() => addTeam(eventId, newName), () => setNewName(""));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label="New team name"
|
||||||
|
value={newName}
|
||||||
|
onChange={(event) => setNewName(event.target.value)}
|
||||||
|
required
|
||||||
|
maxLength={60}
|
||||||
|
placeholder="Team name"
|
||||||
|
className={`${inputClass} max-w-xs`}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
Add team
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<ErrorText>{error}</ErrorText>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeamRow({
|
||||||
|
eventId,
|
||||||
|
team,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
}: {
|
||||||
|
eventId: string;
|
||||||
|
team: Team;
|
||||||
|
isFirst: boolean;
|
||||||
|
isLast: boolean;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(team.name);
|
||||||
|
const [color, setColor] = useState(team.color);
|
||||||
|
const { pending, error, run } = useAction();
|
||||||
|
|
||||||
|
// Saves on blur rather than per keystroke: one request per edit.
|
||||||
|
const save = () => {
|
||||||
|
if (name === team.name && color === team.color) return;
|
||||||
|
run(() => updateTeam(eventId, team.id, { name, color }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex flex-col gap-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
aria-label={`Colour for ${team.name}`}
|
||||||
|
value={color}
|
||||||
|
onChange={(event) => setColor(event.target.value)}
|
||||||
|
onBlur={save}
|
||||||
|
className="h-11 w-14 cursor-pointer rounded-md border border-ink/25 bg-white p-1"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label="Team name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
onBlur={save}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") event.currentTarget.blur();
|
||||||
|
}}
|
||||||
|
maxLength={60}
|
||||||
|
className={`${inputClass} min-w-40 flex-1`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`Move ${team.name} up`}
|
||||||
|
disabled={pending || isFirst}
|
||||||
|
onClick={() => run(() => moveTeam(eventId, team.id, "up"))}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`Move ${team.name} down`}
|
||||||
|
disabled={pending || isLast}
|
||||||
|
onClick={() => run(() => moveTeam(eventId, team.id, "down"))}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Delete team "${team.name}" and its scores?`)) {
|
||||||
|
run(() => deleteTeam(eventId, team.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ErrorText>{error}</ErrorText>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState, useTransition } from "react";
|
||||||
|
|
||||||
|
import type { ActionResult } from "@/app/admin/actions";
|
||||||
|
|
||||||
|
/** Runs a server action, tracking pending state and the last error message. */
|
||||||
|
export function useAction() {
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const run = useCallback((action: () => Promise<ActionResult>, onSuccess?: () => void) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const result = await action();
|
||||||
|
if (result.ok) {
|
||||||
|
setError(null);
|
||||||
|
onSuccess?.();
|
||||||
|
} else {
|
||||||
|
setError(result.error);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Couldn't reach the server. Check your connection and try again.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { pending, error, run };
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ButtonHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: "bg-ink text-white hover:bg-ink/90",
|
||||||
|
secondary: "border border-ink/25 bg-white text-ink hover:bg-ink/5",
|
||||||
|
danger: "border border-alert/50 bg-white text-alert hover:bg-alert/10",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
// min-h-11 = 44px tap target
|
||||||
|
md: "min-h-11 px-4 text-base",
|
||||||
|
sm: "min-h-11 min-w-11 px-3 text-sm",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ButtonVariant = keyof typeof variants;
|
||||||
|
|
||||||
|
export function buttonClass(variant: ButtonVariant = "primary", size: keyof typeof sizes = "md") {
|
||||||
|
return `inline-flex items-center justify-center gap-2 rounded-md font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${variants[variant]} ${sizes[size]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: keyof typeof sizes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({ variant, size, className = "", type = "button", ...props }: ButtonProps) {
|
||||||
|
return <button type={type} className={`${buttonClass(variant, size)} ${className}`} {...props} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export const inputClass =
|
||||||
|
"min-h-11 w-full rounded-md border border-ink/25 bg-white px-3 text-base text-ink placeholder:text-ink/40";
|
||||||
|
|
||||||
|
export const textareaClass = `${inputClass} py-2`;
|
||||||
|
|
||||||
|
/** Label wrapping its control, so the whole thing is one tap target and needs no ids. */
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
className = "",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className={`flex flex-col gap-1 ${className}`}>
|
||||||
|
<span className="text-sm font-bold">{label}</span>
|
||||||
|
{children}
|
||||||
|
{hint && <span className="text-sm text-ink/70">{hint}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { isAdminEmail } from "./admin-emails";
|
||||||
|
|
||||||
|
describe("isAdminEmail", () => {
|
||||||
|
const list = "Host@Example.com, second@example.com";
|
||||||
|
|
||||||
|
it("matches case-insensitively and ignores whitespace", () => {
|
||||||
|
expect(isAdminEmail("host@example.com", list)).toBe(true);
|
||||||
|
expect(isAdminEmail(" SECOND@example.com ", list)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects everyone else, including missing emails and an empty list", () => {
|
||||||
|
expect(isAdminEmail("stranger@example.com", list)).toBe(false);
|
||||||
|
expect(isAdminEmail(undefined, list)).toBe(false);
|
||||||
|
expect(isAdminEmail("", "")).toBe(false);
|
||||||
|
expect(isAdminEmail("host@example.com", undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** Parses the comma-separated ADMIN_EMAILS env var. */
|
||||||
|
export function parseAdminEmails(raw: string | undefined): string[] {
|
||||||
|
return (raw ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((email) => email.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdminEmail(email: unknown, raw: string | undefined): boolean {
|
||||||
|
return typeof email === "string" && parseAdminEmails(raw).includes(email.trim().toLowerCase());
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { serverEnv } from "@/lib/env.server";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call at the top of every admin page and server action. The proxy already guards
|
||||||
|
* /admin, but server actions are plain POST endpoints, so each one re-checks.
|
||||||
|
*/
|
||||||
|
export async function requireAdmin() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data } = await supabase.auth.getClaims();
|
||||||
|
const email = data?.claims.email?.toLowerCase();
|
||||||
|
|
||||||
|
if (!data || !email) redirect("/login");
|
||||||
|
if (!serverEnv.ADMIN_EMAILS.includes(email)) redirect("/login?error=not-admin");
|
||||||
|
|
||||||
|
return { supabase, userId: data.claims.sub, email };
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { CriterionDraft } from "@/lib/validation/admin";
|
||||||
|
|
||||||
|
const blocks = (label: string, min: number, max: number, description = ""): CriterionDraft => ({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
inputType: "blocks",
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step: 1,
|
||||||
|
weight: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Quick presets for a game's criteria (PRD 4.1). */
|
||||||
|
export const criteriaPresets = {
|
||||||
|
karaoke: {
|
||||||
|
label: "Karaoke (5 × 1–5)",
|
||||||
|
criteria: [
|
||||||
|
blocks("Pitch", 1, 5, "Did they hit the notes?"),
|
||||||
|
blocks("Energy", 1, 5, "How much did they give it?"),
|
||||||
|
blocks("Lyrics accuracy", 1, 5, "Right words, right time"),
|
||||||
|
blocks("Stage presence", 1, 5, "Confidence and movement"),
|
||||||
|
blocks("Crowd reaction", 1, 5, "How loud was the room?"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
single: {
|
||||||
|
label: "Single score (0–10)",
|
||||||
|
criteria: [blocks("Score", 0, 10)],
|
||||||
|
},
|
||||||
|
points: {
|
||||||
|
label: "Points (number)",
|
||||||
|
criteria: [
|
||||||
|
{
|
||||||
|
label: "Points",
|
||||||
|
description: "",
|
||||||
|
inputType: "number",
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
weight: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
winLose: {
|
||||||
|
label: "Win/Lose (0–1)",
|
||||||
|
criteria: [blocks("Win", 0, 1, "1 = won, 0 = lost")],
|
||||||
|
},
|
||||||
|
} satisfies Record<string, { label: string; criteria: CriterionDraft[] }>;
|
||||||
|
|
||||||
|
export type PresetKey = keyof typeof criteriaPresets;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
import type { Database, Tables } from "@/lib/database.types";
|
||||||
|
|
||||||
|
type Client = SupabaseClient<Database>;
|
||||||
|
|
||||||
|
export type GameWithCriteria = Tables<"games"> & { criteria: Tables<"criteria">[] };
|
||||||
|
|
||||||
|
export type EventSetup = {
|
||||||
|
event: Tables<"events">;
|
||||||
|
teams: Tables<"teams">[];
|
||||||
|
games: GameWithCriteria[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Everything the setup page needs. Works with either the admin's RLS client or the service client. */
|
||||||
|
export async function loadEventSetup(supabase: Client, eventId: string): Promise<EventSetup | null> {
|
||||||
|
const [event, teams, games] = await Promise.all([
|
||||||
|
supabase.from("events").select("*").eq("id", eventId).maybeSingle(),
|
||||||
|
supabase
|
||||||
|
.from("teams")
|
||||||
|
.select("*")
|
||||||
|
.eq("event_id", eventId)
|
||||||
|
.order("sort_order")
|
||||||
|
.order("created_at"),
|
||||||
|
supabase
|
||||||
|
.from("games")
|
||||||
|
.select("*, criteria(*)")
|
||||||
|
.eq("event_id", eventId)
|
||||||
|
.order("sort_order")
|
||||||
|
.order("created_at")
|
||||||
|
.order("sort_order", { referencedTable: "criteria" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const error = event.error ?? teams.error ?? games.error;
|
||||||
|
if (error) throw new Error(`Could not load the event: ${error.message}`);
|
||||||
|
if (!event.data) return null;
|
||||||
|
|
||||||
|
return { event: event.data, teams: teams.data ?? [], games: games.data ?? [] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { createServerClient } from "@supabase/ssr";
|
||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import type { Database } from "@/lib/database.types";
|
||||||
|
import { publicEnv } from "@/lib/env.public";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes the admin's auth cookie (Server Components can't write cookies, so the proxy
|
||||||
|
* does it) and returns the verified JWT claims, or null when signed out.
|
||||||
|
*/
|
||||||
|
export async function updateSession(request: NextRequest) {
|
||||||
|
let response = NextResponse.next({ request });
|
||||||
|
|
||||||
|
const supabase = createServerClient<Database>(
|
||||||
|
publicEnv.NEXT_PUBLIC_SUPABASE_URL,
|
||||||
|
publicEnv.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return request.cookies.getAll();
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
|
||||||
|
response = NextResponse.next({ request });
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
response.cookies.set(name, value, options),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// getClaims() verifies the token's signature; never trust the raw cookie.
|
||||||
|
const { data } = await supabase.auth.getClaims();
|
||||||
|
return { response, claims: data?.claims ?? null };
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { criteriaPresets } from "@/lib/presets";
|
||||||
|
|
||||||
|
import { criterionSchema, gameSchema, parsePlacementPoints, type GameDraft } from "./admin";
|
||||||
|
|
||||||
|
const criterion = { label: "Pitch", description: "", inputType: "blocks", min: 1, max: 5, step: 1, weight: 1 } as const;
|
||||||
|
|
||||||
|
const game: GameDraft = {
|
||||||
|
title: "Karaoke",
|
||||||
|
description: "",
|
||||||
|
scoringMode: "judged",
|
||||||
|
pointsMode: "raw",
|
||||||
|
multiplier: 1,
|
||||||
|
placementPoints: [10, 7, 5, 3],
|
||||||
|
adminNotes: "",
|
||||||
|
judgeNotes: "",
|
||||||
|
criteria: [criterion],
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = (result: ReturnType<typeof criterionSchema.safeParse>) =>
|
||||||
|
result.success ? null : result.error.issues[0].message;
|
||||||
|
|
||||||
|
describe("criterionSchema", () => {
|
||||||
|
it("accepts every preset", () => {
|
||||||
|
for (const preset of Object.values(criteriaPresets)) {
|
||||||
|
for (const draft of preset.criteria) {
|
||||||
|
expect(criterionSchema.safeParse(draft).success).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects max <= min", () => {
|
||||||
|
expect(message(criterionSchema.safeParse({ ...criterion, min: 5, max: 5 }))).toMatch(/max must be more/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a step that doesn't fit the range, but allows fractional steps", () => {
|
||||||
|
expect(message(criterionSchema.safeParse({ ...criterion, min: 0, max: 5, step: 2 }))).toMatch(/step/);
|
||||||
|
expect(criterionSchema.safeParse({ ...criterion, min: 0, max: 5, step: 0.5 }).success).toBe(true);
|
||||||
|
expect(criterionSchema.safeParse({ ...criterion, min: 0, max: 1, step: 0.1 }).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("limits blocks to 11 buttons but not number inputs", () => {
|
||||||
|
expect(message(criterionSchema.safeParse({ ...criterion, min: 0, max: 20 }))).toMatch(/blocks/);
|
||||||
|
expect(criterionSchema.safeParse({ ...criterion, inputType: "number", min: 0, max: 20 }).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty number field (NaN)", () => {
|
||||||
|
expect(message(criterionSchema.safeParse({ ...criterion, weight: Number.NaN }))).toMatch(/Weight must be a number/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("gameSchema", () => {
|
||||||
|
it("accepts a valid game and trims text", () => {
|
||||||
|
const parsed = gameSchema.parse({ ...game, title: " Karaoke " });
|
||||||
|
expect(parsed.title).toBe("Karaoke");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needs a title, a criterion and whole placement points", () => {
|
||||||
|
expect(gameSchema.safeParse({ ...game, title: " " }).success).toBe(false);
|
||||||
|
expect(gameSchema.safeParse({ ...game, criteria: [] }).success).toBe(false);
|
||||||
|
expect(gameSchema.safeParse({ ...game, placementPoints: [10, 7.5] }).success).toBe(false);
|
||||||
|
expect(gameSchema.safeParse({ ...game, placementPoints: [] }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parsePlacementPoints", () => {
|
||||||
|
it("splits on commas and spaces", () => {
|
||||||
|
expect(parsePlacementPoints("10, 7,5 3")).toEqual([10, 7, 5, 3]);
|
||||||
|
expect(parsePlacementPoints("")).toEqual([]);
|
||||||
|
expect(parsePlacementPoints("10, x")).toEqual([10, Number.NaN]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { idSchema } from "@/lib/tokens";
|
||||||
|
|
||||||
|
// Limits mirror the CHECK constraints in supabase/migrations/0001_init.sql so that
|
||||||
|
// the admin sees a friendly message instead of a database error.
|
||||||
|
|
||||||
|
const text = (max: number, what: string) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, `${what} can't be empty.`)
|
||||||
|
.max(max, `${what} can be at most ${max} characters.`);
|
||||||
|
|
||||||
|
const optionalText = (max: number, what: string) =>
|
||||||
|
z.string().trim().max(max, `${what} can be at most ${max} characters.`);
|
||||||
|
|
||||||
|
const number = (what: string) => z.number(`${what} must be a number.`);
|
||||||
|
|
||||||
|
export const eventSchema = z.object({
|
||||||
|
name: text(120, "Event name"),
|
||||||
|
eventDate: z.union([z.iso.date("Pick a valid date."), z.literal("")]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const teamSchema = z.object({
|
||||||
|
name: text(60, "Team name"),
|
||||||
|
color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, "Pick a colour."),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Is (max − min) a whole number of steps? Tolerates float error for steps like 0.5. */
|
||||||
|
const onStep = (min: number, max: number, step: number) => {
|
||||||
|
const steps = (max - min) / step;
|
||||||
|
return Math.abs(steps - Math.round(steps)) < 1e-9;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const criterionSchema = z
|
||||||
|
.object({
|
||||||
|
id: idSchema.optional(),
|
||||||
|
label: text(60, "Criterion label"),
|
||||||
|
description: optionalText(300, "Criterion description"),
|
||||||
|
inputType: z.enum(["blocks", "number"]),
|
||||||
|
min: number("Min").min(-999999).max(999999),
|
||||||
|
max: number("Max").min(-999999).max(999999),
|
||||||
|
step: number("Step").positive("Step must be more than 0.").max(999999),
|
||||||
|
weight: number("Weight").min(0, "Weight can't be negative.").max(9999),
|
||||||
|
})
|
||||||
|
.superRefine((criterion, context) => {
|
||||||
|
const name = criterion.label || "A criterion";
|
||||||
|
if (criterion.max <= criterion.min) {
|
||||||
|
context.addIssue({ code: "custom", message: `${name}: max must be more than min.` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!onStep(criterion.min, criterion.max, criterion.step)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `${name}: the step must divide evenly into the range from min to max.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
criterion.inputType === "blocks" &&
|
||||||
|
(criterion.max - criterion.min) / criterion.step > 10
|
||||||
|
) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `${name}: blocks can show at most 11 buttons. Use a number input or a bigger step.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const gameSchema = z.object({
|
||||||
|
title: text(120, "Game title"),
|
||||||
|
description: optionalText(2000, "Description"),
|
||||||
|
scoringMode: z.enum(["judged", "direct"]),
|
||||||
|
pointsMode: z.enum(["raw", "placement"]),
|
||||||
|
multiplier: number("Multiplier").min(0, "Multiplier can't be negative.").max(9999),
|
||||||
|
placementPoints: z
|
||||||
|
.array(z.number().int("Placement points must be whole numbers.").min(0).max(100000))
|
||||||
|
.min(1, "Add at least one placement value, like 10, 7, 5, 3.")
|
||||||
|
.max(30),
|
||||||
|
adminNotes: optionalText(4000, "Admin notes"),
|
||||||
|
judgeNotes: optionalText(4000, "Judge notes"),
|
||||||
|
criteria: z.array(criterionSchema).min(1, "A game needs at least one criterion.").max(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CriterionDraft = z.input<typeof criterionSchema>;
|
||||||
|
export type GameDraft = z.input<typeof gameSchema>;
|
||||||
|
|
||||||
|
/** Parses "10, 7, 5, 3" into numbers; invalid parts become NaN so validation reports them. */
|
||||||
|
export function parsePlacementPoints(value: string): number[] {
|
||||||
|
return value
|
||||||
|
.split(/[,\s]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => Number(part));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstError(error: z.ZodError): string {
|
||||||
|
return error.issues[0]?.message ?? "Something in the form isn't valid.";
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { isAdminEmail } from "@/lib/admin-emails";
|
||||||
|
import { updateSession } from "@/lib/supabase/proxy";
|
||||||
|
|
||||||
|
// Next.js 16 renamed middleware.ts to proxy.ts.
|
||||||
|
export async function proxy(request: NextRequest) {
|
||||||
|
const { response, claims } = await updateSession(request);
|
||||||
|
const isAdmin = isAdminEmail(claims?.email, process.env.ADMIN_EMAILS);
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
if (pathname.startsWith("/admin") && !isAdmin) {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = "/login";
|
||||||
|
url.search = claims ? "?error=not-admin" : "";
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
if (pathname === "/login" && isAdmin) {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = "/admin";
|
||||||
|
url.search = "";
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only admin routes need a session. Scoreboard and judge pages stay out of the
|
||||||
|
// proxy so they never wait on an auth check.
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/admin/:path*", "/login"],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user