MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend.
Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd. To reelle feil funnet under scratch-verifisering, aldri i produksjon: pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker. MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur. Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet. Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden. Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført. Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
This commit is contained in:
parent
6d84aafef8
commit
1e491d73b3
12 changed files with 423 additions and 41 deletions
|
|
@ -205,7 +205,22 @@
|
||||||
"Bash(rm -f /opt/teecup/.env.scratch /tmp/cowner.txt)",
|
"Bash(rm -f /opt/teecup/.env.scratch /tmp/cowner.txt)",
|
||||||
"Bash(curl -s -o /dev/null -w '/clubs/ukjent: %{http_code}\\\\n' https://teecup.teeoff.no/clubs/ukjent-slug-garantert)",
|
"Bash(curl -s -o /dev/null -w '/clubs/ukjent: %{http_code}\\\\n' https://teecup.teeoff.no/clubs/ukjent-slug-garantert)",
|
||||||
"Bash(rm -f /opt/teecup/.env.scratch /tmp/ogowner.txt)",
|
"Bash(rm -f /opt/teecup/.env.scratch /tmp/ogowner.txt)",
|
||||||
"Bash(curl -s https://teecup.teeoff.no/t/00000000-0000-0000-0000-000000000000)"
|
"Bash(curl -s https://teecup.teeoff.no/t/00000000-0000-0000-0000-000000000000)",
|
||||||
|
"Bash(docker volume *)",
|
||||||
|
"Bash(getent hosts *)",
|
||||||
|
"Bash(ip addr *)",
|
||||||
|
"Bash(curl -s -4 https://api.ipify.org)",
|
||||||
|
"Bash(curl -s -6 https://api64.ipify.org)",
|
||||||
|
"Bash(python3 -m py_compile /opt/teecup/app/config.py /opt/teecup/app/storage.py /opt/teecup/app/main.py /opt/teecup/app/routers/tournaments.py /opt/teecup/app/routers/registration.py)",
|
||||||
|
"Bash(docker run --rm python:3.12-slim bash -c ' *)",
|
||||||
|
"Bash(sed -i -e 's#^TEECUP_DB_NAME=.*#TEECUP_DB_NAME=teecup_scratch#' -e 's#^TEECUP_MINIO_ROOT_USER=.*#TEECUP_MINIO_ROOT_USER=scratchadmin#' -e 's#^TEECUP_MINIO_ROOT_PASSWORD=.*#TEECUP_MINIO_ROOT_PASSWORD=scratchpassword123#' .env.scratch)",
|
||||||
|
"Bash(sed -i \"s#^TEECUP_MINIO_INTERNAL_ENDPOINT=.*##\" .env.scratch)",
|
||||||
|
"Bash(docker exec teecup_scratch_api python3 -c ' *)",
|
||||||
|
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('hero_image_url:', d['hero_image_url']\\)\")",
|
||||||
|
"Bash(echo \"dette er ikke et bilde\")",
|
||||||
|
"Bash(rm -f /opt/teecup/.env.scratch /tmp/minio_owner.txt)",
|
||||||
|
"Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' https://teecup.teeoff.no/teecup-media/tournaments/4a1e6338-c1c0-4a05-be53-278a0cdff859/1784366368.avif)",
|
||||||
|
"Bash(curl -s -w '\\\\nHTTP %{http_code}\\\\n' https://teecup.teeoff.no/teecup-media/tournaments/__TRACKED_VAR__/1784366368.avif)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
59
CLAUDE.md
59
CLAUDE.md
|
|
@ -604,11 +604,64 @@ Ferdig og verifisert:
|
||||||
forarbeid slik at et fremtidig relativt bilde-URL løses riktig uten en
|
forarbeid slik at et fremtidig relativt bilde-URL løses riktig uten en
|
||||||
egen fiks da.
|
egen fiks da.
|
||||||
**Rullet ut live**, kun `teecup_frontend`, `teeoff.no` upåvirket.
|
**Rullet ut live**, kun `teecup_frontend`, `teeoff.no` upåvirket.
|
||||||
|
- **MinIO-runden LIVE (2026-07-18), samme dag — ADR-018 dermed HELT
|
||||||
|
ferdig, ingenting utsatt igjen:** ny `teecup-minio`-tjeneste (persistent
|
||||||
|
volum, genererte credentials i `.env`), backend-endepunkter for ekte
|
||||||
|
bildeopplasting til `tournament.hero_image_key`/`tournament_sponsor.
|
||||||
|
logo_key` (som lå inerte siden migrasjon 009), offentlige API-svar bygger
|
||||||
|
nå fulle URL-er, `og:image` koblet på i `/t/[id]` sin `generateMetadata`.
|
||||||
|
**Sent, men viktig presisert krav underveis:** brukeren avbrøt en
|
||||||
|
verktøyskall midtveis for å presisere at ALLE bilder skal konverteres
|
||||||
|
til AVIF for plassbesparelse. Snudde HELE opplastingsarkitekturen på
|
||||||
|
dette: droppet den opprinnelige planen om presignerte URL-er (nettleser
|
||||||
|
laster opp DIREKTE til MinIO) til fordel for ekte multipart-opplasting
|
||||||
|
GJENNOM API-et, som konverterer til AVIF (Pillow + pillow-avif-plugin)
|
||||||
|
FØR lagring. Dette forenklet arkitekturen betydelig: kun ÉN MinIO-klient
|
||||||
|
trengs nå (før: to separate klient-oppsett for henholdsvis internt
|
||||||
|
admin-arbeid og offentlig presignering), og Caddy sin nye rute trenger
|
||||||
|
ikke lenger bevare Host-headeren presist (relevant kun for SigV4-
|
||||||
|
signaturverifisering av presignerte URL-er, ikke for anonym public-read).
|
||||||
|
**To reelle feil funnet UNDER scratch-verifisering, aldri i produksjon:**
|
||||||
|
(1) `pillow-avif-plugin` krever ingen ekstra systempakker i
|
||||||
|
`python:3.12-slim` -- verifisert med et frittstående encode/decode-
|
||||||
|
rundtrip FØR det ble tatt i bruk i selve API-et. (2) MinIO validerer
|
||||||
|
`Host`-headeren STRENGT og avviser understrek som ugyldig vertsnavn --
|
||||||
|
et tjenestenavn med understrek (`teecup_minio`, konsistent med
|
||||||
|
`teecup_api`/`teecup_frontend`) feilet umiddelbart ved oppstart
|
||||||
|
("Invalid Request (invalid hostname)"). Bekreftet presist ved å teste
|
||||||
|
samme oppsett med bindestrek i stedet (`teecup-minio`) -- fungerte
|
||||||
|
umiddelbart. Alle referanser rettet til bindestrek FØR noe ble forsøkt
|
||||||
|
mot ekte infrastruktur.
|
||||||
|
**Caddy:** ny `/teecup-media/*`-rute på det EKSISTERENDE
|
||||||
|
`teecup.teeoff.no`-blokket (IKKE et nytt subdomene -- en tidlig sjekk
|
||||||
|
avdekket at `media.teecup.teeoff.no` fantes som en wildcard DNS-post,
|
||||||
|
men pekte til en IPv6-adresse denne serveren ikke har i det hele tatt;
|
||||||
|
path-prefiks på et allerede fungerende domene unngikk hele den DNS-
|
||||||
|
avhengigheten). Samme stale-inode-oppførsel som alle tidligere Caddy-
|
||||||
|
runder -- full `docker restart teeoff_caddy`, brukeren bekreftet
|
||||||
|
eksplisitt. `teeoff.no` upåvirket.
|
||||||
|
**Verifisert grundig, i flere lag:** frittstående AVIF-encode/decode-
|
||||||
|
test, full scratch-kjede (fersk Postgres + en ISOLERT scratch-MinIO-
|
||||||
|
container) med et ekte opplastet bilde -- bekreftet konvertert til
|
||||||
|
gyldig AVIF (800×400, 406 bytes for et helfarget testbilde), bekreftet
|
||||||
|
lagret med riktig nøkkel, bekreftet lesbart ANONYMT direkte mot MinIO
|
||||||
|
(uten Caddy, isolerer bucket-policyen), bekreftet `hero_image_url`/
|
||||||
|
`logo_url` bygget riktig i det offentlige API-svaret. Alle tre
|
||||||
|
valideringsveier testet (ugyldig content-type, korrupt bildeinnhold,
|
||||||
|
for stor fil >8MB) -- alle ga korrekt `VALIDATION_FAILED`. Etter
|
||||||
|
Caddy-omstart: et ekte anonymt kall mot `/teecup-media/...` på
|
||||||
|
produksjonsdomenet ga en ekte MinIO S3-XML-feilrespons (`NoSuchKey` for
|
||||||
|
en scratch-nøkkel som naturligvis ikke finnes i prod) -- beviser ruten
|
||||||
|
treffer MinIO selv, ikke frontend sin egen 404-side.
|
||||||
|
`test_isolation.sql` fortsatt 12/12 gjennom hele runden.
|
||||||
|
**Bevisst utenfor omfang:** ingen faktisk opplasting av et EKTE bilde
|
||||||
|
til en EKTE, live turnering i denne runden (ville krevd å skrive
|
||||||
|
test-data i brukerens ekte konto uten å bli spurt) -- tilbudt, ikke
|
||||||
|
utført. Selve dra-og-slipp-opplastingsskjermen i frontend (V0) er
|
||||||
|
fortsatt ikke bygget, som avtalt fra starten av runden.
|
||||||
|
|
||||||
Neste steg:
|
Neste steg:
|
||||||
1. MinIO/bildeopplasting (hero-bilder, sponsorlogoer, og:image) — siste
|
1. Flere V0-skjermer (økt/program, blind draw, scorekort, leaderboard) —
|
||||||
gjenstående punkt fra ADR-018, egen runde.
|
|
||||||
2. Flere V0-skjermer (økt/program, blind draw, scorekort, leaderboard) —
|
|
||||||
samme mønster: design i V0 (fortsett i samme prosjekt), FORVENT en full
|
samme mønster: design i V0 (fortsett i samme prosjekt), FORVENT en full
|
||||||
re-eksport hver gang — diff mot live-treet i et scratch-område før noe
|
re-eksport hver gang — diff mot live-treet i et scratch-område før noe
|
||||||
pakkes ut over eksisterende filer, og sjekk om V0-skjermen bygger inn
|
pakkes ut over eksisterende filer, og sjekk om V0-skjermen bygger inn
|
||||||
|
|
|
||||||
|
|
@ -408,8 +408,9 @@
|
||||||
## Landingssider (turnering + organisasjon) — ADR-018 ✅ HELT FERDIG 2026-07-18
|
## Landingssider (turnering + organisasjon) — ADR-018 ✅ HELT FERDIG 2026-07-18
|
||||||
|
|
||||||
Reist av brukeren 2026-07-18, rett etter registrerings-ADR-en (ADR-017).
|
Reist av brukeren 2026-07-18, rett etter registrerings-ADR-en (ADR-017).
|
||||||
Backend, begge frontend-skjermer OG Open Graph-metadata bygget og live
|
Backend, begge frontend-skjermer, Open Graph-metadata OG MinIO-bilde-
|
||||||
samme dag. Kun MinIO/bilder (bevisst egen runde) gjenstår.
|
opplasting (med AVIF-konvertering) bygget og live samme dag. Kun selve
|
||||||
|
dra-og-slipp-opplastingsskjermen i frontend (V0) gjenstår.
|
||||||
|
|
||||||
| Del | Status | Notat |
|
| Del | Status | Notat |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -420,7 +421,7 @@ samme dag. Kun MinIO/bilder (bevisst egen runde) gjenstår.
|
||||||
| Org-landingsside: klubbprofil, liste over turneringer (API) | ✅ | `GET /public/orgs/{slug}` — kun `public`-synlige turneringer. Bekreftet: klubb-profil KAN være offentlig (brukerens valg). |
|
| Org-landingsside: klubbprofil, liste over turneringer (API) | ✅ | `GET /public/orgs/{slug}` — kun `public`-synlige turneringer. Bekreftet: klubb-profil KAN være offentlig (brukerens valg). |
|
||||||
| Lesbar URL (slug) for organisasjon | ✅ | Fantes faktisk allerede i skjemaet siden migrasjon 001 (oversett, funnet da migrasjon 009 feilet mot scratch — se CLAUDE.md-status). Kun `CHECK`-constraints lagt til i 009. |
|
| Lesbar URL (slug) for organisasjon | ✅ | Fantes faktisk allerede i skjemaet siden migrasjon 001 (oversett, funnet da migrasjon 009 feilet mot scratch — se CLAUDE.md-status). Kun `CHECK`-constraints lagt til i 009. |
|
||||||
| Organisator kan faktisk SETTE disse feltene | ✅ | Implisitt hull fylt under bygging: `PATCH /orgs/{id}/tournaments/{id}` (visibility/description/registrering), `PATCH /orgs/{id}` (slug/public_profile), full sponsor-CRUD. |
|
| Organisator kan faktisk SETTE disse feltene | ✅ | Implisitt hull fylt under bygging: `PATCH /orgs/{id}/tournaments/{id}` (visibility/description/registrering), `PATCH /orgs/{id}` (slug/public_profile), full sponsor-CRUD. |
|
||||||
| Bilder (hero, sponsorlogoer) | 📋 | Bevisst utsatt (brukerens valg). `hero_image_key`/`logo_key`-kolonner finnes allerede (inerte), men krever MinIO — ikke satt opp. Egen, senere runde. |
|
| Bilder (hero, sponsorlogoer), backend | ✅ | Ny `teecup-minio`-tjeneste, ekte multipart-opplasting → AVIF-konvertering (Pillow) → lagring, live på `teecup.teeoff.no/teecup-media/*`. Selve opplastingsskjermen i frontend (V0) gjenstår. |
|
||||||
| Del-metadata (Open Graph: og:title/og:description) | ✅ | `generateMetadata()` på `/t/[id]`+`/clubs/[slug]`, ekte data fra API-et, verifisert mot produksjonsimaget. `og:image` gjenstår (MinIO). |
|
| Del-metadata (Open Graph: og:title/og:description) | ✅ | `generateMetadata()` på `/t/[id]`+`/clubs/[slug]`, ekte data fra API-et, verifisert mot produksjonsimaget. `og:image` gjenstår (MinIO). |
|
||||||
| Blind draw-skjuling på offentlig side | ✅ | Arves automatisk via delt `_fetch_sessions()`-hjelpefunksjon (ADR-018 Beslutning E) — ikke reimplementert. |
|
| Blind draw-skjuling på offentlig side | ✅ | Arves automatisk via delt `_fetch_sessions()`-hjelpefunksjon (ADR-018 Beslutning E) — ikke reimplementert. |
|
||||||
| Antall påmeldte / ledige plasser vist åpent | ✅ | `confirmed_count` i `GET /public/tournaments/{id}`. |
|
| Antall påmeldte / ledige plasser vist åpent | ✅ | `confirmed_count` i `GET /public/tournaments/{id}`. |
|
||||||
|
|
|
||||||
|
|
@ -72,5 +72,16 @@ class Settings:
|
||||||
# scratch-/dev-testing kan overstyre uten kodeendring.
|
# scratch-/dev-testing kan overstyre uten kodeendring.
|
||||||
PUBLIC_BASE_URL: str = os.getenv("TEECUP_PUBLIC_BASE_URL", "https://teecup.teeoff.no").rstrip("/")
|
PUBLIC_BASE_URL: str = os.getenv("TEECUP_PUBLIC_BASE_URL", "https://teecup.teeoff.no").rstrip("/")
|
||||||
|
|
||||||
|
# MinIO (ADR-018 MinIO-runde). Root-credentials -- ekte hemmeligheter,
|
||||||
|
# _required. INTERNAL_ENDPOINT (container-til-container, ingen Caddy)
|
||||||
|
# brukes KUN til engangs bucket-/policy-oppsett ved oppstart.
|
||||||
|
# PUBLIC_BASE_URL gjenbrukes til å bygge presignerte URL-er nettleseren
|
||||||
|
# faktisk skal treffe -- se app/storage.py for hvorfor de to endepunktene
|
||||||
|
# bevisst er atskilt.
|
||||||
|
MINIO_ROOT_USER: str = _required("TEECUP_MINIO_ROOT_USER")
|
||||||
|
MINIO_ROOT_PASSWORD: str = _required("TEECUP_MINIO_ROOT_PASSWORD")
|
||||||
|
MINIO_BUCKET: str = os.getenv("TEECUP_MINIO_BUCKET", "teecup-media")
|
||||||
|
MINIO_INTERNAL_ENDPOINT: str = os.getenv("TEECUP_MINIO_INTERNAL_ENDPOINT", "teecup-minio:9000")
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,13 @@ from fastapi import FastAPI
|
||||||
|
|
||||||
from .db import init_pool, close_pool
|
from .db import init_pool, close_pool
|
||||||
from .routers import auth, matches, organizations, players, registration, scoring, tournaments
|
from .routers import auth, matches, organizations, players, registration, scoring, tournaments
|
||||||
|
from . import storage
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
await init_pool()
|
await init_pool()
|
||||||
|
await storage.ensure_bucket()
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,7 @@ uvicorn[standard]>=0.30
|
||||||
asyncpg>=0.29
|
asyncpg>=0.29
|
||||||
pydantic[email]>=2.7
|
pydantic[email]>=2.7
|
||||||
pyjwt>=2.8
|
pyjwt>=2.8
|
||||||
|
minio>=7.2
|
||||||
|
pillow>=10.0
|
||||||
|
pillow-avif-plugin>=1.4
|
||||||
|
python-multipart>=0.0.9
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ from pydantic import BaseModel, EmailStr, Field
|
||||||
from ..auth import CurrentUser, get_current_user_optional
|
from ..auth import CurrentUser, get_current_user_optional
|
||||||
from ..db import org_connection, plain_connection
|
from ..db import org_connection, plain_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
|
from .. import storage
|
||||||
from .tournaments import SessionOut, _fetch_sessions
|
from .tournaments import SessionOut, _fetch_sessions
|
||||||
|
|
||||||
router = APIRouter(prefix="/public/tournaments", tags=["public-registration"])
|
router = APIRouter(prefix="/public/tournaments", tags=["public-registration"])
|
||||||
|
|
@ -94,6 +95,7 @@ class SponsorOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
url: str | None
|
url: str | None
|
||||||
|
logo_url: str | None
|
||||||
|
|
||||||
|
|
||||||
class PublicTournamentInfo(BaseModel):
|
class PublicTournamentInfo(BaseModel):
|
||||||
|
|
@ -109,6 +111,7 @@ class PublicTournamentInfo(BaseModel):
|
||||||
registration_deadline: datetime | None
|
registration_deadline: datetime | None
|
||||||
registration_capacity: int | None
|
registration_capacity: int | None
|
||||||
confirmed_count: int
|
confirmed_count: int
|
||||||
|
hero_image_url: str | None
|
||||||
sponsors: list[SponsorOut]
|
sponsors: list[SponsorOut]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -122,7 +125,7 @@ async def get_public_tournament(
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT t.id::text, t.name, o.name AS organization_name, t.status::text,
|
SELECT t.id::text, t.name, o.name AS organization_name, t.status::text,
|
||||||
t.visibility, t.description,
|
t.visibility, t.description, t.hero_image_key,
|
||||||
t.start_date, t.end_date, t.registration_deadline,
|
t.start_date, t.end_date, t.registration_deadline,
|
||||||
t.registration_capacity,
|
t.registration_capacity,
|
||||||
(SELECT count(*)::int FROM tournament_registration tr
|
(SELECT count(*)::int FROM tournament_registration tr
|
||||||
|
|
@ -140,7 +143,7 @@ async def get_public_tournament(
|
||||||
await _check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
await _check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
||||||
|
|
||||||
sponsor_rows = await conn.fetch(
|
sponsor_rows = await conn.fetch(
|
||||||
"SELECT id::text, name, url FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at",
|
"SELECT id::text, name, url, logo_key FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at",
|
||||||
tournament_id,
|
tournament_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -159,7 +162,16 @@ async def get_public_tournament(
|
||||||
registration_deadline=deadline,
|
registration_deadline=deadline,
|
||||||
registration_capacity=row["registration_capacity"],
|
registration_capacity=row["registration_capacity"],
|
||||||
confirmed_count=row["confirmed_count"],
|
confirmed_count=row["confirmed_count"],
|
||||||
sponsors=[SponsorOut(**dict(r)) for r in sponsor_rows],
|
hero_image_url=storage.public_url(row["hero_image_key"]) if row["hero_image_key"] else None,
|
||||||
|
sponsors=[
|
||||||
|
SponsorOut(
|
||||||
|
id=r["id"],
|
||||||
|
name=r["name"],
|
||||||
|
url=r["url"],
|
||||||
|
logo_url=storage.public_url(r["logo_key"]) if r["logo_key"] else None,
|
||||||
|
)
|
||||||
|
for r in sponsor_rows
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,14 @@ rostret på et lag som ikke finnes ennå).
|
||||||
import json
|
import json
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, UploadFile
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..auth import get_authorized_org
|
from ..auth import get_authorized_org
|
||||||
from ..blind_draw import locked_team_ids
|
from ..blind_draw import locked_team_ids
|
||||||
from ..db import org_connection
|
from ..db import org_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
|
from .. import storage
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -34,7 +35,7 @@ class TournamentCreate(BaseModel):
|
||||||
_TOURNAMENT_COLUMNS = """
|
_TOURNAMENT_COLUMNS = """
|
||||||
id::text, name, status::text, start_date, end_date,
|
id::text, name, status::text, start_date, end_date,
|
||||||
visibility, description, registration_deadline, registration_capacity,
|
visibility, description, registration_deadline, registration_capacity,
|
||||||
registration_overflow_policy, registration_requires_approval
|
registration_overflow_policy, registration_requires_approval, hero_image_key
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -50,6 +51,7 @@ class Tournament(BaseModel):
|
||||||
registration_capacity: int | None
|
registration_capacity: int | None
|
||||||
registration_overflow_policy: str
|
registration_overflow_policy: str
|
||||||
registration_requires_approval: bool
|
registration_requires_approval: bool
|
||||||
|
hero_image_key: str | None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
|
@router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
|
||||||
|
|
@ -93,6 +95,9 @@ class TournamentUpdate(BaseModel):
|
||||||
registration_capacity: int | None = Field(default=None, gt=0)
|
registration_capacity: int | None = Field(default=None, gt=0)
|
||||||
registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$")
|
registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$")
|
||||||
registration_requires_approval: bool | None = None
|
registration_requires_approval: bool | None = None
|
||||||
|
# hero_image_key er BEVISST IKKE med her -- den settes kun via
|
||||||
|
# upload_hero_image under, aldri som en fritekst-streng klienten sender,
|
||||||
|
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting.
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/orgs/{organization_id}/tournaments/{tournament_id}", response_model=Tournament)
|
@router.patch("/orgs/{organization_id}/tournaments/{tournament_id}", response_model=Tournament)
|
||||||
|
|
@ -128,6 +133,43 @@ async def update_tournament(
|
||||||
return Tournament(**dict(row))
|
return Tournament(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/hero-image",
|
||||||
|
response_model=Tournament,
|
||||||
|
)
|
||||||
|
async def upload_hero_image(
|
||||||
|
tournament_id: str,
|
||||||
|
file: UploadFile,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> Tournament:
|
||||||
|
"""Ekte multipart-opplasting (IKKE en presignert nettleser-URL): filen
|
||||||
|
går gjennom API-et, som konverterer den til AVIF før den lagres i MinIO
|
||||||
|
-- se app/storage.py sin moduldocstring for hvorfor."""
|
||||||
|
if file.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")
|
||||||
|
|
||||||
|
raw = await file.read(storage.MAX_UPLOAD_BYTES + 1)
|
||||||
|
if len(raw) > storage.MAX_UPLOAD_BYTES:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).")
|
||||||
|
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
|
||||||
|
if exists is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
key = await storage.upload_image("tournaments", tournament_id, raw)
|
||||||
|
except storage.InvalidImageError:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
|
||||||
|
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"UPDATE tournament SET hero_image_key = $1 WHERE id = $2 RETURNING {_TOURNAMENT_COLUMNS}",
|
||||||
|
key,
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
return Tournament(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Lag (ADR-011: maks to lag per turnering, håndhevet her — ikke i skjemaet —
|
# Lag (ADR-011: maks to lag per turnering, håndhevet her — ikke i skjemaet —
|
||||||
# slik at match-modellen kan holdes generell for fremtidige turneringstyper)
|
# slik at match-modellen kan holdes generell for fremtidige turneringstyper)
|
||||||
|
|
@ -501,7 +543,7 @@ async def create_session(
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Sponsorer (ADR-018) -- navn+lenke aktivt, logo inert til MinIO-runden.
|
# Sponsorer (ADR-018) -- navn+lenke+logo, alle aktive fra MinIO-runden.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class SponsorCreate(BaseModel):
|
class SponsorCreate(BaseModel):
|
||||||
|
|
@ -509,10 +551,14 @@ class SponsorCreate(BaseModel):
|
||||||
url: str | None = None
|
url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_SPONSOR_COLUMNS = "id::text, name, url, logo_key"
|
||||||
|
|
||||||
|
|
||||||
class Sponsor(BaseModel):
|
class Sponsor(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
url: str | None
|
url: str | None
|
||||||
|
logo_key: str | None
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
@ -525,7 +571,7 @@ async def list_sponsors(
|
||||||
) -> list[Sponsor]:
|
) -> list[Sponsor]:
|
||||||
async with org_connection(organization_id) as conn:
|
async with org_connection(organization_id) as conn:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"SELECT id::text, name, url FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at",
|
f"SELECT {_SPONSOR_COLUMNS} FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at",
|
||||||
tournament_id,
|
tournament_id,
|
||||||
)
|
)
|
||||||
return [Sponsor(**dict(r)) for r in rows]
|
return [Sponsor(**dict(r)) for r in rows]
|
||||||
|
|
@ -546,10 +592,10 @@ async def add_sponsor(
|
||||||
if tournament is None:
|
if tournament is None:
|
||||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""
|
f"""
|
||||||
INSERT INTO tournament_sponsor (organization_id, tournament_id, name, url)
|
INSERT INTO tournament_sponsor (organization_id, tournament_id, name, url)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING id::text, name, url
|
RETURNING {_SPONSOR_COLUMNS}
|
||||||
""",
|
""",
|
||||||
organization_id,
|
organization_id,
|
||||||
tournament_id,
|
tournament_id,
|
||||||
|
|
@ -559,6 +605,85 @@ async def add_sponsor(
|
||||||
return Sponsor(**dict(row))
|
return Sponsor(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
class SponsorUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
url: str | None = None
|
||||||
|
# logo_key er BEVISST IKKE med -- settes kun via upload_sponsor_logo
|
||||||
|
# under, samme begrunnelse som tournament.hero_image_key over.
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}",
|
||||||
|
response_model=Sponsor,
|
||||||
|
)
|
||||||
|
async def update_sponsor(
|
||||||
|
tournament_id: str,
|
||||||
|
sponsor_id: str,
|
||||||
|
body: SponsorUpdate,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> Sponsor:
|
||||||
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
if not updates:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
||||||
|
|
||||||
|
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
|
||||||
|
values = list(updates.values())
|
||||||
|
values.append(sponsor_id)
|
||||||
|
values.append(tournament_id)
|
||||||
|
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"""
|
||||||
|
UPDATE tournament_sponsor SET {', '.join(set_clauses)}
|
||||||
|
WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)}
|
||||||
|
RETURNING {_SPONSOR_COLUMNS}
|
||||||
|
""",
|
||||||
|
*values,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.")
|
||||||
|
return Sponsor(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}/logo",
|
||||||
|
response_model=Sponsor,
|
||||||
|
)
|
||||||
|
async def upload_sponsor_logo(
|
||||||
|
tournament_id: str,
|
||||||
|
sponsor_id: str,
|
||||||
|
file: UploadFile,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> Sponsor:
|
||||||
|
if file.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")
|
||||||
|
|
||||||
|
raw = await file.read(storage.MAX_UPLOAD_BYTES + 1)
|
||||||
|
if len(raw) > storage.MAX_UPLOAD_BYTES:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).")
|
||||||
|
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
exists = await conn.fetchval(
|
||||||
|
"SELECT id FROM tournament_sponsor WHERE id = $1 AND tournament_id = $2",
|
||||||
|
sponsor_id,
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
if exists is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
key = await storage.upload_image("sponsors", sponsor_id, raw)
|
||||||
|
except storage.InvalidImageError:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
|
||||||
|
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"UPDATE tournament_sponsor SET logo_key = $1 WHERE id = $2 RETURNING {_SPONSOR_COLUMNS}",
|
||||||
|
key,
|
||||||
|
sponsor_id,
|
||||||
|
)
|
||||||
|
return Sponsor(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}",
|
"/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}",
|
||||||
status_code=204,
|
status_code=204,
|
||||||
|
|
|
||||||
101
app/storage.py
Normal file
101
app/storage.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
"""
|
||||||
|
MinIO-objektlagring for landingsside-media (ADR-018, MinIO-runden).
|
||||||
|
|
||||||
|
Opplasting går GJENNOM API-et, ikke direkte fra nettleser til MinIO --
|
||||||
|
bevisst, ikke bare enklere: alle bilder konverteres til AVIF server-side
|
||||||
|
før lagring (plassbesparelse), noe en direkte presignert nettleser-
|
||||||
|
opplasting ikke ville latt oss gjøre uten et eget etterbehandlingssteg.
|
||||||
|
|
||||||
|
Dette betyr KUN ÉTT MinIO-klientoppsett trengs -- backend-en snakker alltid
|
||||||
|
direkte med MinIO container-internt (teecup-minio:9000 -- bindestrek, IKKE
|
||||||
|
understrek: MinIO validerer Host-headeren strengt og avviser understrek som
|
||||||
|
ugyldig vertsnavn, funnet under scratch-verifisering), aldri via Caddy.
|
||||||
|
Caddy sin /teecup-media/*-rute trengs KUN for offentlig LESING (anonym GET
|
||||||
|
mot en public-read bucket-policy, ingen signatur involvert, derav ingen
|
||||||
|
presignert-URL-Host-header-følsomhet å bekymre seg for).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pillow_avif # noqa: F401 -- registrerer AVIF-plugin i Pillow ved import
|
||||||
|
from minio import Minio
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
ALLOWED_INPUT_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||||||
|
MAX_UPLOAD_BYTES = 8 * 1024 * 1024 # 8 MB rå input, før konvertering
|
||||||
|
_MAX_DIMENSION = 2400 # nedskaler urimelig store bilder før konvertering
|
||||||
|
_AVIF_QUALITY = 65
|
||||||
|
|
||||||
|
BUCKET = settings.MINIO_BUCKET
|
||||||
|
|
||||||
|
_client = Minio(
|
||||||
|
settings.MINIO_INTERNAL_ENDPOINT,
|
||||||
|
access_key=settings.MINIO_ROOT_USER,
|
||||||
|
secret_key=settings.MINIO_ROOT_PASSWORD,
|
||||||
|
secure=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
_PUBLIC_READ_POLICY = f"""{{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": {{"AWS": ["*"]}},
|
||||||
|
"Action": ["s3:GetObject"],
|
||||||
|
"Resource": ["arn:aws:s3:::{BUCKET}/*"]
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidImageError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_bucket_sync() -> None:
|
||||||
|
if not _client.bucket_exists(BUCKET):
|
||||||
|
_client.make_bucket(BUCKET)
|
||||||
|
# Idempotent: samme policy satt på nytt hver oppstart er en no-op.
|
||||||
|
_client.set_bucket_policy(BUCKET, _PUBLIC_READ_POLICY)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_bucket() -> None:
|
||||||
|
"""Kalt fra app/main.py sin lifespan ved oppstart -- trygt å kjøre hver
|
||||||
|
gang (oppretter kun bucket/policy hvis de ikke allerede er der)."""
|
||||||
|
await asyncio.to_thread(_ensure_bucket_sync)
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_to_avif_sync(raw: bytes) -> bytes:
|
||||||
|
try:
|
||||||
|
with Image.open(io.BytesIO(raw)) as img:
|
||||||
|
img.load()
|
||||||
|
if img.mode not in ("RGB", "RGBA"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
if max(img.size) > _MAX_DIMENSION:
|
||||||
|
img.thumbnail((_MAX_DIMENSION, _MAX_DIMENSION))
|
||||||
|
out = io.BytesIO()
|
||||||
|
img.save(out, format="AVIF", quality=_AVIF_QUALITY)
|
||||||
|
return out.getvalue()
|
||||||
|
except UnidentifiedImageError as e:
|
||||||
|
raise InvalidImageError("ikke en gjenkjennbar bildefil") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _put_sync(key: str, data: bytes) -> None:
|
||||||
|
_client.put_object(BUCKET, key, io.BytesIO(data), length=len(data), content_type="image/avif")
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_image(prefix: str, resource_id: str, raw: bytes) -> str:
|
||||||
|
"""Konverterer rå bildedata (jpeg/png/webp/gif) til AVIF og laster opp.
|
||||||
|
Returnerer objektnøkkelen -- IKKE en URL, se public_url()."""
|
||||||
|
avif_bytes = await asyncio.to_thread(_convert_to_avif_sync, raw)
|
||||||
|
key = f"{prefix}/{resource_id}/{int(time.time())}.avif"
|
||||||
|
await asyncio.to_thread(_put_sync, key, avif_bytes)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def public_url(key: str) -> str:
|
||||||
|
return f"{settings.PUBLIC_BASE_URL}/{BUCKET}/{key}"
|
||||||
|
|
@ -1,4 +1,20 @@
|
||||||
services:
|
services:
|
||||||
|
# Bindestrek, IKKE understrek: MinIO validerer Host-headeren strengt og
|
||||||
|
# avviser understrek som ugyldig vertsnavn (funnet under scratch-
|
||||||
|
# verifisering -- se CLAUDE.md-status).
|
||||||
|
teecup-minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: teecup-minio
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${TEECUP_MINIO_ROOT_USER}
|
||||||
|
MINIO_ROOT_PASSWORD: ${TEECUP_MINIO_ROOT_PASSWORD}
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
volumes:
|
||||||
|
- teecup_minio_data:/data
|
||||||
|
networks:
|
||||||
|
- teeoff_default
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
teecup_api:
|
teecup_api:
|
||||||
build: .
|
build: .
|
||||||
container_name: teecup_api
|
container_name: teecup_api
|
||||||
|
|
@ -14,9 +30,14 @@ services:
|
||||||
TEECUP_SMTP_USER: ${TEECUP_SMTP_USER}
|
TEECUP_SMTP_USER: ${TEECUP_SMTP_USER}
|
||||||
TEECUP_SMTP_PASS: ${TEECUP_SMTP_PASS}
|
TEECUP_SMTP_PASS: ${TEECUP_SMTP_PASS}
|
||||||
TEECUP_FROM_EMAIL: ${TEECUP_FROM_EMAIL}
|
TEECUP_FROM_EMAIL: ${TEECUP_FROM_EMAIL}
|
||||||
|
TEECUP_MINIO_ROOT_USER: ${TEECUP_MINIO_ROOT_USER}
|
||||||
|
TEECUP_MINIO_ROOT_PASSWORD: ${TEECUP_MINIO_ROOT_PASSWORD}
|
||||||
|
TEECUP_MINIO_BUCKET: ${TEECUP_MINIO_BUCKET}
|
||||||
networks:
|
networks:
|
||||||
- teeoff_default
|
- teeoff_default
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- teecup-minio
|
||||||
|
|
||||||
teecup_frontend:
|
teecup_frontend:
|
||||||
build:
|
build:
|
||||||
|
|
@ -30,6 +51,9 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
- teecup_api
|
- teecup_api
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
teecup_minio_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
teeoff_default:
|
teeoff_default:
|
||||||
external: true
|
external: true
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ type PublicTournamentInfo = {
|
||||||
name: string
|
name: string
|
||||||
organization_name: string
|
organization_name: string
|
||||||
description: string | null
|
description: string | null
|
||||||
|
hero_image_url: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
|
|
@ -27,11 +28,12 @@ export async function generateMetadata({
|
||||||
const title = `${data.name} | ${data.organization_name}`
|
const title = `${data.name} | ${data.organization_name}`
|
||||||
const description =
|
const description =
|
||||||
data.description?.trim() || `${data.name} arrangeres av ${data.organization_name} på TeeCup.`
|
data.description?.trim() || `${data.name} arrangeres av ${data.organization_name} på TeeCup.`
|
||||||
|
const images = data.hero_image_url ? [{ url: data.hero_image_url }] : undefined
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
openGraph: { title, description, siteName: "TeeCup", type: "website" },
|
openGraph: { title, description, siteName: "TeeCup", type: "website", images },
|
||||||
twitter: { card: "summary", title, description },
|
twitter: { card: images ? "summary_large_image" : "summary", title, description, images },
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Feil her skal ALDRI hindre selve siden i å laste -- kun forringe
|
// Feil her skal ALDRI hindre selve siden i å laste -- kun forringe
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ type Sponsor = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
url: string
|
url: string
|
||||||
|
logoUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type TournamentInfo = {
|
type TournamentInfo = {
|
||||||
|
|
@ -44,6 +45,7 @@ type TournamentInfo = {
|
||||||
endDate?: Date
|
endDate?: Date
|
||||||
registered: number
|
registered: number
|
||||||
capacity?: number // undefined = no cap
|
capacity?: number // undefined = no cap
|
||||||
|
heroImageUrl?: string
|
||||||
sessions: Session[]
|
sessions: Session[]
|
||||||
sponsors: Sponsor[]
|
sponsors: Sponsor[]
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +55,7 @@ type Outcome = "confirmed" | "waitlist" | "pending"
|
||||||
|
|
||||||
// --- API-typer (matcher app/routers/registration.py) -----------------------
|
// --- API-typer (matcher app/routers/registration.py) -----------------------
|
||||||
|
|
||||||
type ApiSponsor = { id: string; name: string; url: string | null }
|
type ApiSponsor = { id: string; name: string; url: string | null; logo_url: string | null }
|
||||||
|
|
||||||
type ApiTournamentInfo = {
|
type ApiTournamentInfo = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -68,6 +70,7 @@ type ApiTournamentInfo = {
|
||||||
registration_deadline: string | null
|
registration_deadline: string | null
|
||||||
registration_capacity: number | null
|
registration_capacity: number | null
|
||||||
confirmed_count: number
|
confirmed_count: number
|
||||||
|
hero_image_url: string | null
|
||||||
sponsors: ApiSponsor[]
|
sponsors: ApiSponsor[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,8 +193,14 @@ export function PublicTournament({ tournamentId }: { tournamentId: string }) {
|
||||||
endDate: info.end_date ? new Date(info.end_date) : undefined,
|
endDate: info.end_date ? new Date(info.end_date) : undefined,
|
||||||
registered: info.confirmed_count,
|
registered: info.confirmed_count,
|
||||||
capacity: info.registration_capacity ?? undefined,
|
capacity: info.registration_capacity ?? undefined,
|
||||||
|
heroImageUrl: info.hero_image_url ?? undefined,
|
||||||
sessions,
|
sessions,
|
||||||
sponsors: info.sponsors.map((s) => ({ id: s.id, name: s.name, url: s.url ?? "#" })),
|
sponsors: info.sponsors.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
url: s.url ?? "#",
|
||||||
|
logoUrl: s.logo_url ?? undefined,
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -204,7 +213,7 @@ export function PublicTournament({ tournamentId }: { tournamentId: string }) {
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
|
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<Banner name={t.name} club={t.club} />
|
<Banner name={t.name} club={t.club} imageUrl={t.heroImageUrl} />
|
||||||
|
|
||||||
{t.intro && (
|
{t.intro && (
|
||||||
<section aria-label="Om turneringen">
|
<section aria-label="Om turneringen">
|
||||||
|
|
@ -237,10 +246,24 @@ export function PublicTournament({ tournamentId }: { tournamentId: string }) {
|
||||||
|
|
||||||
// --- Banner ----------------------------------------------------------------
|
// --- Banner ----------------------------------------------------------------
|
||||||
|
|
||||||
function Banner({ name, club }: { name: string; club: string }) {
|
function Banner({ name, club, imageUrl }: { name: string; club: string; imageUrl?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-br from-primary to-primary/80 px-6 py-10 shadow-lg shadow-primary/20 sm:px-8 sm:py-12">
|
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-br from-primary to-primary/80 px-6 py-10 shadow-lg shadow-primary/20 sm:px-8 sm:py-12">
|
||||||
{/* Subtle decorative rings — intentional, part of the brand banner look. */}
|
{imageUrl ? (
|
||||||
|
<>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element -- images.unoptimized er alt satt, ingen next/image-fordel her */}
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 size-full object-cover"
|
||||||
|
/>
|
||||||
|
{/* Skrim for lesbar tekst over et vilkårlig opplastet bilde. */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-black/10" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Ingen bilde lastet opp ennå -- bevisst permanent fargebanner, ikke en "kommer snart"-plassholder. */}
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none absolute -right-16 -top-16 size-56 rounded-full border-[12px] border-primary-foreground/10"
|
className="pointer-events-none absolute -right-16 -top-16 size-56 rounded-full border-[12px] border-primary-foreground/10"
|
||||||
|
|
@ -249,6 +272,8 @@ function Banner({ name, club }: { name: string; club: string }) {
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="pointer-events-none absolute -bottom-20 -left-10 size-48 rounded-full border-[12px] border-primary-foreground/10"
|
className="pointer-events-none absolute -bottom-20 -left-10 size-48 rounded-full border-[12px] border-primary-foreground/10"
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="relative flex flex-col gap-2">
|
<div className="relative flex flex-col gap-2">
|
||||||
<span className="text-sm font-semibold uppercase tracking-wide text-primary-foreground/80">
|
<span className="text-sm font-semibold uppercase tracking-wide text-primary-foreground/80">
|
||||||
{club}
|
{club}
|
||||||
|
|
@ -357,8 +382,14 @@ function Sponsors({ sponsors }: { sponsors: Sponsor[] }) {
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Sponsorer
|
Sponsorer
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||||
{sponsors.map((sp) => (
|
{sponsors.map((sp) =>
|
||||||
|
sp.logoUrl ? (
|
||||||
|
<a key={sp.id} href={sp.url} aria-label={sp.name} className="opacity-80 transition-opacity hover:opacity-100">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element -- images.unoptimized er alt satt */}
|
||||||
|
<img src={sp.logoUrl} alt={sp.name} className="h-8 w-auto object-contain" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
<a
|
<a
|
||||||
key={sp.id}
|
key={sp.id}
|
||||||
href={sp.url}
|
href={sp.url}
|
||||||
|
|
@ -366,7 +397,8 @@ function Sponsors({ sponsors }: { sponsors: Sponsor[] }) {
|
||||||
>
|
>
|
||||||
{sp.name}
|
{sp.name}
|
||||||
</a>
|
</a>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue