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 ( - -
- - -
- -
- - - onChange({ ...value, nodeBg: event.target.value || undefined, preset: undefined }) - } - /> -
- -
- - - onChange({ ...value, connector: event.target.value || undefined, preset: undefined }) - } - /> -
- -
- - - onChange({ ...value, radius: event.target.value || undefined, preset: undefined }) - } - /> -
- - -
- ); -} +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) => ( +
  1. + + {index + 1}. + {participant.name} + + + + + +
  2. + ))} +
+ + {reseed.isError ? ( +

+ {reseed.error instanceof Error ? reseed.error.message : t("seedingError")} +

+ ) : null} + + +
+ ); +} + +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} +
  • ); @@ -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")}

    -
    - -
    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 ( + +
    +
    + + +
    + +
    + + + onChange({ + ...value, + nodeBg: hexToHslChannels(event.target.value), + preset: undefined, + }) + } + /> +
    + +
    + + + onChange({ + ...value, + connector: hexToHslChannels(event.target.value), + preset: undefined, + }) + } + /> +
    + +
    + + + onChange({ ...value, radius: event.target.value || undefined, preset: undefined }) + } + /> +
    + + +
    + +
    +
    +

    {t("themeLibrary")}

    + {isLoggedIn ? ( + + ) : 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. */} + + +
    • + ))} +
    + )} +
    +
    + ); +} + +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;