Legg til turnering-presentasjon: hero-bilde, sponsorer, synlighet, påmelding
Backend for dette (hero_image_key, sponsor-CRUD, visibility/description/ registrering) har vært klart og live siden ADR-018 (2026-07-18), men ingen organisator-skjerm satte noensinne disse feltene - alt var 100% API-only. Ny tournament-presentation.tsx dekker begge turneringstyper (delt tournament-tabell, ikke format_type-spesifikt): full side for lagturneringer (ny rute), embeddet som fjerde fane for individuelle turneringer. Lagt til i alle fire eksisterende nav-rader. To små backend-tillegg uten migrasjon: hero_image_url/logo_url som beregnede felt (samme mønster som avatar_url), og en DELETE-endepunkt for hero-bilde (fantes fra før kun opplasting). Fant og rettet en ekte mobil-layoutbug under scratch-verifisering: sponsor-raden klemte navnet til nesten ingenting på 390px viewport - samme klasse trunkeringsdefekt som leaderboard-omskrivingen tidligere denne uken, men på et annet sted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
d053a71428
commit
fa6a1fa78d
8 changed files with 873 additions and 25 deletions
69
CHANGELOG.md
69
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.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
29
frontend/app/tournaments/[id]/presentation/page.tsx
Normal file
29
frontend/app/tournaments/[id]/presentation/page.tsx
Normal file
|
|
@ -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 (
|
||||
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen.
|
||||
</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TournamentPresentation organizationId={org} tournamentId={id} tournamentName={tournamentName} />
|
||||
)
|
||||
}
|
||||
|
|
@ -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<ApiCourse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner">
|
||||
<nav
|
||||
className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto px-5 pb-3"
|
||||
aria-label="Turneringsseksjoner"
|
||||
>
|
||||
{(
|
||||
[
|
||||
["setup", "Oppsett"],
|
||||
["score", "Scorekort"],
|
||||
["leaderboard", "Leaderboard"],
|
||||
["presentation", "Presentasjon"],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
|
|
@ -430,7 +435,7 @@ export function IndividualTournamentDetail({
|
|||
onClick={() => setTab(key)}
|
||||
aria-current={tab === key ? "page" : undefined}
|
||||
className={cn(
|
||||
"min-h-11 rounded-full px-4 py-2 text-sm font-bold transition-colors",
|
||||
"min-h-11 shrink-0 rounded-full px-4 py-2 text-sm font-bold transition-colors",
|
||||
tab === key
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
|
|
@ -474,6 +479,9 @@ export function IndividualTournamentDetail({
|
|||
{tab === "leaderboard" && (
|
||||
<LeaderboardTab base={base} tournament={tournament} />
|
||||
)}
|
||||
{tab === "presentation" && (
|
||||
<TournamentPresentationPanel organizationId={organizationId} tournamentId={tournamentId} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -370,25 +370,34 @@ export function TournamentDetail({
|
|||
{joinCode && <JoinCodeChip code={joinCode} />}
|
||||
</div>
|
||||
|
||||
<nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner">
|
||||
<nav
|
||||
className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto px-5 pb-3"
|
||||
aria-label="Turneringsseksjoner"
|
||||
>
|
||||
<span
|
||||
aria-current="page"
|
||||
className="rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
||||
className="shrink-0 rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
||||
>
|
||||
Lag og spillere
|
||||
</span>
|
||||
<Link
|
||||
href={`/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
||||
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
Program
|
||||
</Link>
|
||||
<Link
|
||||
href={`/tournaments/${tournamentId}/leaderboard?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
||||
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
Leaderboard
|
||||
</Link>
|
||||
<Link
|
||||
href={`/tournaments/${tournamentId}/presentation?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
||||
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
Presentasjon
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -106,25 +106,34 @@ export function TournamentLeaderboard({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner">
|
||||
<nav
|
||||
className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto px-5 pb-3"
|
||||
aria-label="Turneringsseksjoner"
|
||||
>
|
||||
<Link
|
||||
href={detailHref}
|
||||
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
Lag og spillere
|
||||
</Link>
|
||||
<Link
|
||||
href={programHref}
|
||||
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
Program
|
||||
</Link>
|
||||
<span
|
||||
aria-current="page"
|
||||
className="rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
||||
className="shrink-0 rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
||||
>
|
||||
Leaderboard
|
||||
</span>
|
||||
<Link
|
||||
href={`/tournaments/${tournamentId}/presentation?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
||||
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
Presentasjon
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
680
frontend/components/tournament-presentation.tsx
Normal file
680
frontend/components/tournament-presentation.tsx
Normal file
|
|
@ -0,0 +1,680 @@
|
|||
"use client"
|
||||
|
||||
// Turnering-presentasjon (ADR-018): beskrivelse, synlighet, påmeldings-
|
||||
// innstillinger, hero-bilde og sponsorer. Backend for alt dette har vært
|
||||
// klart siden 2026-07-18 -- denne skjermen var det manglende siste steget
|
||||
// (se FEATURE_BACKLOG.md "Landingssider"-seksjonen, rettet 2026-08-03).
|
||||
// Delt mellom lag- og individuelle turneringer (samme `tournament`-tabell,
|
||||
// disse feltene er ikke format_type-spesifikke) -- se tournament-router.tsx
|
||||
// for hvordan format_type ellers velger skjerm.
|
||||
|
||||
import type React from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
ArrowLeft,
|
||||
Camera,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
Lock,
|
||||
Plus,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type Visibility = "public" | "org" | "participants"
|
||||
type OverflowPolicy = "waitlist" | "closed"
|
||||
|
||||
type ApiTournament = {
|
||||
id: string
|
||||
visibility: Visibility
|
||||
description: string | null
|
||||
registration_deadline: string | null
|
||||
registration_capacity: number | null
|
||||
registration_overflow_policy: OverflowPolicy
|
||||
registration_requires_approval: boolean
|
||||
hero_image_url: string | null
|
||||
}
|
||||
|
||||
type ApiSponsor = {
|
||||
id: string
|
||||
name: string
|
||||
url: string | null
|
||||
logo_url: string | null
|
||||
}
|
||||
|
||||
const VISIBILITY_OPTIONS: { value: Visibility; label: string; hint: string; icon: typeof Globe }[] = [
|
||||
{ value: "public", label: "Offentlig", hint: "Alle kan se siden, uten innlogging.", icon: Globe },
|
||||
{ value: "org", label: "Kun org", hint: "Kun medlemmer av klubben.", icon: Users },
|
||||
{ value: "participants", label: "Kun deltakere", hint: "Kun rostrede/registrerte spillere.", icon: Lock },
|
||||
]
|
||||
|
||||
function isoToLocalInputValue(iso: string | null): string {
|
||||
if (!iso) return ""
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ""
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function localInputValueToIso(value: string): string | null {
|
||||
if (!value.trim()) return null
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? null : d.toISOString()
|
||||
}
|
||||
|
||||
// Innholdet uten header/fanerad -- egen eksport slik at
|
||||
// individual-tournament-detail.tsx (som har sitt eget in-page fanesystem,
|
||||
// ikke egne ruter som lagturneringene) kan bygge den inn som en fjerde fane
|
||||
// uten å duplisere en header. TournamentPresentation under (full side, egen
|
||||
// rute) er kun en tynn header+nav-innpakning rundt denne.
|
||||
export function TournamentPresentationPanel({
|
||||
organizationId,
|
||||
tournamentId,
|
||||
}: {
|
||||
organizationId: string
|
||||
tournamentId: string
|
||||
}) {
|
||||
const [tournament, setTournament] = useState<ApiTournament | null>(null)
|
||||
const [sponsors, setSponsors] = useState<ApiSponsor[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
// Skjema-lokal tilstand, seedet fra `tournament` ved (ny) lasting.
|
||||
const [description, setDescription] = useState("")
|
||||
const [visibility, setVisibility] = useState<Visibility>("org")
|
||||
const [requiresApproval, setRequiresApproval] = useState(false)
|
||||
const [overflowPolicy, setOverflowPolicy] = useState<OverflowPolicy>("waitlist")
|
||||
const [capacity, setCapacity] = useState("")
|
||||
const [deadline, setDeadline] = useState("")
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
|
||||
const [uploadingHero, setUploadingHero] = useState(false)
|
||||
const heroInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [sponsorName, setSponsorName] = useState("")
|
||||
const [sponsorUrl, setSponsorUrl] = useState("")
|
||||
const [addingSponsor, setAddingSponsor] = useState(false)
|
||||
const [sponsorError, setSponsorError] = useState<string | null>(null)
|
||||
const [logoTargetId, setLogoTargetId] = useState<string | null>(null)
|
||||
const [uploadingLogoFor, setUploadingLogoFor] = useState<string | null>(null)
|
||||
const sponsorLogoInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
try {
|
||||
const [tournamentsRes, sponsorsRes] = await Promise.all([
|
||||
fetch(`/orgs/${organizationId}/tournaments`, { credentials: "include" }),
|
||||
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sponsors`, { credentials: "include" }),
|
||||
])
|
||||
if (!tournamentsRes.ok) throw new Error("load failed")
|
||||
const list: ApiTournament[] = await tournamentsRes.json()
|
||||
const mine = list.find((t) => t.id === tournamentId)
|
||||
if (!mine) throw new Error("not found")
|
||||
if (cancelled) return
|
||||
setTournament(mine)
|
||||
setDescription(mine.description ?? "")
|
||||
setVisibility(mine.visibility)
|
||||
setRequiresApproval(mine.registration_requires_approval)
|
||||
setOverflowPolicy(mine.registration_overflow_policy)
|
||||
setCapacity(mine.registration_capacity != null ? String(mine.registration_capacity) : "")
|
||||
setDeadline(isoToLocalInputValue(mine.registration_deadline))
|
||||
if (sponsorsRes.ok) setSponsors(await sponsorsRes.json())
|
||||
} catch {
|
||||
if (!cancelled) setLoadError("Klarte ikke å laste turnering-presentasjonen. Prøv å laste siden på nytt.")
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [organizationId, tournamentId])
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
setSaved(false)
|
||||
try {
|
||||
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
description: description.trim() === "" ? null : description.trim(),
|
||||
visibility,
|
||||
registration_requires_approval: requiresApproval,
|
||||
registration_overflow_policy: overflowPolicy,
|
||||
registration_capacity: capacity.trim() === "" ? null : Number.parseInt(capacity, 10),
|
||||
registration_deadline: localInputValueToIso(deadline),
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null)
|
||||
throw new Error(body?.detail?.message ?? "Klarte ikke å lagre.")
|
||||
}
|
||||
const updated: ApiTournament = await res.json()
|
||||
setTournament(updated)
|
||||
setSaved(true)
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : "Klarte ikke å lagre. Prøv igjen.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleHeroSelected(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploadingHero(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/hero-image`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
})
|
||||
if (!res.ok) throw new Error("Klarte ikke å laste opp bildet.")
|
||||
const updated: ApiTournament = await res.json()
|
||||
setTournament(updated)
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : "Klarte ikke å laste opp bildet.")
|
||||
} finally {
|
||||
setUploadingHero(false)
|
||||
if (heroInputRef.current) heroInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveHero() {
|
||||
setUploadingHero(true)
|
||||
try {
|
||||
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/hero-image`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
})
|
||||
if (res.ok) setTournament(await res.json())
|
||||
} finally {
|
||||
setUploadingHero(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddSponsor(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (sponsorName.trim() === "") return
|
||||
setAddingSponsor(true)
|
||||
setSponsorError(null)
|
||||
try {
|
||||
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sponsors`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ name: sponsorName.trim(), url: sponsorUrl.trim() === "" ? null : sponsorUrl.trim() }),
|
||||
})
|
||||
if (!res.ok) throw new Error("Klarte ikke å legge til sponsoren.")
|
||||
const created: ApiSponsor = await res.json()
|
||||
setSponsors((prev) => [...prev, created])
|
||||
setSponsorName("")
|
||||
setSponsorUrl("")
|
||||
} catch (err) {
|
||||
setSponsorError(err instanceof Error ? err.message : "Klarte ikke å legge til sponsoren.")
|
||||
} finally {
|
||||
setAddingSponsor(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSponsor(sponsorId: string) {
|
||||
setSponsorError(null)
|
||||
try {
|
||||
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sponsors/${sponsorId}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
})
|
||||
if (res.status !== 204) throw new Error("Klarte ikke å fjerne sponsoren.")
|
||||
setSponsors((prev) => prev.filter((s) => s.id !== sponsorId))
|
||||
} catch (err) {
|
||||
setSponsorError(err instanceof Error ? err.message : "Klarte ikke å fjerne sponsoren.")
|
||||
}
|
||||
}
|
||||
|
||||
function triggerLogoUpload(sponsorId: string) {
|
||||
setLogoTargetId(sponsorId)
|
||||
sponsorLogoInputRef.current?.click()
|
||||
}
|
||||
|
||||
async function handleLogoSelected(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
const sponsorId = logoTargetId
|
||||
if (!file || !sponsorId) return
|
||||
setUploadingLogoFor(sponsorId)
|
||||
setSponsorError(null)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
const res = await fetch(
|
||||
`/orgs/${organizationId}/tournaments/${tournamentId}/sponsors/${sponsorId}/logo`,
|
||||
{ method: "POST", credentials: "include", body: formData },
|
||||
)
|
||||
if (!res.ok) throw new Error("Klarte ikke å laste opp logoen.")
|
||||
const updated: ApiSponsor = await res.json()
|
||||
setSponsors((prev) => prev.map((s) => (s.id === updated.id ? updated : s)))
|
||||
} catch (err) {
|
||||
setSponsorError(err instanceof Error ? err.message : "Klarte ikke å laste opp logoen.")
|
||||
} finally {
|
||||
setUploadingLogoFor(null)
|
||||
setLogoTargetId(null)
|
||||
if (sponsorLogoInputRef.current) sponsorLogoInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="mb-4 flex flex-col gap-1">
|
||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
Hvordan turneringen vises på sin offentlige side (
|
||||
<Link
|
||||
href={`/t/${tournamentId}`}
|
||||
target="_blank"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
/t/{tournamentId}
|
||||
<ExternalLink aria-hidden="true" className="ml-1 inline size-3" />
|
||||
</Link>
|
||||
) og klubbsiden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{loadError && (
|
||||
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
||||
{loadError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||
/>
|
||||
</div>
|
||||
) : tournament ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* --- Hero-bilde ------------------------------------------------ */}
|
||||
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||
<h3 className="text-sm font-bold text-foreground">Hero-bilde</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex h-20 w-32 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-secondary">
|
||||
{tournament.hero_image_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={tournament.hero_image_url} alt="" className="size-full object-cover" />
|
||||
) : (
|
||||
<Camera aria-hidden="true" className="size-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<input
|
||||
ref={heroInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={handleHeroSelected}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={uploadingHero}
|
||||
onClick={() => heroInputRef.current?.click()}
|
||||
className="h-11 rounded-xl font-semibold"
|
||||
>
|
||||
<Camera aria-hidden="true" className="size-4" />
|
||||
{tournament.hero_image_url ? "Bytt bilde" : "Last opp bilde"}
|
||||
</Button>
|
||||
{tournament.hero_image_url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveHero}
|
||||
disabled={uploadingHero}
|
||||
className="inline-flex w-fit items-center gap-1 text-xs font-semibold text-muted-foreground transition-colors hover:text-destructive"
|
||||
>
|
||||
<X aria-hidden="true" className="size-3" />
|
||||
Fjern bilde
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- Beskrivelse & synlighet ------------------------------------ */}
|
||||
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||
<h3 className="text-sm font-bold text-foreground">Beskrivelse og synlighet</h3>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="tournament-description">Beskrivelse</Label>
|
||||
<textarea
|
||||
id="tournament-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Fortell spillere og besøkende hva turneringen handler om…"
|
||||
className="w-full rounded-xl border border-input bg-transparent px-3 py-2.5 text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Hvem kan se siden</Label>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{VISIBILITY_OPTIONS.map((opt) => {
|
||||
const Icon = opt.icon
|
||||
const selected = visibility === opt.value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setVisibility(opt.value)}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"flex min-h-11 flex-col items-start gap-1 rounded-2xl border px-3 py-3 text-left transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-sm font-semibold">
|
||||
<Icon aria-hidden="true" className="size-4" />
|
||||
{opt.label}
|
||||
</span>
|
||||
<span className="text-xs leading-snug text-muted-foreground text-pretty">{opt.hint}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- Påmelding --------------------------------------------------- */}
|
||||
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||
<h3 className="text-sm font-bold text-foreground">Påmelding</h3>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 border-b border-border py-1 last:border-b-0">
|
||||
<span className="text-sm font-medium text-foreground text-pretty">
|
||||
Krev godkjenning før bekreftet plass
|
||||
</span>
|
||||
<Switch checked={requiresApproval} onCheckedChange={setRequiresApproval} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Når plassene tar slutt</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOverflowPolicy("waitlist")}
|
||||
aria-pressed={overflowPolicy === "waitlist"}
|
||||
className={cn(
|
||||
"flex min-h-11 items-center justify-center rounded-2xl border px-3 py-3 text-sm font-semibold transition-colors",
|
||||
overflowPolicy === "waitlist"
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
Sett på venteliste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOverflowPolicy("closed")}
|
||||
aria-pressed={overflowPolicy === "closed"}
|
||||
className={cn(
|
||||
"flex min-h-11 items-center justify-center rounded-2xl border px-3 py-3 text-sm font-semibold transition-colors",
|
||||
overflowPolicy === "closed"
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
Steng påmelding
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="tournament-capacity">Maks antall påmeldte (valgfritt)</Label>
|
||||
<Input
|
||||
id="tournament-capacity"
|
||||
type="number"
|
||||
min={1}
|
||||
inputMode="numeric"
|
||||
value={capacity}
|
||||
onChange={(e) => setCapacity(e.target.value)}
|
||||
placeholder="Ingen grense"
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="tournament-deadline">Påmeldingsfrist (valgfritt)</Label>
|
||||
<Input
|
||||
id="tournament-deadline"
|
||||
type="datetime-local"
|
||||
value={deadline}
|
||||
onChange={(e) => setDeadline(e.target.value)}
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{saveError && (
|
||||
<p role="alert" className="text-sm font-medium text-destructive">
|
||||
{saveError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="h-11 rounded-xl px-6 font-semibold"
|
||||
>
|
||||
{saving ? "Lagrer…" : "Lagre endringer"}
|
||||
</Button>
|
||||
{saved && !saving && (
|
||||
<span className="flex items-center gap-1 text-sm font-semibold text-primary">
|
||||
<Check aria-hidden="true" className="size-4" />
|
||||
Lagret
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* --- Sponsorer ----------------------------------------------------- */}
|
||||
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||
<h3 className="text-sm font-bold text-foreground">Sponsorer</h3>
|
||||
|
||||
<input
|
||||
ref={sponsorLogoInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={handleLogoSelected}
|
||||
/>
|
||||
|
||||
{sponsors.length > 0 && (
|
||||
<ul className="flex flex-col divide-y divide-border rounded-2xl border border-border">
|
||||
{sponsors.map((sp) => (
|
||||
<li key={sp.id} className="flex flex-col gap-2 px-3 py-3 sm:flex-row sm:items-center">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-secondary">
|
||||
{sp.logo_url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={sp.logo_url} alt="" className="size-full object-contain" />
|
||||
) : (
|
||||
<Camera aria-hidden="true" className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-semibold text-foreground">{sp.name}</span>
|
||||
{sp.url && (
|
||||
<span className="truncate text-xs text-muted-foreground">{sp.url}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => triggerLogoUpload(sp.id)}
|
||||
disabled={uploadingLogoFor === sp.id}
|
||||
className="flex h-11 shrink-0 items-center gap-1 rounded-xl px-3 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
<Camera aria-hidden="true" className="size-3.5" />
|
||||
{sp.logo_url ? "Bytt logo" : "Last opp logo"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteSponsor(sp.id)}
|
||||
aria-label={`Fjern ${sp.name}`}
|
||||
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Trash2 aria-hidden="true" className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{sponsorError && (
|
||||
<p role="alert" className="text-sm font-medium text-destructive">
|
||||
{sponsorError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAddSponsor} className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<Label htmlFor="sponsor-name">Sponsornavn</Label>
|
||||
<Input
|
||||
id="sponsor-name"
|
||||
value={sponsorName}
|
||||
onChange={(e) => setSponsorName(e.target.value)}
|
||||
placeholder="F.eks. Tjøme Rørlegger AS"
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<Label htmlFor="sponsor-url">Lenke (valgfritt)</Label>
|
||||
<Input
|
||||
id="sponsor-url"
|
||||
value={sponsorUrl}
|
||||
onChange={(e) => setSponsorUrl(e.target.value)}
|
||||
placeholder="https://…"
|
||||
className="h-11"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={addingSponsor || sponsorName.trim() === ""}
|
||||
className="h-11 shrink-0 rounded-xl font-semibold"
|
||||
>
|
||||
<Plus aria-hidden="true" className="size-4" />
|
||||
Legg til
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Full side (egen rute, `/tournaments/[id]/presentation`) -- header+fanerad
|
||||
// rundt panelet over, samme mønster som tournament-detail.tsx/
|
||||
// tournament-program.tsx/tournament-leaderboard.tsx sine respektive header-
|
||||
// blokker (duplisert der også, ikke en delt komponent -- se CHANGELOG.md
|
||||
// 2026-08-02 for et tidligere funnet eksempel på nettopp denne dupliseringen
|
||||
// som årsak til en bug reparert flere steder).
|
||||
export function TournamentPresentation({
|
||||
organizationId,
|
||||
tournamentId,
|
||||
tournamentName,
|
||||
}: {
|
||||
organizationId: string
|
||||
tournamentId: string
|
||||
tournamentName: string
|
||||
}) {
|
||||
const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||
const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||
const leaderboardHref = `/tournaments/${tournamentId}/leaderboard?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-4xl items-center gap-3 px-5 py-4">
|
||||
<Link
|
||||
href={detailHref}
|
||||
aria-label="Tilbake til turnering"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||
</Link>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Turnering
|
||||
</span>
|
||||
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
|
||||
{tournamentName}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto px-5 pb-3"
|
||||
aria-label="Turneringsseksjoner"
|
||||
>
|
||||
<Link
|
||||
href={detailHref}
|
||||
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
Lag og spillere
|
||||
</Link>
|
||||
<Link
|
||||
href={programHref}
|
||||
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
Program
|
||||
</Link>
|
||||
<Link
|
||||
href={leaderboardHref}
|
||||
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
Leaderboard
|
||||
</Link>
|
||||
<span
|
||||
aria-current="page"
|
||||
className="shrink-0 rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
||||
>
|
||||
Presentasjon
|
||||
</span>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-4xl flex-1 px-5 py-6 sm:py-8">
|
||||
<h2 className="mb-1 text-base font-bold text-foreground">Presentasjon</h2>
|
||||
<TournamentPresentationPanel organizationId={organizationId} tournamentId={tournamentId} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -292,26 +292,35 @@ export function TournamentProgram({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner">
|
||||
<nav
|
||||
className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto px-5 pb-3"
|
||||
aria-label="Turneringsseksjoner"
|
||||
>
|
||||
<Link
|
||||
href={detailHref}
|
||||
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
Lag og spillere
|
||||
</Link>
|
||||
<Link
|
||||
href={programHref}
|
||||
aria-current="page"
|
||||
className="rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
||||
className="shrink-0 rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
||||
>
|
||||
Program
|
||||
</Link>
|
||||
<Link
|
||||
href={leaderboardHref}
|
||||
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
Leaderboard
|
||||
</Link>
|
||||
<Link
|
||||
href={`/tournaments/${tournamentId}/presentation?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
||||
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
Presentasjon
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue