From 1f63b6ab2fce8a76269833d6b0a0dce72f0c3185 Mon Sep 17 00:00:00 2001
From: Tmgldbch
Date: Mon, 15 Jun 2026 09:46:23 +0200
Subject: [PATCH] feat(m2.1): saved themes, theme editor, link expiry/revoke,
reseeding
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Polish milestone built via an orchestrated 3-slice pass + a 4-lens adversarial
review (2 blockers + 1 major folded in). Type-checked (api + web exit 0) and the
engine tests still pass (12/12).
Backend (apps/api):
- themes module: user-scoped SavedTheme CRUD (GET/POST/DELETE /themes, 401 for
guests, owner-only delete).
- links: optional expiresInHours on create; DELETE /tournaments/:id/links/:linkId
to revoke.
- reseed: PUT /tournaments/:id/seeding regenerates the bracket from a new seed
order; the generation/persistence path is now a shared helper used by create()
and reseed(); all guards + the destructive regenerate run in one transaction;
emits an SSE change signal.
Frontend (apps/web):
- ThemeEditor (preset + color pickers via a new hex<->HSL util in lib/color.ts +
round-trip test) with a saved-theme library gated by useSession.
- SeedingEditor: dependency-free up/down reorder (dnd-kit isn't installable
offline) → reseed; locked once a real result exists.
- SharePanel: per-link TTL select + expiry display + revoke.
Review fix (the important one): auto-advanced byes are COMPLETED but carry no
score, so counting COMPLETED matches wrongly (a) flipped a fresh non-power-of-2
bracket to RUNNING and (b) locked reseeding immediately — exactly where seeding
matters. Introduced isScored() (both opponents carry a numeric score) and use it
for the reseed lock and status recompute; the frontend lock mirrors it. Also:
retry:false on auth-gated queries.
Deferred (see docs/PLAN.md): dnd-kit polish, re-score/correction, SSE
multi-replica, in-app dialogs instead of window.prompt/confirm.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
apps/api/src/app.module.ts | 2 +
apps/api/src/themes/themes.controller.ts | 52 +++++
apps/api/src/themes/themes.module.ts | 9 +
apps/api/src/themes/themes.service.ts | 54 +++++
apps/api/src/tournaments/links.service.ts | 25 +-
.../src/tournaments/tournaments.controller.ts | 24 +-
.../src/tournaments/tournaments.service.ts | 214 ++++++++++++++----
apps/web/messages/de.json | 25 ++
apps/web/messages/en.json | 25 ++
.../app/[locale]/tournaments/[id]/page.tsx | 113 ++-------
apps/web/src/components/SeedingEditor.tsx | 106 +++++++++
apps/web/src/components/SharePanel.tsx | 67 +++++-
apps/web/src/components/ThemeEditor.tsx | 190 ++++++++++++++++
apps/web/src/lib/api.ts | 40 +++-
apps/web/src/lib/color.spec.ts | 46 ++++
apps/web/src/lib/color.ts | 132 +++++++++++
apps/web/src/lib/queries.ts | 58 ++++-
apps/web/src/lib/vitest-shim.d.ts | 23 ++
docs/PLAN.md | 18 +-
packages/shared/src/schemas/dto.ts | 26 +++
20 files changed, 1086 insertions(+), 163 deletions(-)
create mode 100644 apps/api/src/themes/themes.controller.ts
create mode 100644 apps/api/src/themes/themes.module.ts
create mode 100644 apps/api/src/themes/themes.service.ts
create mode 100644 apps/web/src/components/SeedingEditor.tsx
create mode 100644 apps/web/src/components/ThemeEditor.tsx
create mode 100644 apps/web/src/lib/color.spec.ts
create mode 100644 apps/web/src/lib/color.ts
create mode 100644 apps/web/src/lib/vitest-shim.d.ts
diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
index 1a4d4f9..38ee3a9 100644
--- a/apps/api/src/app.module.ts
+++ b/apps/api/src/app.module.ts
@@ -4,6 +4,7 @@ import { LoggerModule } from "nestjs-pino";
import { ServiceTokenGuard } from "./common/service-token.guard";
import { HealthController } from "./health.controller";
import { PrismaModule } from "./prisma/prisma.module";
+import { ThemesModule } from "./themes/themes.module";
import { TournamentsModule } from "./tournaments/tournaments.module";
import { UsersModule } from "./users/users.module";
@@ -29,6 +30,7 @@ import { UsersModule } from "./users/users.module";
PrismaModule,
UsersModule,
TournamentsModule,
+ ThemesModule,
],
controllers: [HealthController],
providers: [
diff --git a/apps/api/src/themes/themes.controller.ts b/apps/api/src/themes/themes.controller.ts
new file mode 100644
index 0000000..f1fd0c0
--- /dev/null
+++ b/apps/api/src/themes/themes.controller.ts
@@ -0,0 +1,52 @@
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ HttpCode,
+ Param,
+ Post,
+ UnauthorizedException,
+ UsePipes,
+} from "@nestjs/common";
+import {
+ CreateSavedThemeInput,
+ SavedThemeDto,
+ createSavedThemeInputSchema,
+} from "@tournamentify/shared";
+import { Actor, CurrentActor } from "../common/current-actor.decorator";
+import { ZodValidationPipe } from "../common/zod-validation.pipe";
+import { ThemesService } from "./themes.service";
+
+@Controller("themes")
+export class ThemesController {
+ constructor(private readonly themes: ThemesService) {}
+
+ @Get()
+ list(@CurrentActor() actor: Actor): Promise {
+ return this.themes.list(requireUser(actor));
+ }
+
+ @Post()
+ @UsePipes(new ZodValidationPipe(createSavedThemeInputSchema))
+ create(
+ @CurrentActor() actor: Actor,
+ @Body() input: CreateSavedThemeInput,
+ ): Promise {
+ return this.themes.create(requireUser(actor), input);
+ }
+
+ @Delete(":themeId")
+ @HttpCode(204)
+ remove(@CurrentActor() actor: Actor, @Param("themeId") themeId: string): Promise {
+ return this.themes.remove(requireUser(actor), themeId);
+ }
+}
+
+/** Saved themes are tied to an account — guests (anon token only) get 401. */
+function requireUser(actor: Actor): string {
+ if (!actor.userId) {
+ throw new UnauthorizedException("Saved themes require a logged-in account");
+ }
+ return actor.userId;
+}
diff --git a/apps/api/src/themes/themes.module.ts b/apps/api/src/themes/themes.module.ts
new file mode 100644
index 0000000..bdf4e08
--- /dev/null
+++ b/apps/api/src/themes/themes.module.ts
@@ -0,0 +1,9 @@
+import { Module } from "@nestjs/common";
+import { ThemesController } from "./themes.controller";
+import { ThemesService } from "./themes.service";
+
+@Module({
+ controllers: [ThemesController],
+ providers: [ThemesService],
+})
+export class ThemesModule {}
diff --git a/apps/api/src/themes/themes.service.ts b/apps/api/src/themes/themes.service.ts
new file mode 100644
index 0000000..cb0a0d9
--- /dev/null
+++ b/apps/api/src/themes/themes.service.ts
@@ -0,0 +1,54 @@
+import { Injectable, NotFoundException } from "@nestjs/common";
+import { Prisma, SavedTheme } from "@prisma/client";
+import { CreateSavedThemeInput, SavedThemeDto, designTokensSchema } from "@tournamentify/shared";
+import { PrismaService } from "../prisma/prisma.service";
+
+/**
+ * Saved themes are an account feature: every operation is scoped to a single
+ * owning user. Stored tokens are validated through the shared schema on the way
+ * out so a malformed row never leaks an unexpected shape to the client.
+ */
+@Injectable()
+export class ThemesService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async list(userId: string): Promise {
+ const themes = await this.prisma.savedTheme.findMany({
+ where: { ownerUserId: userId },
+ orderBy: { createdAt: "desc" },
+ });
+ return themes.map(toSavedTheme);
+ }
+
+ async create(userId: string, input: CreateSavedThemeInput): Promise {
+ const theme = await this.prisma.savedTheme.create({
+ data: {
+ ownerUserId: userId,
+ name: input.name,
+ tokens: input.tokens as Prisma.InputJsonValue,
+ },
+ });
+ return toSavedTheme(theme);
+ }
+
+ async remove(userId: string, themeId: string): Promise {
+ // deleteMany scoped to the owner so another user's theme is never touched;
+ // a zero count means it either does not exist or is not ours -> 404.
+ const result = await this.prisma.savedTheme.deleteMany({
+ where: { id: themeId, ownerUserId: userId },
+ });
+ if (result.count === 0) {
+ throw new NotFoundException("Theme not found");
+ }
+ }
+}
+
+function toSavedTheme(theme: SavedTheme): SavedThemeDto {
+ const parsed = designTokensSchema.safeParse(theme.tokens);
+ return {
+ id: theme.id,
+ name: theme.name,
+ tokens: parsed.success ? parsed.data : {},
+ createdAt: theme.createdAt.toISOString(),
+ };
+}
diff --git a/apps/api/src/tournaments/links.service.ts b/apps/api/src/tournaments/links.service.ts
index c9e69db..4143b86 100644
--- a/apps/api/src/tournaments/links.service.ts
+++ b/apps/api/src/tournaments/links.service.ts
@@ -1,25 +1,46 @@
import { randomUUID } from "crypto";
-import { Injectable } from "@nestjs/common";
+import { Injectable, NotFoundException } from "@nestjs/common";
import { CapabilityLink, CapabilityType } from "@prisma/client";
import { CapabilityLinkDto } from "@tournamentify/shared";
import { PrismaService } from "../prisma/prisma.service";
import { toCapabilityLink } from "./tournament.mapper";
+const MS_PER_HOUR = 60 * 60 * 1000;
+
@Injectable()
export class LinksService {
constructor(private readonly prisma: PrismaService) {}
- async create(tournamentId: string, type: CapabilityType): Promise {
+ async create(
+ tournamentId: string,
+ type: CapabilityType,
+ expiresInHours?: number,
+ ): Promise {
+ const expiresAt =
+ expiresInHours !== undefined
+ ? new Date(Date.now() + expiresInHours * MS_PER_HOUR)
+ : null;
const link = await this.prisma.capabilityLink.create({
data: {
tournamentId,
type,
token: randomUUID(),
+ expiresAt,
},
});
return toCapabilityLink(link);
}
+ /** Revoke (delete) a link, but only if it belongs to the given tournament. */
+ async revoke(tournamentId: string, linkId: string): Promise {
+ const result = await this.prisma.capabilityLink.deleteMany({
+ where: { id: linkId, tournamentId },
+ });
+ if (result.count === 0) {
+ throw new NotFoundException("Link not found");
+ }
+ }
+
async list(tournamentId: string): Promise {
const links = await this.prisma.capabilityLink.findMany({
where: { tournamentId },
diff --git a/apps/api/src/tournaments/tournaments.controller.ts b/apps/api/src/tournaments/tournaments.controller.ts
index 323dd22..5efe699 100644
--- a/apps/api/src/tournaments/tournaments.controller.ts
+++ b/apps/api/src/tournaments/tournaments.controller.ts
@@ -8,6 +8,7 @@ import {
Param,
Patch,
Post,
+ Put,
Query,
Sse,
UsePipes,
@@ -17,6 +18,7 @@ import {
CreateCapabilityLinkInput,
CreateTournamentInput,
MatchUpdateEvent,
+ ReseedInput,
ScoreInput,
TournamentDetailDto,
TournamentSetup,
@@ -25,6 +27,7 @@ import {
createCapabilityLinkInputSchema,
createTournamentInputSchema,
importSetupInputSchema,
+ reseedInputSchema,
scoreInputSchema,
updateDesignInputSchema,
} from "@tournamentify/shared";
@@ -107,7 +110,7 @@ export class TournamentsController {
@CurrentActor() actor: Actor,
@Body(new ZodValidationPipe(createCapabilityLinkInputSchema)) body: CreateCapabilityLinkInput,
): Promise {
- return this.tournaments.createLink(id, actor, body.type);
+ return this.tournaments.createLink(id, actor, body.type, body.expiresInHours);
}
@Get(":id/links")
@@ -115,6 +118,25 @@ export class TournamentsController {
return this.tournaments.listLinks(id, actor);
}
+ @Delete(":id/links/:linkId")
+ @HttpCode(204)
+ revokeLink(
+ @Param("id") id: string,
+ @Param("linkId") linkId: string,
+ @CurrentActor() actor: Actor,
+ ): Promise {
+ return this.tournaments.revokeLink(id, actor, linkId);
+ }
+
+ @Put(":id/seeding")
+ reseed(
+ @Param("id") id: string,
+ @CurrentActor() actor: Actor,
+ @Body(new ZodValidationPipe(reseedInputSchema)) body: ReseedInput,
+ ): Promise {
+ return this.tournaments.reseed(actor, id, body.participantIds);
+ }
+
@Post(":id/matches/:matchId/score")
score(
@Param("id") id: string,
diff --git a/apps/api/src/tournaments/tournaments.service.ts b/apps/api/src/tournaments/tournaments.service.ts
index 7b4794a..9b02688 100644
--- a/apps/api/src/tournaments/tournaments.service.ts
+++ b/apps/api/src/tournaments/tournaments.service.ts
@@ -94,57 +94,142 @@ export class TournamentsService {
idByIndex[index] = created.id;
}
- for (const [stageIndex, stageSetup] of input.stages.entries()) {
- const generated = this.generator.generateStage(stageSetup, input.participants.length);
- const stage = await tx.stage.create({
- data: {
- tournamentId: tournament.id,
- type: toPrismaStageType(stageSetup.type),
- number: stageIndex + 1,
- // Carry the display name inside settings — Stage has no name column.
- settings: {
- ...stageSetup.settings,
- name: stageSetup.name,
- } as Prisma.InputJsonValue,
- },
+ await this.generateAndPersistStages(tx, tournament.id, input.stages, idByIndex);
+ await this.autoAdvanceByes(tx, tournament.id);
+
+ return tournament.id;
+ });
+
+ return this.loadDetail(id, true);
+ }
+
+ /**
+ * Regenerate the bracket from a new seeding order (DRAFT only). The set of
+ * participant ids must be identical to the tournament's current one — this is
+ * a reorder, not an add/remove. Existing stages are dropped and rebuilt from
+ * their stored setups in the new participant order, then byes are settled.
+ */
+ async reseed(
+ actor: Actor,
+ id: string,
+ participantIds: string[],
+ ): Promise {
+ await this.assertOwner(id, actor);
+
+ // Everything that guards and performs the destructive regenerate runs in one
+ // transaction, so a concurrent score() can't slip a real result past the lock.
+ await this.prisma.$transaction(async (tx) => {
+ const tournament = await tx.tournament.findUniqueOrThrow({
+ where: { id },
+ include: {
+ participants: true,
+ stages: { orderBy: { number: "asc" } },
+ },
+ });
+
+ // Lock once a real result exists. Auto-advanced byes are COMPLETED but
+ // carry no score, so they must NOT lock the seeding.
+ const scored = await this.countScored(tx, id);
+ if (scored > 0) {
+ throw new BadRequestException("Setzliste gesperrt");
+ }
+
+ // The new order must be a permutation of the existing participant ids:
+ // same length and same set (no additions, removals, or duplicates).
+ const existingIds = new Set(tournament.participants.map((p) => p.id));
+ const uniqueIncoming = new Set(participantIds);
+ if (
+ participantIds.length !== existingIds.size ||
+ uniqueIncoming.size !== participantIds.length ||
+ participantIds.some((pid) => !existingIds.has(pid))
+ ) {
+ throw new BadRequestException("Ungueltige Setzliste");
+ }
+
+ // Reuse the stored setups (type + name + format settings) but drive
+ // generation from the *new* participant order.
+ const stageSetups: TournamentSetup["stages"] = tournament.stages.map((stage) => ({
+ type: toDomainStageType(stage.type),
+ name: settingsName(stage.settings),
+ settings: stripStageName(stage.settings),
+ }));
+
+ // Apply the new seeds (1-based index in the supplied order).
+ for (const [index, participantId] of participantIds.entries()) {
+ await tx.participant.update({
+ where: { id: participantId },
+ data: { seed: index + 1 },
});
+ }
- for (const group of generated.groups) {
- const groupRow = await tx.group.create({
- data: { stageId: stage.id, number: group.number },
+ // Drop all stages — cascades remove groups/rounds/matches.
+ await tx.stage.deleteMany({ where: { tournamentId: id } });
+
+ await this.generateAndPersistStages(tx, id, stageSetups, participantIds);
+ await this.autoAdvanceByes(tx, id);
+ await this.recomputeStatus(tx, id);
+ });
+
+ this.events.emit(id);
+ return this.loadDetail(id, true);
+ }
+
+ /**
+ * Generate each stage's bracket and persist the Stage/Group/Round/Match rows.
+ * `idByIndex` maps the 0-based participantIndex emitted by the generator onto
+ * concrete Participant ids; its length is the participant count fed to the
+ * generator. Shared by create() and reseed().
+ */
+ private async generateAndPersistStages(
+ tx: TxClient,
+ tournamentId: string,
+ stages: TournamentSetup["stages"],
+ idByIndex: string[],
+ ): Promise {
+ for (const [stageIndex, stageSetup] of stages.entries()) {
+ const generated = this.generator.generateStage(stageSetup, idByIndex.length);
+ const stage = await tx.stage.create({
+ data: {
+ tournamentId,
+ type: toPrismaStageType(stageSetup.type),
+ number: stageIndex + 1,
+ // Carry the display name inside settings — Stage has no name column.
+ settings: {
+ ...stageSetup.settings,
+ name: stageSetup.name,
+ } as Prisma.InputJsonValue,
+ },
+ });
+
+ for (const group of generated.groups) {
+ const groupRow = await tx.group.create({
+ data: { stageId: stage.id, number: group.number },
+ });
+
+ for (const round of group.rounds) {
+ const roundRow = await tx.round.create({
+ data: {
+ groupId: groupRow.id,
+ number: round.number,
+ nameOverride: round.name,
+ bestOf: 1,
+ },
});
- for (const round of group.rounds) {
- const roundRow = await tx.round.create({
+ for (const match of round.matches) {
+ await tx.match.create({
data: {
- groupId: groupRow.id,
- number: round.number,
- nameOverride: round.name,
- bestOf: 1,
+ roundId: roundRow.id,
+ number: match.number,
+ status: "PENDING",
+ opponent1: slotToJson(match.opponent1, idByIndex),
+ opponent2: slotToJson(match.opponent2, idByIndex),
},
});
-
- for (const match of round.matches) {
- await tx.match.create({
- data: {
- roundId: roundRow.id,
- number: match.number,
- status: "PENDING",
- opponent1: slotToJson(match.opponent1, idByIndex),
- opponent2: slotToJson(match.opponent2, idByIndex),
- },
- });
- }
}
}
}
-
- await this.autoAdvanceByes(tx, tournament.id);
-
- return tournament.id;
- });
-
- return this.loadDetail(id, true);
+ }
}
async list(actor: Actor): Promise {
@@ -251,9 +336,14 @@ export class TournamentsService {
return { claimed: result.count };
}
- async createLink(id: string, actor: Actor, type: "VIEW" | "SCORE"): Promise {
+ async createLink(
+ id: string,
+ actor: Actor,
+ type: "VIEW" | "SCORE",
+ expiresInHours?: number,
+ ): Promise {
await this.assertOwner(id, actor);
- return this.links.create(id, type);
+ return this.links.create(id, type, expiresInHours);
}
async listLinks(id: string, actor: Actor): Promise {
@@ -261,6 +351,11 @@ export class TournamentsService {
return this.links.list(id);
}
+ async revokeLink(id: string, actor: Actor, linkId: string): Promise {
+ await this.assertOwner(id, actor);
+ await this.links.revoke(id, linkId);
+ }
+
/**
* Enter a score for a match and propagate the consequences. Authorized for the
* owner or a valid SCORE link. Both opponents must already be resolved
@@ -479,22 +574,34 @@ export class TournamentsService {
}
}
+ /** Count COMPLETED matches that carry actual entered scores (excludes byes). */
+ private async countScored(tx: TxClient, tournamentId: string): Promise {
+ const matches = await tx.match.findMany({
+ where: { status: "COMPLETED", round: { group: { stage: { tournamentId } } } },
+ select: { opponent1: true, opponent2: true },
+ });
+ return matches.filter((m) => isScored(m.opponent1, m.opponent2)).length;
+ }
+
/**
* Derive the tournament status from its matches: COMPLETED when every match is
- * COMPLETED, RUNNING when at least one is, otherwise DRAFT. Idempotent.
+ * COMPLETED, RUNNING once a genuinely played match exists, otherwise DRAFT.
+ * Auto-advanced byes are COMPLETED but unscored, so they keep a fresh bracket
+ * in DRAFT rather than flipping it to RUNNING. Idempotent.
*/
private async recomputeStatus(tx: TxClient, tournamentId: string): Promise {
- const [total, completed] = await Promise.all([
+ const [total, completed, scored] = await Promise.all([
tx.match.count({ where: { round: { group: { stage: { tournamentId } } } } }),
tx.match.count({
where: { status: "COMPLETED", round: { group: { stage: { tournamentId } } } },
}),
+ this.countScored(tx, tournamentId),
]);
let status: "DRAFT" | "RUNNING" | "COMPLETED";
if (total > 0 && completed === total) {
status = "COMPLETED";
- } else if (completed > 0) {
+ } else if (scored > 0) {
status = "RUNNING";
} else {
status = "DRAFT";
@@ -545,6 +652,21 @@ function slotToJson(slot: GeneratedSlot, idByIndex: string[]): Prisma.InputJsonV
}
}
+/** A match counts as genuinely played only when BOTH opponents carry a numeric
+ * score. Auto-advanced byes are COMPLETED but unscored, so they return false. */
+function isScored(opponent1: unknown, opponent2: unknown): boolean {
+ const a = parseSlot(opponent1);
+ const b = parseSlot(opponent2);
+ return (
+ a !== null &&
+ "participantId" in a &&
+ typeof a.score === "number" &&
+ b !== null &&
+ "participantId" in b &&
+ typeof b.score === "number"
+ );
+}
+
function settingsName(settings: Prisma.JsonValue): string {
if (settings && typeof settings === "object" && !Array.isArray(settings)) {
const name = (settings as Record).name;
diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json
index 1bd40ee..de71095 100644
--- a/apps/web/messages/de.json
+++ b/apps/web/messages/de.json
@@ -69,6 +69,23 @@
"themeSave": "Design speichern",
"themeSaving": "Wird gespeichert …",
"themeError": "Design konnte nicht gespeichert werden",
+ "themeLibrary": "Gespeicherte Designs",
+ "themeSaveAs": "Als Design speichern",
+ "themeNone": "Kein gespeichertes Design",
+ "themeDelete": "Löschen",
+ "themeDeleteConfirm": "Dieses gespeicherte Design wirklich löschen?",
+ "themeLoginHint": "Melde dich an, um Designs zu speichern und wiederzuverwenden.",
+ "themeNamePrompt": "Name für das Design",
+ "themeColorNode": "Knotenfarbe",
+ "themeColorConnector": "Verbindungsfarbe",
+ "seedingTitle": "Setzliste",
+ "seedingHint": "Ändere die Reihenfolge der Teilnehmer und speichere, um den Turnierbaum neu zu erzeugen.",
+ "seedingLocked": "Die Setzliste ist gesperrt, sobald die ersten Ergebnisse eingetragen sind.",
+ "seedingSave": "Setzliste speichern",
+ "seedingSaving": "Wird gespeichert …",
+ "seedingError": "Setzliste konnte nicht gespeichert werden",
+ "seedingUp": "Nach oben",
+ "seedingDown": "Nach unten",
"status": {
"DRAFT": "Entwurf",
"RUNNING": "Läuft",
@@ -91,6 +108,14 @@
"copy": "Kopieren",
"copied": "Kopiert",
"copyError": "Kopieren fehlgeschlagen",
+ "expiry": "Gültigkeit",
+ "expiryNever": "Unbegrenzt",
+ "expiry24h": "24 Stunden",
+ "expiry7d": "7 Tage",
+ "expiresLabel": "Läuft ab",
+ "revoke": "Widerrufen",
+ "revoking": "Wird widerrufen …",
+ "revokeConfirm": "Diesen Link wirklich widerrufen?",
"type": {
"VIEW": "Ansicht",
"SCORE": "Ergebnis"
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 465b617..65dc4bf 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -69,6 +69,23 @@
"themeSave": "Save theme",
"themeSaving": "Saving …",
"themeError": "Could not save the theme",
+ "themeLibrary": "Saved themes",
+ "themeSaveAs": "Save as theme",
+ "themeNone": "No saved theme",
+ "themeDelete": "Delete",
+ "themeDeleteConfirm": "Really delete this saved theme?",
+ "themeLoginHint": "Sign in to save and reuse themes.",
+ "themeNamePrompt": "Name for the theme",
+ "themeColorNode": "Node colour",
+ "themeColorConnector": "Connector colour",
+ "seedingTitle": "Seeding",
+ "seedingHint": "Reorder the participants and save to regenerate the bracket.",
+ "seedingLocked": "Seeding is locked once the first scores have been entered.",
+ "seedingSave": "Save seeding",
+ "seedingSaving": "Saving …",
+ "seedingError": "Could not save the seeding",
+ "seedingUp": "Move up",
+ "seedingDown": "Move down",
"status": {
"DRAFT": "Draft",
"RUNNING": "Running",
@@ -91,6 +108,14 @@
"copy": "Copy",
"copied": "Copied",
"copyError": "Copy failed",
+ "expiry": "Expiry",
+ "expiryNever": "Never",
+ "expiry24h": "24 hours",
+ "expiry7d": "7 days",
+ "expiresLabel": "Expires",
+ "revoke": "Revoke",
+ "revoking": "Revoking …",
+ "revokeConfirm": "Really revoke this link?",
"type": {
"VIEW": "View",
"SCORE": "Score"
diff --git a/apps/web/src/app/[locale]/tournaments/[id]/page.tsx b/apps/web/src/app/[locale]/tournaments/[id]/page.tsx
index d47b236..be6563c 100644
--- a/apps/web/src/app/[locale]/tournaments/[id]/page.tsx
+++ b/apps/web/src/app/[locale]/tournaments/[id]/page.tsx
@@ -11,98 +11,10 @@ import { ScoreableMatch } from "@/components/ScoreableMatch";
import { RoundRobinMatches } from "@/components/RoundRobinMatches";
import SharePanel from "@/components/SharePanel";
import { StageView } from "@/components/bracket/StageView";
-import { designToStyle, PRESETS } from "@/components/bracket/design";
-import { Button, Card, Input, Label } from "@/components/ui";
-
-const PRESET_NAMES = Object.keys(PRESETS);
-
-/** Owner theme controls: preset picker + the three overridable tokens. */
-function ThemeBar({
- value,
- onChange,
- onSave,
- saving,
-}: {
- value: DesignTokens;
- onChange: (next: DesignTokens) => void;
- onSave: () => void;
- saving: boolean;
-}) {
- const t = useTranslations("detail");
-
- function applyPreset(presetName: string) {
- const preset = PRESETS[presetName];
- if (!preset) {
- onChange({ ...value, preset: undefined });
- return;
- }
- onChange({ ...preset });
- }
-
- return (
-
-
- {t("themePreset")}
- applyPreset(event.target.value)}
- >
- {t("themeCustom")}
- {PRESET_NAMES.map((name) => (
-
- {name}
-
- ))}
-
-
-
-
- {t("themeNodeBg")}
-
- onChange({ ...value, nodeBg: event.target.value || undefined, preset: undefined })
- }
- />
-
-
-
- {t("themeConnector")}
-
- onChange({ ...value, connector: event.target.value || undefined, preset: undefined })
- }
- />
-
-
-
- {t("themeRadius")}
-
- onChange({ ...value, radius: event.target.value || undefined, preset: undefined })
- }
- />
-
-
-
- {saving ? t("themeSaving") : t("themeSave")}
-
-
- );
-}
+import { designToStyle } from "@/components/bracket/design";
+import { Button } from "@/components/ui";
+import { ThemeEditor } from "@/components/ThemeEditor";
+import { SeedingEditor } from "@/components/SeedingEditor";
function DetailContent() {
const t = useTranslations("detail");
@@ -176,6 +88,19 @@ function DetailContent() {
);
}
+ // Seeding can only be reordered before any real result is entered. Auto-advanced
+ // byes are COMPLETED but carry no score, so we lock only on an actually played
+ // match (both opponents have a numeric score) — mirrors the backend lock.
+ const hasPlayedResult = data.stages.some((stage) =>
+ stage.groups.some((group) =>
+ group.rounds.some((round) =>
+ round.matches.some(
+ (match) => match.opponent1?.score != null && match.opponent2?.score != null,
+ ),
+ ),
+ ),
+ );
+
const renderMatch = (match: MatchDto) => (
) : null}
- updateDesign.mutate(localDesign)}
@@ -232,6 +157,8 @@ function DetailContent() {
) : null}
+
+
diff --git a/apps/web/src/components/SeedingEditor.tsx b/apps/web/src/components/SeedingEditor.tsx
new file mode 100644
index 0000000..fe945ca
--- /dev/null
+++ b/apps/web/src/components/SeedingEditor.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import type { ParticipantDto } from "@tournamentify/shared";
+import { useReseed } from "@/lib/queries";
+import { Button, Card } from "@/components/ui";
+
+/**
+ * Dependency-free seeding editor. Participants are reordered with per-row Up/Down
+ * buttons (no drag-and-drop), then the new order is persisted via PUT /seeding,
+ * which regenerates the bracket. Reseeding is only allowed while the tournament
+ * is still a DRAFT; once results exist the editor is locked and read-only.
+ */
+
+/** Move the item at `index` by `delta` (±1), returning a new array. */
+function move
(items: T[], index: number, delta: number): T[] {
+ const target = index + delta;
+ if (target < 0 || target >= items.length) {
+ return items;
+ }
+ const next = items.slice();
+ const [item] = next.splice(index, 1);
+ next.splice(target, 0, item!);
+ return next;
+}
+
+export function SeedingEditor({
+ id,
+ participants,
+ locked,
+}: {
+ id: string;
+ participants: ParticipantDto[];
+ locked: boolean;
+}) {
+ const t = useTranslations("detail");
+ const reseed = useReseed(id);
+
+ const [order, setOrder] = useState(participants);
+
+ // Keep local order in sync when the upstream participant list changes (e.g.
+ // after a successful reseed re-renders the parent with fresh data).
+ useEffect(() => {
+ setOrder(participants);
+ }, [participants]);
+
+ function onSave() {
+ reseed.mutate(order.map((p) => p.id));
+ }
+
+ return (
+
+
+
{t("seedingTitle")}
+
{t("seedingHint")}
+
+
+ {locked ? {t("seedingLocked")}
: null}
+
+
+ {order.map((participant, index) => (
+
+
+ {index + 1}.
+ {participant.name}
+
+
+ setOrder((current) => move(current, index, -1))}
+ >
+ {t("seedingUp")}
+
+ setOrder((current) => move(current, index, 1))}
+ >
+ {t("seedingDown")}
+
+
+
+ ))}
+
+
+ {reseed.isError ? (
+
+ {reseed.error instanceof Error ? reseed.error.message : t("seedingError")}
+
+ ) : null}
+
+
+ {reseed.isPending ? t("seedingSaving") : t("seedingSave")}
+
+
+ );
+}
+
+export default SeedingEditor;
diff --git a/apps/web/src/components/SharePanel.tsx b/apps/web/src/components/SharePanel.tsx
index 97bcf1a..8d0e842 100644
--- a/apps/web/src/components/SharePanel.tsx
+++ b/apps/web/src/components/SharePanel.tsx
@@ -3,8 +3,19 @@
import { useLocale, useTranslations } from "next-intl";
import { useState } from "react";
import type { CapabilityLinkDto } from "@tournamentify/shared";
-import { useCapabilityLinks, useCreateCapabilityLink } from "@/lib/queries";
-import { Button, Card } from "@/components/ui";
+import {
+ useCapabilityLinks,
+ useCreateCapabilityLink,
+ useRevokeCapabilityLink,
+} from "@/lib/queries";
+import { Button, Card, Label, Select } from "@/components/ui";
+
+/** Time-to-live options for a new link. `0` means "never expires". */
+const TTL_OPTIONS = [
+ { value: 0, labelKey: "expiryNever" },
+ { value: 24, labelKey: "expiry24h" },
+ { value: 24 * 7, labelKey: "expiry7d" },
+] as const;
/**
* Owner-only sharing panel. Lists the tournament's capability links and lets the
@@ -22,6 +33,7 @@ function buildShareUrl(locale: string, id: string, token: string): string {
function LinkRow({ id, link }: { id: string; link: CapabilityLinkDto }) {
const t = useTranslations("share");
const locale = useLocale();
+ const revokeLink = useRevokeCapabilityLink(id);
const [copied, setCopied] = useState(false);
const [copyError, setCopyError] = useState(false);
@@ -38,19 +50,36 @@ function LinkRow({ id, link }: { id: string; link: CapabilityLinkDto }) {
}
}
+ function onRevoke() {
+ if (!window.confirm(t("revokeConfirm"))) {
+ return;
+ }
+ revokeLink.mutate(link.id);
+ }
+
+ const expiry = link.expiresAt
+ ? new Date(link.expiresAt).toLocaleString(locale)
+ : t("expiryNever");
+
return (
-
+
{t(`type.${link.type}`)}
{link.token}
+
+ {t("expiresLabel")} {expiry}
+
{copyError ? {t("copyError")} : null}
{copied ? t("copied") : t("copy")}
+
+ {revokeLink.isPending ? t("revoking") : t("revoke")}
+
);
@@ -61,6 +90,13 @@ export default function SharePanel({ id }: { id: string }) {
const links = useCapabilityLinks(id);
const createLink = useCreateCapabilityLink(id);
+ // TTL in hours for the next link; 0 = never expires (omit expiresInHours).
+ const [ttlHours, setTtlHours] = useState(0);
+
+ function create(type: "VIEW" | "SCORE") {
+ createLink.mutate({ type, expiresInHours: ttlHours > 0 ? ttlHours : undefined });
+ }
+
return (
@@ -68,15 +104,26 @@ export default function SharePanel({ id }: { id: string }) {
{t("title")}
{t("description")}
-
-
createLink.mutate("VIEW")}
- disabled={createLink.isPending}
- >
+
+
+ {t("expiry")}
+ setTtlHours(Number(event.target.value))}
+ >
+ {TTL_OPTIONS.map((option) => (
+
+ {t(option.labelKey)}
+
+ ))}
+
+
+
create("VIEW")} disabled={createLink.isPending}>
{t("createView")}
-
createLink.mutate("SCORE")} disabled={createLink.isPending}>
+ create("SCORE")} disabled={createLink.isPending}>
{t("createScore")}
diff --git a/apps/web/src/components/ThemeEditor.tsx b/apps/web/src/components/ThemeEditor.tsx
new file mode 100644
index 0000000..28b73a9
--- /dev/null
+++ b/apps/web/src/components/ThemeEditor.tsx
@@ -0,0 +1,190 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { useSession } from "next-auth/react";
+import type { DesignTokens } from "@tournamentify/shared";
+import { PRESETS } from "@/components/bracket/design";
+import { hexToHslChannels, hslChannelsToHex } from "@/lib/color";
+import { useCreateSavedTheme, useDeleteSavedTheme, useSavedThemes } from "@/lib/queries";
+import { Button, Card, Input, Label, Select } from "@/components/ui";
+
+/**
+ * Bracket theme editor: a preset picker, native color pickers for the node and
+ * connector colors (bridged hex <-> "H S% L%" via lib/color so the picker shows
+ * the real token value), a radius field, and a Save button.
+ *
+ * For logged-in users it also exposes a saved-theme library — apply / delete a
+ * stored theme, or "Save as…" the current tokens under a name. Guests see a hint
+ * prompting them to log in.
+ */
+
+const PRESET_NAMES = Object.keys(PRESETS);
+
+export function ThemeEditor({
+ value,
+ onChange,
+ onSave,
+ saving,
+}: {
+ value: DesignTokens;
+ onChange: (next: DesignTokens) => void;
+ onSave: () => void;
+ saving: boolean;
+}) {
+ const t = useTranslations("detail");
+ const { status } = useSession();
+ const isLoggedIn = status === "authenticated";
+
+ const savedThemes = useSavedThemes(isLoggedIn);
+ const createTheme = useCreateSavedTheme();
+ const deleteTheme = useDeleteSavedTheme();
+
+ function applyPreset(presetName: string) {
+ const preset = PRESETS[presetName];
+ if (!preset) {
+ onChange({ ...value, preset: undefined });
+ return;
+ }
+ onChange({ ...preset });
+ }
+
+ // Color pickers speak hex; tokens are "H S% L%" channel triplets. Fall back to
+ // the classic preset's values so an unset token still gives the picker a swatch.
+ const nodeHex = hslChannelsToHex(value.nodeBg ?? PRESETS.classic!.nodeBg!);
+ const connectorHex = hslChannelsToHex(value.connector ?? PRESETS.classic!.connector!);
+
+ function onSaveAs() {
+ const name = window.prompt(t("themeNamePrompt"));
+ if (!name) {
+ return;
+ }
+ createTheme.mutate({ name, tokens: value });
+ }
+
+ function onDeleteTheme(themeId: string) {
+ if (!window.confirm(t("themeDeleteConfirm"))) {
+ return;
+ }
+ deleteTheme.mutate(themeId);
+ }
+
+ return (
+
+
+
+ {t("themePreset")}
+ applyPreset(event.target.value)}
+ >
+ {t("themeCustom")}
+ {PRESET_NAMES.map((name) => (
+
+ {name}
+
+ ))}
+
+
+
+
+ {t("themeColorNode")}
+
+ onChange({
+ ...value,
+ nodeBg: hexToHslChannels(event.target.value),
+ preset: undefined,
+ })
+ }
+ />
+
+
+
+ {t("themeColorConnector")}
+
+ onChange({
+ ...value,
+ connector: hexToHslChannels(event.target.value),
+ preset: undefined,
+ })
+ }
+ />
+
+
+
+ {t("themeRadius")}
+
+ onChange({ ...value, radius: event.target.value || undefined, preset: undefined })
+ }
+ />
+
+
+
+ {saving ? t("themeSaving") : t("themeSave")}
+
+
+
+
+
+
{t("themeLibrary")}
+ {isLoggedIn ? (
+
+ {t("themeSaveAs")}
+
+ ) : null}
+
+
+ {!isLoggedIn ? (
+
{t("themeLoginHint")}
+ ) : savedThemes.isLoading ? (
+
{t("loading")}
+ ) : !savedThemes.data || savedThemes.data.length === 0 ? (
+
{t("themeNone")}
+ ) : (
+
+ {savedThemes.data.map((theme) => (
+
+ {/* Clicking the name applies the theme's tokens to the live editor. */}
+ onChange({ ...theme.tokens })}
+ >
+ {theme.name}
+
+ onDeleteTheme(theme.id)}
+ disabled={deleteTheme.isPending}
+ >
+ {t("themeDelete")}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+export default ThemeEditor;
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index e2a96d4..e299684 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -1,6 +1,8 @@
import type {
CapabilityLinkDto,
+ CreateSavedThemeInput,
CreateTournamentInput,
+ SavedThemeDto,
ScoreInput,
TournamentDetailDto,
TournamentSetup,
@@ -93,10 +95,14 @@ export function updateDesign(id: string, design: UpdateDesignInput): Promise {
+export function createCapabilityLink(
+ id: string,
+ type: "VIEW" | "SCORE",
+ expiresInHours?: number,
+): Promise {
return request(`${BFF}/tournaments/${encodeURIComponent(id)}/links`, {
method: "POST",
- body: JSON.stringify({ type }),
+ body: JSON.stringify({ type, expiresInHours }),
});
}
@@ -104,6 +110,36 @@ export function listCapabilityLinks(id: string): Promise {
return request(`${BFF}/tournaments/${encodeURIComponent(id)}/links`);
}
+export function revokeCapabilityLink(id: string, linkId: string): Promise {
+ return request(
+ `${BFF}/tournaments/${encodeURIComponent(id)}/links/${encodeURIComponent(linkId)}`,
+ { method: "DELETE" },
+ );
+}
+
+export function reseed(id: string, participantIds: string[]): Promise {
+ return request(`${BFF}/tournaments/${encodeURIComponent(id)}/seeding`, {
+ method: "PUT",
+ body: JSON.stringify({ participantIds }),
+ });
+}
+
+// Saved themes (account feature) — user-scoped, not tied to a tournament.
+export function listSavedThemes(): Promise {
+ return request(`${BFF}/themes`);
+}
+
+export function createSavedTheme(input: CreateSavedThemeInput): Promise {
+ return request(`${BFF}/themes`, {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+}
+
+export function deleteSavedTheme(themeId: string): Promise {
+ return request(`${BFF}/themes/${encodeURIComponent(themeId)}`, { method: "DELETE" });
+}
+
/** URL for an EventSource (SSE) subscription — used with `new EventSource(...)`, not fetch. */
export function eventsUrl(id: string, token?: string): string {
const query = token ? `?token=${encodeURIComponent(token)}` : "";
diff --git a/apps/web/src/lib/color.spec.ts b/apps/web/src/lib/color.spec.ts
new file mode 100644
index 0000000..20d7088
--- /dev/null
+++ b/apps/web/src/lib/color.spec.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import { hexToHslChannels, hslChannelsToHex } from "./color";
+
+/**
+ * Round-trip stability is what matters for the color picker: a token loaded into
+ * the and written straight back must not drift. We assert
+ * stability (idempotence after the first conversion) rather than bit-exact
+ * equality, since hex -> HSL rounds to whole degrees/percents.
+ */
+describe("color helpers", () => {
+ it("converts white to a neutral, near-full-lightness triplet", () => {
+ expect(hexToHslChannels("#ffffff")).toBe("0 0% 100%");
+ expect(hslChannelsToHex("0 0% 100%")).toBe("#ffffff");
+ });
+
+ it("converts black to a zero-lightness triplet", () => {
+ expect(hexToHslChannels("#000000")).toBe("0 0% 0%");
+ expect(hslChannelsToHex("0 0% 0%")).toBe("#000000");
+ });
+
+ it("is stable for a mid hue after the first round-trip", () => {
+ const hex = "#4f8fce";
+ const channels = hexToHslChannels(hex);
+ const back = hslChannelsToHex(channels);
+ // hex -> channels -> hex -> channels must be a fixed point.
+ expect(hexToHslChannels(back)).toBe(channels);
+ expect(hslChannelsToHex(hexToHslChannels(back))).toBe(back);
+ });
+
+ it("round-trips a saturated primary stably", () => {
+ const channels = hexToHslChannels("#ff0000");
+ expect(channels).toBe("0 100% 50%");
+ expect(hslChannelsToHex(channels)).toBe("#ff0000");
+ });
+
+ it("falls back to defaults on garbage input", () => {
+ expect(hexToHslChannels("not-a-color")).toBe("0 0% 0%");
+ expect(hslChannelsToHex("nope")).toBe("#000000");
+ expect(hexToHslChannels("#zzz")).toBe("0 0% 0%");
+ });
+
+ it("accepts shorthand hex", () => {
+ expect(hexToHslChannels("#fff")).toBe("0 0% 100%");
+ expect(hexToHslChannels("#000")).toBe("0 0% 0%");
+ });
+});
diff --git a/apps/web/src/lib/color.ts b/apps/web/src/lib/color.ts
new file mode 100644
index 0000000..e9945da
--- /dev/null
+++ b/apps/web/src/lib/color.ts
@@ -0,0 +1,132 @@
+/**
+ * Color conversion helpers bridging the two representations the app uses:
+ *
+ * - HSL channel triplets ("210 40% 98%") — the form CSS variables expect, so
+ * they can be dropped straight into hsl(var(--…)). This is how design tokens
+ * (nodeBg / connector) are stored.
+ * - Hex strings ("#rrggbb") — what native reads and writes.
+ *
+ * Both functions are tolerant of malformed input and fall back to a sensible
+ * default rather than throwing, so the UI never crashes on a stray token.
+ */
+
+const DEFAULT_HEX = "#000000";
+const DEFAULT_CHANNELS = "0 0% 0%";
+
+/** Clamp a number into [min, max]. */
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, value));
+}
+
+/** Expand "#abc" -> "aabbcc" and strip a leading "#"; returns null if unusable. */
+function normalizeHex(hex: string): string | null {
+ if (typeof hex !== "string") {
+ return null;
+ }
+ let value = hex.trim().replace(/^#/, "");
+ if (/^[0-9a-fA-F]{3}$/.test(value)) {
+ value = value
+ .split("")
+ .map((c) => c + c)
+ .join("");
+ }
+ if (/^[0-9a-fA-F]{6}$/.test(value)) {
+ return value.toLowerCase();
+ }
+ return null;
+}
+
+/**
+ * Convert a hex color to the "H S% L%" channel form CSS variables expect.
+ * Hue is rounded to a whole degree; saturation/lightness to whole percents.
+ * Returns "0 0% 0%" for input that cannot be parsed.
+ */
+export function hexToHslChannels(hex: string): string {
+ const normalized = normalizeHex(hex);
+ if (!normalized) {
+ return DEFAULT_CHANNELS;
+ }
+
+ const r = parseInt(normalized.slice(0, 2), 16) / 255;
+ const g = parseInt(normalized.slice(2, 4), 16) / 255;
+ const b = parseInt(normalized.slice(4, 6), 16) / 255;
+
+ const max = Math.max(r, g, b);
+ const min = Math.min(r, g, b);
+ const delta = max - min;
+
+ const l = (max + min) / 2;
+
+ let h = 0;
+ let s = 0;
+ if (delta !== 0) {
+ s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min);
+ switch (max) {
+ case r:
+ h = (g - b) / delta + (g < b ? 6 : 0);
+ break;
+ case g:
+ h = (b - r) / delta + 2;
+ break;
+ default:
+ h = (r - g) / delta + 4;
+ break;
+ }
+ h *= 60;
+ }
+
+ const hh = Math.round(h) % 360;
+ const ss = Math.round(s * 100);
+ const ll = Math.round(l * 100);
+ return `${hh < 0 ? hh + 360 : hh} ${ss}% ${ll}%`;
+}
+
+/** Format a 0..255 channel as a two-digit lowercase hex pair. */
+function toHexPair(value: number): string {
+ return clamp(Math.round(value), 0, 255).toString(16).padStart(2, "0");
+}
+
+/**
+ * Convert a "H S% L%" channel triplet to "#rrggbb".
+ * Returns "#000000" for input that cannot be parsed.
+ */
+export function hslChannelsToHex(channels: string): string {
+ if (typeof channels !== "string") {
+ return DEFAULT_HEX;
+ }
+ const match = channels
+ .trim()
+ .match(/^(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)%?\s+(-?\d+(?:\.\d+)?)%?$/);
+ if (!match) {
+ return DEFAULT_HEX;
+ }
+
+ const h = ((Number(match[1]) % 360) + 360) % 360;
+ const s = clamp(Number(match[2]), 0, 100) / 100;
+ const l = clamp(Number(match[3]), 0, 100) / 100;
+
+ if (s === 0) {
+ const gray = toHexPair(l * 255);
+ return `#${gray}${gray}${gray}`;
+ }
+
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+ const p = 2 * l - q;
+
+ const hueToChannel = (t: number): number => {
+ let tt = t;
+ if (tt < 0) tt += 1;
+ if (tt > 1) tt -= 1;
+ if (tt < 1 / 6) return p + (q - p) * 6 * tt;
+ if (tt < 1 / 2) return q;
+ if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
+ return p;
+ };
+
+ const hk = h / 360;
+ const r = hueToChannel(hk + 1 / 3) * 255;
+ const g = hueToChannel(hk) * 255;
+ const b = hueToChannel(hk - 1 / 3) * 255;
+
+ return `#${toHexPair(r)}${toHexPair(g)}${toHexPair(b)}`;
+}
diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts
index eaa43e4..db94b91 100644
--- a/apps/web/src/lib/queries.ts
+++ b/apps/web/src/lib/queries.ts
@@ -2,6 +2,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
+ CreateSavedThemeInput,
CreateTournamentInput,
ScoreInput,
TournamentDetailDto,
@@ -95,15 +96,70 @@ export function useCapabilityLinks(id: string, enabled = true) {
queryKey: linkKeys.list(id),
queryFn: () => api.listCapabilityLinks(id),
enabled: Boolean(id) && enabled,
+ retry: false, // auth-gated; don't retry a 401/403
});
}
export function useCreateCapabilityLink(id: string) {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: (type: "VIEW" | "SCORE") => api.createCapabilityLink(id, type),
+ mutationFn: (vars: { type: "VIEW" | "SCORE"; expiresInHours?: number }) =>
+ api.createCapabilityLink(id, vars.type, vars.expiresInHours),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: linkKeys.list(id) });
},
});
}
+
+export function useRevokeCapabilityLink(id: string) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (linkId: string) => api.revokeCapabilityLink(id, linkId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: linkKeys.list(id) });
+ },
+ });
+}
+
+export function useReseed(id: string) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (participantIds: string[]) => api.reseed(id, participantIds),
+ onSuccess: (detail: TournamentDetailDto) => {
+ queryClient.setQueryData(tournamentKeys.detail(id), detail);
+ },
+ });
+}
+
+export const themeKeys = {
+ list: () => ["saved-themes"] as const,
+};
+
+export function useSavedThemes(enabled = true) {
+ return useQuery({
+ queryKey: themeKeys.list(),
+ queryFn: () => api.listSavedThemes(),
+ enabled,
+ retry: false, // auth-gated; don't retry a 401/403
+ });
+}
+
+export function useCreateSavedTheme() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (input: CreateSavedThemeInput) => api.createSavedTheme(input),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: themeKeys.list() });
+ },
+ });
+}
+
+export function useDeleteSavedTheme() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (themeId: string) => api.deleteSavedTheme(themeId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: themeKeys.list() });
+ },
+ });
+}
diff --git a/apps/web/src/lib/vitest-shim.d.ts b/apps/web/src/lib/vitest-shim.d.ts
new file mode 100644
index 0000000..24fbb0b
--- /dev/null
+++ b/apps/web/src/lib/vitest-shim.d.ts
@@ -0,0 +1,23 @@
+/**
+ * Minimal ambient declaration for the `vitest` test API.
+ *
+ * The web workspace has no test runner wired up (and `vitest` is not installed
+ * in its node_modules), but co-located *.spec.ts files are still type-checked by
+ * `tsc -p apps/web/tsconfig.json` because the project globs `**\/*.ts`. Without
+ * this shim those specs fail with "Cannot find module 'vitest'". The shim only
+ * declares the surface our specs use; when the package is actually installed its
+ * real types take precedence.
+ */
+declare module "vitest" {
+ type TestFn = () => void | Promise;
+
+ export function describe(name: string, fn: () => void): void;
+ export function it(name: string, fn: TestFn): void;
+ export function test(name: string, fn: TestFn): void;
+
+ interface Assertion {
+ toBe(expected: unknown): void;
+ toEqual(expected: unknown): void;
+ }
+ export function expect(actual: unknown): Assertion;
+}
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 3605770..299ff54 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -129,17 +129,19 @@ Auth.js-v5-Flow und der BFF-SSE-Stream.
## M2 — Bekannte Follow-ups (aus dem adversarialen Review)
-Bewusst auf M2.1+ verschoben:
-- **Capability-Links:** Default-Expiry/TTL + Revoke-Endpoint (aktuell laufen Links nie ab); der
- SSE-Token reist im Query-String — der Reverse-Proxy sollte `/events`-URLs nicht mit Query loggen.
-- **Voller Theme-Editor:** gespeicherte Theme-Bibliothek (`SavedTheme`-CRUD) + Farb-Picker statt
- HSL-Textfeldern; aktuell Presets + drei Token-Felder, live angewendet & persistiert.
-- **Drag-Reseeding** des generierten Brackets (dnd-kit) — Seeding wird derzeit beim Erstellen über
- die Eingabe-Reihenfolge gesetzt.
+**M2.1 ✅ umgesetzt:** Saved-Theme-Bibliothek (`SavedTheme`-CRUD, Account-Feature) + voller Theme-Editor
+(Color-Picker, Hex↔HSL), Capability-Link-**Expiry + Revoke**, und **Reseeding** (Reihenfolge ändern →
+Bracket-Regenerate; gesperrt sobald ein *echtes* Ergebnis existiert — Auto-Byes zählen nicht).
+Reorder ist dependency-frei (Hoch/Runter), weil dnd-kit offline nicht installierbar war.
+
+Noch offen (M3 / später):
+- **dnd-kit-Politur** fürs Reseeding (aktuell dependency-freier Hoch/Runter-Reorder).
- **Re-Scoring/Korrektur** abgeschlossener Matches inkl. Re-Compute der Folgerunden — derzeit
serverseitig abgelehnt (verhindert veraltete Sieger downstream).
- **SSE-Skalierung:** `EventsService` ist prozess-lokal (eine Instanz). Multi-Replica braucht
- Redis-Pub/Sub oder Postgres `LISTEN/NOTIFY`.
+ Redis-Pub/Sub oder Postgres `LISTEN/NOTIFY`. SSE-Token reist im Query-String → `/events`-URLs
+ nicht mit Query loggen.
+- **In-App-Dialoge** statt `window.prompt/confirm` (Theme speichern, Revoke/Delete bestätigen).
⚠️ Vor dem Start (online), wie M1: `pnpm install`, `prisma migrate`, dann `pnpm build && pnpm typecheck`.
Besonders laufzeit-prüfen: Progression/Bye-Advance, SSE durch den BFF (EventSource sendet keine Header),
diff --git a/packages/shared/src/schemas/dto.ts b/packages/shared/src/schemas/dto.ts
index 6860950..e8a63cc 100644
--- a/packages/shared/src/schemas/dto.ts
+++ b/packages/shared/src/schemas/dto.ts
@@ -27,6 +27,8 @@ export type UserSyncInput = z.infer;
export const createCapabilityLinkInputSchema = z.object({
type: z.enum(["VIEW", "SCORE"]),
+ /** Optional lifetime; the server turns this into an absolute expiresAt. */
+ expiresInHours: z.number().int().positive().max(8760).optional(),
});
export type CreateCapabilityLinkInput = z.infer;
@@ -159,3 +161,27 @@ export const capabilityLinkDtoSchema = z.object({
expiresAt: z.string().nullable(),
});
export type CapabilityLinkDto = z.infer;
+
+// ---------------------------------------------------------------------------
+// Saved themes (account feature) + reseeding (M2.1)
+// ---------------------------------------------------------------------------
+
+export const savedThemeDtoSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ tokens: designTokensSchema,
+ createdAt: z.string(),
+});
+export type SavedThemeDto = z.infer;
+
+export const createSavedThemeInputSchema = z.object({
+ name: z.string().min(1).max(60),
+ tokens: designTokensSchema,
+});
+export type CreateSavedThemeInput = z.infer;
+
+/** Reorder participants (new seed order) and regenerate the bracket — DRAFT only. */
+export const reseedInputSchema = z.object({
+ participantIds: z.array(z.string()).min(2),
+});
+export type ReseedInput = z.infer;