diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a4186..1f43f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6955,10 +6955,10 @@ Neste steg: `TeamFlightBoard`) og High-low-high (`round-leaderboard.tsx`s `HighLowBoard`/`session-scorecard.tsx`). Migrasjoner bekreftet live t.o.m. 052. Samme rettelse lagt inn i FEATURE_BACKLOG.md samme dag. - **Reell, fortsatt åpen gap (urelatert til de åtte formatene):** + ~~**Reell, fortsatt åpen gap (urelatert til de åtte formatene):** organisator-vendt opplasting av hero-/sponsorbilder for turnering-landingssiden (ADR-018) — backend/lagring finnes, ingen - dra-og-slipp-skjerm bygget ennå. + dra-og-slipp-skjerm bygget ennå.~~ — **lukket samme dag, se punkt 16.** 15. **Ferdig, kun for historikk:** «Det store grepet» (ADR-040) — alle 5 steg (backend/allowance_override, lag-sortering, rundeoppsett- veiviseren, delt Score/Scorekort/Leaderboard-fane-rad, integrering) @@ -6967,3 +6967,68 @@ Neste steg: scorekortet mer enn celle-fargelegging for å skille lagene tydelig nok (utover selve sorteringen, som allerede er fikset) — ikke stilt til V0 ennå, egen liten vurdering om ønskelig. Se FEATURE_BACKLOG.md. +16. **Turnering-presentasjon: organisator-vendt hero-bilde/sponsor- + opplasting + beskrivelse/synlighet/påmeldingsinnstillinger — BYGGET, + SCRATCH-VERIFISERT OG LIVE 2026-08-03.** Brukeren spurte om + presentasjonssider var på plass; svaret avdekket at backenden for + dette (`hero_image_key`/sponsor-CRUD/`visibility`/`description`/ + påmeldingsfelt) hadde vært klar og LIVE siden ADR-018 (2026-07-18), + men INGEN organisator-skjerm noensinne satte disse feltene — alt var + 100% API-only. Bekreftet omfanget eksplisitt med bruker (fullt + presentasjons-panel, ikke bare bilder) før bygging. + Ny `components/tournament-presentation.tsx`: `TournamentPresentation` + (full side, egen rute `/tournaments/[id]/presentation`, brukt av + lagturneringer) + `TournamentPresentationPanel` (samme innhold uten + header/nav, bygget inn som en fjerde in-page-fane i + `individual-tournament-detail.tsx` -- denne filen har sitt eget + fanesystem, ikke egne ruter som lagturneringene). Feltene er delt + mellom lag- og individuelle turneringer (samme `tournament`-tabell, + ikke `format_type`-spesifikke), så ÉN komponent dekker begge. + "Presentasjon"-fanen lagt til i alle fire eksisterende nav-rader + (`tournament-detail.tsx`/`tournament-program.tsx`/`tournament- + leaderboard.tsx`/`individual-tournament-detail.tsx`) -- disse fire er + fortsatt hver sin duplikat (samme mønster/samme kjente ulempe som + 2026-08-02-bug-runden over), ikke en delt komponent denne runden. + La samtidig til `overflow-x-auto` på alle fire nav-rader (fjerde fane + presset bredden over det tidligere 3-faners layoutet tålte på smale + mobilskjermer). + **To små, bevisst minimale backend-tillegg** (ingen migrasjon -- + rene response-modell-/endepunkt-tillegg): + - `Tournament.hero_image_url`/`Sponsor.logo_url`: nye beregnede felt + (samme mønster som `auth.py` sin `avatar_url`) -- organisator- + frontend skal aldri selv måtte kjenne MinIO-bucket/base-URL. + `_tournament_from_row()`/`_sponsor_from_row()`-hjelpere lagt til, + alle 8 tidligere `Tournament(**dict(row))`/`Sponsor(**dict(row))`- + kallsteder erstattet. + - Ny `DELETE /orgs/{id}/tournaments/{id}/hero-image` (samme mønster + som eksisterende `DELETE /auth/profile/avatar`) -- fantes ikke fra + før, kun opplasting. + **Én reell bug funnet og rettet UNDER scratch-verifisering** (ikke + antatt riktig fra ren typesjekk): sponsor-radens layout (logo- + miniatyr + navn + "Last opp logo"-knapp med full tekst + slett-ikon, + alle på én rad) klemte sponsornavnet til nesten ingenting på en ekte + 390px mobil-viewport ("Tjø…" for "Tjøme Rørlegger AS"). Rettet ved å + la navn/lenke ligge på egen rad, handlingsknappene på en egen rad + under (`flex-col` på mobil, `sm:flex-row` fra small breakpoint) -- + samme klasse defekt (for lite bredde satt av til tekst i en trang + flex-rad) som `SideName`-trunkeringsbugen i leaderboard-omskrivingen + tidligere denne uken, men et annet konkret sted. + **Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle + + isolert scratch-MinIO + engangs API-/frontend-container, ekte + nettleser-innlogging inkl. reell 2FA-e-post-oppsett): full + PATCH-rundtur (beskrivelse/synlighet/godkjenning/venteliste- + policy lagret og lest tilbake korrekt fra ekte DB), hero-bilde + lastet opp og bekreftet i MinIO (`image/avif`, riktig nøkkel i DB), + hero-bilde fjernet (nøkkel nullstilt), sponsor lagt til, sponsor- + logo lastet opp og bekreftet i MinIO, sponsor slettet -- alt testet + på BÅDE en lagturnering og en individuell turnering, pluss bekreftet + at den offentlige siden (`/t/{id}`) viser organisatorens satte + beskrivelse. **Rullet ut live 2026-08-03**, bruker bekreftet + eksplisitt: `docker compose up -d --build teecup_api teecup_frontend`, + begge containere boot-et rent, `/health`/`/dashboard` → 200, + ny `/tournaments/[id]/presentation`-rute bekreftet i build-outputen. + **Ikke klikket gjennom i selve produksjonen** -- ingen ekte turnering + finnes ennå i `teecup_db` ("Ingen turneringer ennå" på dashbordet), + så dette er samme build som scratch-verifisert, ikke i tillegg + egenhendig bekreftet mot ekte produksjonsdata. Bekreft ved neste + faktiske turnering. diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index 40601ba..9ba19c6 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -83,12 +83,23 @@ class Tournament(BaseModel): registration_overflow_policy: str registration_requires_approval: bool hero_image_key: str | None + hero_image_url: str | None join_code: str format_type: str scoring_method: str | None bbb_sweep_bonus_enabled: bool +def _tournament_from_row(row) -> Tournament: + """Samme mønster som auth.py sin avatar_url: DB-kolonnen forblir + `hero_image_key` (den nøytrale, lagrings-uavhengige verdien), men + responsen får i tillegg en ferdig-konstruert URL -- organisator- + frontend skal aldri selv måtte kjenne til MinIO-bucket/base-URL.""" + data = dict(row) + key = data["hero_image_key"] + return Tournament(**data, hero_image_url=storage.public_url(key) if key else None) + + @router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament]) async def list_tournaments( organization_id: str = Depends(get_authorized_org), @@ -121,7 +132,7 @@ async def list_tournaments( ORDER BY t.created_at DESC """ ) - return [Tournament(**dict(r)) for r in rows] + return [_tournament_from_row(r) for r in rows] @router.post("/orgs/{organization_id}/tournaments", response_model=Tournament, status_code=201) @@ -159,7 +170,7 @@ async def create_tournament( continue if row is None: raise app_error(500, "VALIDATION_FAILED", "Klarte ikke å generere en unik invitasjonskode. Prøv igjen.") - return Tournament(**dict(row)) + return _tournament_from_row(row) class TournamentUpdate(BaseModel): @@ -220,7 +231,7 @@ async def update_tournament( ) if row is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") - return Tournament(**dict(row)) + return _tournament_from_row(row) @router.post( @@ -257,7 +268,28 @@ async def upload_hero_image( key, tournament_id, ) - return Tournament(**dict(row)) + return _tournament_from_row(row) + + +@router.delete( + "/orgs/{organization_id}/tournaments/{tournament_id}/hero-image", + response_model=Tournament, +) +async def delete_hero_image( + tournament_id: str, + organization_id: str = Depends(get_authorized_org), +) -> Tournament: + """Samme mønster som DELETE /auth/profile/avatar -- nullstiller kun + referansen, sletter ikke selve MinIO-objektet (samme aksepterte + forenkling som avatar-fjerning).""" + async with org_connection(organization_id) as conn: + row = await conn.fetchrow( + f"UPDATE tournament SET hero_image_key = NULL WHERE id = $1 RETURNING {_TOURNAMENT_COLUMNS}", + tournament_id, + ) + if row is None: + raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") + return _tournament_from_row(row) # --------------------------------------------------------------------------- @@ -1173,6 +1205,13 @@ class Sponsor(BaseModel): name: str url: str | None logo_key: str | None + logo_url: str | None + + +def _sponsor_from_row(row) -> Sponsor: + data = dict(row) + key = data["logo_key"] + return Sponsor(**data, logo_url=storage.public_url(key) if key else None) @router.get( @@ -1188,7 +1227,7 @@ async def list_sponsors( f"SELECT {_SPONSOR_COLUMNS} FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at", tournament_id, ) - return [Sponsor(**dict(r)) for r in rows] + return [_sponsor_from_row(r) for r in rows] @router.post( @@ -1216,7 +1255,7 @@ async def add_sponsor( body.name, body.url, ) - return Sponsor(**dict(row)) + return _sponsor_from_row(row) class SponsorUpdate(BaseModel): @@ -1256,7 +1295,7 @@ async def update_sponsor( ) if row is None: raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.") - return Sponsor(**dict(row)) + return _sponsor_from_row(row) @router.post( @@ -1295,7 +1334,7 @@ async def upload_sponsor_logo( key, sponsor_id, ) - return Sponsor(**dict(row)) + return _sponsor_from_row(row) @router.delete( diff --git a/frontend/app/tournaments/[id]/presentation/page.tsx b/frontend/app/tournaments/[id]/presentation/page.tsx new file mode 100644 index 0000000..b8265a0 --- /dev/null +++ b/frontend/app/tournaments/[id]/presentation/page.tsx @@ -0,0 +1,29 @@ +import { TournamentPresentation } from "@/components/tournament-presentation" + +export default async function TournamentPresentationPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }> + searchParams: Promise<{ org?: string; name?: string }> +}) { + const { id } = await params + const { org, name } = await searchParams + const tournamentName = name?.trim() || "Turnering" + + // Samme grunn som app/tournaments/[id]/page.tsx: API-et krever + // organization_id på alle turnering-endepunkter (RLS, se ADR-001/003). + if (!org) { + return ( +
+

+ Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen. +

+
+ ) + } + + return ( + + ) +} diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index 14fd8a6..0f25056 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -38,6 +38,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" +import { TournamentPresentationPanel } from "@/components/tournament-presentation" import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge" import { cn } from "@/lib/utils" @@ -181,7 +182,7 @@ export function IndividualTournamentDetail({ const [courses, setCourses] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [tab, setTab] = useState<"setup" | "score" | "leaderboard">("setup") + const [tab, setTab] = useState<"setup" | "score" | "leaderboard" | "presentation">("setup") const base = `/orgs/${organizationId}/tournaments/${tournamentId}` @@ -416,12 +417,16 @@ export function IndividualTournamentDetail({ -