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.
+