Før redesign
|
|
@ -404,7 +404,40 @@
|
|||
"Bash(openssl rand *)",
|
||||
"Bash(curl -s -X POST http://localhost:18100/auth/request-link -H \"Content-Type: application/json\" -d '{\"email\":\"scratch-official-e2e@testmailbox.golf\"}')",
|
||||
"Bash(docker rm -f teecup_frontend_scratch6_run teecup_api_scratch6_run teecup-minio-scratch6 2>&1 *)",
|
||||
"Bash(grep -n \"^[0-9]\\\\{1,2\\\\}\\\\. \\\\*\\\\*\" /opt/teecup/CHANGELOG.md)"
|
||||
"Bash(grep -n \"^[0-9]\\\\{1,2\\\\}\\\\. \\\\*\\\\*\" /opt/teecup/CHANGELOG.md)",
|
||||
"Bash(grep -n \"my-rounds/\\\\${friend\" /opt/teecup/frontend/components/dashboard.tsx)",
|
||||
"Bash(grep -n \"href=\\\\`/my-rounds\\\\|href={\\\\`/my-rounds\\\\|round.round_id\\\\|friend.round_id\" /opt/teecup/frontend/components/dashboard.tsx)",
|
||||
"Bash(curl -s -o /dev/null -w \"dashboard: %{http_code}\\\\n\" https://teecup.golf/dashboard)",
|
||||
"Bash(curl -s -o /dev/null -w \"teeoff.no upåvirket: %{http_code}\\\\n\" https://teeoff.no)",
|
||||
"Bash(curl -s https://teecup.golf/health)",
|
||||
"Bash(curl -s -o /dev/null -w \"icon.svg: %{http_code}\\\\n\" https://teecup.golf/icon.svg)",
|
||||
"Bash(curl -s -o /dev/null -w \"apple-icon.png: %{http_code}\\\\n\" https://teecup.golf/apple-icon.png)",
|
||||
"Bash(curl -s -o /dev/null -w \"manifest icon-512: %{http_code}\\\\n\" https://teecup.golf/icons/icon-512.png)",
|
||||
"Bash(grep -n \"^function \\\\|^export function \\\\|fetch\\(\\\\`/rounds\\\\|fetch\\(\\\\\"/rounds\\\\|ws/rounds\\\\|href={\\\\`/my-rounds\" /opt/teecup/frontend/components/round-leaderboard.tsx)",
|
||||
"Bash(grep -n 'fetch\\(`/rounds\\\\|fetch\\(\"/rounds\\\\|ws/rounds\\\\|href={`/my-rounds\\\\|href=\"/my-rounds' /opt/teecup/frontend/components/round-scorecard.tsx)",
|
||||
"Bash(grep -n \"const endpoint\\\\|/rounds/\\\\${roundId}/sides\\\\|/rounds/\\\\${roundId}/participants\" /opt/teecup/frontend/components/round-scorecard.tsx)",
|
||||
"Bash(grep -n 'href=\"/my-rounds\"\\\\|href={`/my-rounds' /opt/teecup/frontend/components/round-scorecard.tsx)",
|
||||
"Bash(grep -n 'fetch\\(`/rounds\\\\|fetch\\(\"/rounds\\\\|/ws/rounds\\\\|href={`/my-rounds\\\\|href=\"/my-rounds\\\\|Rediger\\\\|Slett\\\\|Fullfør\\\\|Medspiller' /opt/teecup/frontend/components/round-scorecard.tsx)",
|
||||
"Bash(grep -n 'href={`/my-rounds' /opt/teecup/frontend/components/round-leaderboard.tsx)",
|
||||
"Bash(grep -n 'fetch\\(`/rounds\\\\|fetch\\(\"/rounds\\\\|/ws/rounds\\\\|Rediger\\\\|Slett runde\\\\|Fullfør runde\\\\|Medspiller' /opt/teecup/frontend/components/round-leaderboard.tsx)",
|
||||
"Bash(mkdir -p /opt/teecup/frontend/app/watch/\\\\[id\\\\]/scorecard /opt/teecup/frontend/app/watch/\\\\[id\\\\]/leaderboard)",
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:18202/)",
|
||||
"Bash(curl -s -o /dev/null -w \"API flight-group \\(random id\\): %{http_code}\\\\n\" https://teecup.golf/public/rounds/00000000-0000-0000-0000-000000000000/flight-group)",
|
||||
"Bash(curl -s -o /dev/null -w \"watch page: %{http_code}\\\\n\" https://teecup.golf/watch/00000000-0000-0000-0000-000000000000)",
|
||||
"Bash(curl -s -o /dev/null -w \"watch scorecard page: %{http_code}\\\\n\" https://teecup.golf/watch/00000000-0000-0000-0000-000000000000/scorecard)",
|
||||
"Bash(curl -s -o /dev/null -w \"watch leaderboard page: %{http_code}\\\\n\" https://teecup.golf/watch/00000000-0000-0000-0000-000000000000/leaderboard)",
|
||||
"Bash(curl -s -X POST http://localhost:8000/auth/request-link -H 'Content-Type: application/json' -d '{\"email\":\"mort.wo25@gmail.com\",\"locale\":\"nb\"}' --max-time 10 -o /dev/null -w 'status=%{http_code}\\\\n')",
|
||||
"Bash(chmod 777 /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies)",
|
||||
"Bash(rm -f /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies/*.txt /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies/body.txt)",
|
||||
"Bash(mkdir -p v0-export-34)",
|
||||
"Bash(unzip -o \"/opt/teecup/tee-cup-login-screen \\(34\\).zip\" -d v0-export-34)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(awk '/^export function MatchScorecardGrid/{f=1} f{print NR\": \"$0} f && /^}/{c++; if\\(c==1\\) exit}' /opt/teecup/frontend/components/round-scorecard.tsx)",
|
||||
"Bash(awk -F: '$1>=781')",
|
||||
"Bash(grep -n \"return { round, leaderboard\" /opt/teecup/frontend/components/round-leaderboard.tsx)",
|
||||
"WebFetch(domain:docs.mapbox.com)",
|
||||
"Bash(python3 -m py_compile app/routers/rounds.py app/routers/round_messages.py app/config.py)",
|
||||
"Bash(npm view *)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/opt/teeoff/deploy",
|
||||
|
|
|
|||
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
|||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
Temp-uploads/
|
||||
|
|
|
|||
107
059_post_reactions_and_comments.sql
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
-- =====================================================================
|
||||
-- TeeCup — Reaksjoner (emoji) + trådede kommentarer på innlegg,
|
||||
-- migrasjon 059 (ADR-046).
|
||||
--
|
||||
-- To tabellpar, ett per eksisterende "innlegg"-tabell -- IKKE én
|
||||
-- polymorf tabell på tvers av tenant-grensen (ADR-044 Beslutning C sin
|
||||
-- begrunnelse gjelder identisk her: round_message har ingen
|
||||
-- organization_id/RLS (ADR-033 Beslutning A), message har begge
|
||||
-- (ADR-025) -- CLAUDE.md sin invariant "organization_id på alle
|
||||
-- domenetabeller, håndhevet av RLS" krever egen RLS-håndtering for
|
||||
-- org-siden uansett).
|
||||
--
|
||||
-- Én reaksjon per bruker per innlegg (UPSERT ved bytte av emoji, ikke
|
||||
-- en stabel). Kommentarer lagres FLATT med parent_comment_id --
|
||||
-- frontend bygger tre-strukturen. Sletting av en kommentar kaskade-
|
||||
-- sletter undertråden (samme "kun slett, aldri rediger"-filosofi som
|
||||
-- round_message/message selv).
|
||||
-- =====================================================================
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- round_message -- ingen RLS, speiler round_message selv (ADR-033)
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE round_message_reaction (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
round_message_id uuid NOT NULL REFERENCES round_message(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES app_user(id),
|
||||
emoji text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (round_message_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX round_message_reaction_message_idx ON round_message_reaction (round_message_id);
|
||||
|
||||
-- INGEN RLS -- samme plain_connection()-mønster som round_message selv.
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON round_message_reaction TO teecup_app;
|
||||
|
||||
CREATE TABLE round_message_comment (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
round_message_id uuid NOT NULL REFERENCES round_message(id) ON DELETE CASCADE,
|
||||
parent_comment_id uuid REFERENCES round_message_comment(id) ON DELETE CASCADE,
|
||||
author_user_id uuid NOT NULL REFERENCES app_user(id),
|
||||
-- Frosset ved skrivetidspunkt, samme prinsipp som round_message.author_display_name.
|
||||
author_display_name text NOT NULL,
|
||||
body text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CHECK (length(btrim(body)) > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX round_message_comment_message_idx ON round_message_comment (round_message_id, created_at);
|
||||
CREATE INDEX round_message_comment_parent_idx ON round_message_comment (parent_comment_id);
|
||||
|
||||
GRANT SELECT, INSERT, DELETE ON round_message_comment TO teecup_app;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- message (lag-chat + org-oppslagstavle) -- RLS'et, org-tenant-invariant
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE message_reaction (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id uuid NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
|
||||
message_id uuid NOT NULL REFERENCES message(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES app_user(id),
|
||||
emoji text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (message_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX message_reaction_message_idx ON message_reaction (message_id);
|
||||
|
||||
ALTER TABLE message_reaction ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE message_reaction FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY org_isolation ON message_reaction
|
||||
USING (organization_id = app_current_org())
|
||||
WITH CHECK (organization_id = app_current_org());
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON message_reaction TO teecup_app;
|
||||
|
||||
CREATE TABLE message_comment (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id uuid NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
|
||||
message_id uuid NOT NULL REFERENCES message(id) ON DELETE CASCADE,
|
||||
parent_comment_id uuid REFERENCES message_comment(id) ON DELETE CASCADE,
|
||||
author_user_id uuid NOT NULL REFERENCES app_user(id),
|
||||
author_display_name text NOT NULL,
|
||||
body text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CHECK (length(btrim(body)) > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX message_comment_message_idx ON message_comment (message_id, created_at);
|
||||
CREATE INDEX message_comment_parent_idx ON message_comment (parent_comment_id);
|
||||
|
||||
ALTER TABLE message_comment ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE message_comment FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY org_isolation ON message_comment
|
||||
USING (organization_id = app_current_org())
|
||||
WITH CHECK (organization_id = app_current_org());
|
||||
|
||||
GRANT SELECT, INSERT, DELETE ON message_comment TO teecup_app;
|
||||
75
060_round_shot.sql
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
-- =====================================================================
|
||||
-- TeeCup — Slag-for-slag GPS-avstandsmåling, migrasjon 060 (ADR-048).
|
||||
--
|
||||
-- IKKE en gjenåpning av ADR-033s avvisning av fri slag-for-slag-logging
|
||||
-- som DATAMODELL for hull-statistikk. Dette er en separat, valgfri
|
||||
-- måle-funksjon: spilleren kan måle avstanden på ett eller flere
|
||||
-- enkeltslag med GPS, uavhengig av det faste hull-statistikk-skjemaet.
|
||||
-- `round_hole` sine faste felt (club_off_tee, putts, osv.) er UBERØRT.
|
||||
--
|
||||
-- Eierskap: round_shot henger kun av round_hole.id -- IKKE direkte av
|
||||
-- round_participant_id/round_side_id. round_hole har allerede en XOR
|
||||
-- mellom disse to (round_hole_owner_xor, migrasjon 031), så round_shot
|
||||
-- arver eierskapet (individuell ELLER lagformat) transitivt uten å
|
||||
-- duplisere XOR-logikken selv. Dette dekker BEGGE hull-typer fra v1
|
||||
-- (ADR-048 Beslutning C).
|
||||
--
|
||||
-- Eier-mønster for øvrig: samme non-RLS plain_connection() + WHERE-
|
||||
-- filtrering som resten av frittstående-runde-subsystemet (ADR-033
|
||||
-- Beslutning A).
|
||||
--
|
||||
-- Koordinater: rene double precision lat/lng-par, IKKE PostGIS. Volum er
|
||||
-- maks noen titalls rader per runde, og eneste geometriske operasjon er
|
||||
-- punkt-til-punkt storsirkel-avstand (Haversine), regnet CLIENT-SIDE i
|
||||
-- JS -- PostGIS ville vært en ny, tung avhengighet uten reell gevinst
|
||||
-- her (ADR-048 Beslutning D).
|
||||
-- =====================================================================
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
CREATE TABLE round_shot (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
round_hole_id uuid NOT NULL REFERENCES round_hole(id) ON DELETE CASCADE,
|
||||
-- Hvem som MÅLTE slaget -- ikke nødvendigvis samme som round_hole sin
|
||||
-- eier, en medspiller kan føre for flighten (samme skrive-autorisasjons-
|
||||
-- modell som update_hole/side-PATCH allerede bruker).
|
||||
recorded_by_user_id uuid NOT NULL REFERENCES app_user(id),
|
||||
|
||||
-- Eksplisitt, hull-tolerant rekkefølge (IKKE ORDER BY captured_at) --
|
||||
-- lar et slag midt i sekvensen slettes uten omnummerering av resten,
|
||||
-- og unngår at GPS-tidsstempel-drift/bakgrunnsthrottling noensinne
|
||||
-- stille endrer rekkefølgen.
|
||||
shot_number integer NOT NULL CHECK (shot_number > 0),
|
||||
|
||||
club text NOT NULL CHECK (char_length(club) <= 50),
|
||||
-- Øvre grense 500m er en fornuftssjekk mot GPS-glitch/feiltrykk, ikke
|
||||
-- en golf-regel (lengste realistiske driver ligger rundt 370m).
|
||||
distance_meters numeric(6,1) NOT NULL CHECK (distance_meters > 0 AND distance_meters < 500),
|
||||
|
||||
start_method text NOT NULL CHECK (start_method IN ('gps', 'map_tap')),
|
||||
start_lat double precision NOT NULL CHECK (start_lat BETWEEN -90 AND 90),
|
||||
start_lng double precision NOT NULL CHECK (start_lng BETWEEN -180 AND 180),
|
||||
end_lat double precision NOT NULL CHECK (end_lat BETWEEN -90 AND 90),
|
||||
end_lng double precision NOT NULL CHECK (end_lng BETWEEN -180 AND 180),
|
||||
|
||||
-- Deling til rundens kommentarfelt (ADR-044) -- lenke, ikke bare et
|
||||
-- bool-flagg, slik at "delt" alltid reflekterer om meldingen FAKTISK
|
||||
-- finnes (ON DELETE SET NULL: slettes meldingen separat i feeden,
|
||||
-- forblir slaget "målt, men ikke lenger delt", ikke ugyldig).
|
||||
shared_round_message_id uuid REFERENCES round_message(id) ON DELETE SET NULL,
|
||||
|
||||
captured_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (round_hole_id, shot_number)
|
||||
);
|
||||
|
||||
CREATE INDEX round_shot_hole_idx ON round_shot (round_hole_id, shot_number);
|
||||
|
||||
-- INGEN RLS -- samme plain_connection()-mønster som round_message/
|
||||
-- round_hole (ADR-033 Beslutning A). Autorisasjon i app-laget.
|
||||
GRANT SELECT, INSERT, DELETE ON round_shot TO teecup_app;
|
||||
-- Ingen generell UPDATE-grant -- samme "slett og opprett på nytt, aldri
|
||||
-- rediger"-prinsipp som round_message (et feilmålt slag slettes og måles
|
||||
-- på nytt). ETT eksplisitt, smalt unntak: å knytte et allerede opprettet
|
||||
-- slag til en round_message som opprettes i et eget, påfølgende
|
||||
-- delings-steg (se app/routers/rounds.py, share_shot).
|
||||
GRANT UPDATE (shared_round_message_id) ON round_shot TO teecup_app;
|
||||
|
Before Width: | Height: | Size: 130 KiB |
|
|
@ -4181,7 +4181,480 @@ slett-knapp på `/my-feed` (kun fra runde-siden).
|
|||
|
||||
---
|
||||
|
||||
## Åpne spørsmål (ikke besluttet ennå)
|
||||
## ADR-045: Tilskuer-visning (`/watch/[id]`) — tre faner + "Spillere og runde", full paritet med eiersiden
|
||||
|
||||
Reist av brukeren 2026-08-06 (video-illustrasjon av bugen): en tilskuer som
|
||||
klikker seg inn på en venns pågående runde via "Venner på banen" endte på
|
||||
`/my-rounds/{id}` (eier-/medspiller-only, `_get_accessible_round_or_404`)
|
||||
i stedet for `/watch/{id}` (den faktiske tredjepartsvisningen, ADR-036
|
||||
fase 2) — rettet samme dag som en liten, isolert lenke-bug (se CHANGELOG
|
||||
2026-08-06, punkt 26). I samme runde ba brukeren om at `/watch/[id]`
|
||||
(fram til da ÉN enkelt side, kun kompakt status + leaderboard/matchstatus)
|
||||
skulle få samme tre faner som eiersiden (Score/Scorekort/Leaderboard) og
|
||||
vise "Spillere og runde" (deltakerliste, HCP/utslag/tildelte slag,
|
||||
flight-gruppering), uten eier-kun-handlingene (Rediger/Fullfør/Slett
|
||||
runde, +Medspiller).
|
||||
|
||||
**Beslutning A — Full formatparitet via delt komponent + `publicMode`-
|
||||
bryter, IKKE nye, forenklede tilskuer-komponenter.** `round-scorecard.tsx`
|
||||
(`RoundScorecard`) og `round-leaderboard.tsx` (`RoundLeaderboard`) sin
|
||||
faktiske rutenett-/tavle-rendring var ALLEREDE ren, skrivefri visning —
|
||||
redigering skjer et helt annet sted (Score-fanens `ScoringWizard` i
|
||||
`round-detail.tsx`). Det eneste som hindret gjenbruk for tilskuere var at
|
||||
alle interne data-hentinger (flere hooks: `useFormatResult`,
|
||||
`useFlightTeamStandings`, `useLeaderboardData`, pluss selve
|
||||
`RoundScorecard`/`RoundLeaderboard` sine egne fetch-effekter) hardkodet
|
||||
`/rounds/*`-prefikset og en autentisert WebSocket. Løsning: en ny,
|
||||
valgfri `publicMode`-prop (default `false`, INGEN endring i eiersidens
|
||||
oppførsel) som bytter alle interne URL-er til `/public/rounds/*` og
|
||||
WS-kanalen til `/ws/public/rounds/{id}/live`. Dette gir ekte full
|
||||
formatparitet (alle formater, ikke et utvalg) for null ekstra
|
||||
vedlikeholdshold -- samme kode, to datakilder -- fremfor å bygge og
|
||||
vedlikeholde en parallell, forenklet visning. Bekreftet eksplisitt med
|
||||
bruker (AskUserQuestion) at full detalj var ønsket, ikke en forenklet
|
||||
fellesvisning.
|
||||
|
||||
**Beslutning B — Ingen gjenbruk av `RoundHeader`/`RoundPageShell` for
|
||||
selve fane-navigasjonen.** `RoundPageShell` gjør sitt eget autentiserte
|
||||
`fetch('/rounds/{id}')`-kall og hardkoder `/my-rounds/{id}`-hrefs.
|
||||
`RoundHeader` er i og for seg props-styrt, men "Administrer"-tannhjulet
|
||||
render ALLTID og åpner `ManageRoundDialog` (Rediger/Fullfør/Slett-runde)
|
||||
uansett om handlere er gitt inn — ingen eksisterende prop slår av selve
|
||||
admin-seksjonen. Bygget i stedet en egen, liten `WatchTabs`-komponent
|
||||
(`frontend/components/watch-tabs.tsx`) uten noen admin-vei i det hele
|
||||
tatt -- ingen risiko for at en eier-handling vises ved et uhell, siden
|
||||
koden rett og slett ikke inneholder den.
|
||||
|
||||
**Beslutning C — Ny, egen "Spillere og runde"-komponent
|
||||
(`watch-players.tsx`), IKKE et forsøk på å gjøre `round-detail.tsx` sin
|
||||
lokale `PlayerList`-funksjon gjenbrukbar via `canManage=false`.**
|
||||
`PlayerList` er ikke eksportert, tett koblet til eier-autentiserte
|
||||
handlere, og selv `canManage`-proppen dekker ikke alt konsekvent i dag
|
||||
("Fullfør runde"-knappen der er kun gated på `!completed`, ikke
|
||||
`isOwnerViewer` -- en eksisterende, urelatert kvalitetsbrist, ikke
|
||||
arvet inn her). Den nye komponenten inneholder ingen +Medspiller-/
|
||||
Rediger-/Fullfør-/Slett-kode i det hele tatt. Viser deltakerkort
|
||||
(navn, "Eier"-merke -- bevisst INGEN "Deg"-merke, siden
|
||||
`PublicRoundParticipantOut` ikke eksponerer `user_id` og det uansett
|
||||
ikke gir mening i tilskuer-rammen), Utslag/HCP/Tildelte slag (allerede
|
||||
offentlig eksponert data), "Så langt"-progresjon (fra
|
||||
`/public/rounds/{id}/leaderboard`), og flight-gruppering.
|
||||
|
||||
**Beslutning D — Nytt offentlig endepunkt `GET /public/rounds/{id}/
|
||||
flight-group`.** Speiler den eksisterende autentiserte `/rounds/{id}/
|
||||
flight-group`, men med en ny `_viewable_flight_rows`-variant av
|
||||
`_accessible_flight_rows` som bruker `_can_view_round` (eier/medspiller/
|
||||
`public`/riktig-kategorisert `friends`-venn) per SØSKEN-flight uavhengig
|
||||
-- en flight-gruppe kan ha søsken med ulik `visibility_mode`, og en
|
||||
tilskuer skal kun se de søsknene de faktisk har innsyn i, akkurat som
|
||||
selve anker-runden. Avklart eksplisitt med bruker (AskUserQuestion) at
|
||||
flight-gruppering skulle bygges nå, ikke utsettes.
|
||||
|
||||
**Lukket, relatert gap:** den gamle `watch-round.tsx` sin lokale
|
||||
`TWO_SIDED_FORMATS`-liste dekket kun 8 av 17 formater (manglet
|
||||
`copenhagen/bbb/flag/shamble/money_ball/scramble_solo/scramble_solo_
|
||||
match`, som falt til "Ingen live-visning tilgjengelig ennå"). Løst
|
||||
automatisk av Beslutning A (samme `RoundLeaderboard`-komponent som
|
||||
eiersiden, som allerede dekker alle formater) -- ikke en egen,
|
||||
ekstra oppgave.
|
||||
|
||||
**Verifisert grundig, isolert scratch-miljø** (DB/rolle/MinIO/API-/
|
||||
frontend-container, pluss en minimal scratch-Caddy for ekte WS-testing,
|
||||
samme mønster som ADR-044): 6/6 håndregnede sjekker for det nye
|
||||
flight-group-endepunktet (anonym ser kun offentlige søsken-flighter,
|
||||
en venn med riktig kategori ser i tillegg `friends`-synlige, eieren ser
|
||||
alle, en fremmed uten vennskap ser kun offentlige, en privat anker-runde
|
||||
avvises helt for anonym). `tsc --noEmit` rent på hele frontend-prosjektet
|
||||
etter refaktoren. Ekte nettleser: **regresjon FØRST** -- eiersidens
|
||||
`/my-rounds/{id}/scorecard` og `/leaderboard` bekreftet uendret (samme
|
||||
tall, samme layout) FØR noe nytt ble testet. Deretter tilskuer-sidene:
|
||||
alle tre faner, "Spillere og runde" uten eier-knapper, ETT allerede-
|
||||
dekket format (`stroke`) OG ETT tidligere udekket format (`flag`,
|
||||
Flaggturnering) begge bekreftet å rendre korrekt gjennom
|
||||
`/watch/{id}/leaderboard` -- beviser formatparitet-lukkingen direkte,
|
||||
ikke bare i teorien. En privat rundes tilskuer-sider (alle tre) bekreftet
|
||||
korrekt avvist for en anonym leser (egen isolert nettleser-kontekst,
|
||||
ikke bare et API-kall) med riktig feilmelding og "Tilbake til
|
||||
dashbordet"-lenke. Scratch-miljøet ryddet opp fullstendig etter bruk.
|
||||
|
||||
**Bevisst utenfor omfang:** enhver endring i selve eiersidens
|
||||
`PlayerList`/`ManageRoundDialog`-logikk (den nevnte "Fullfør runde"-
|
||||
gating-kvaliteten er en egen, urelatert observasjon).
|
||||
|
||||
---
|
||||
|
||||
## ADR-046: Emoji-reaksjoner + trådede kommentarer på innlegg
|
||||
|
||||
Reist av brukeren 2026-08-06, som del av samme melding som "nyeste først,
|
||||
over alt": "man skal kunne like (eller bruke andre emojier) og kommentere
|
||||
på innlegg." Avklart eksplisitt med bruker (AskUserQuestion, tre
|
||||
spørsmål): (1) "innlegg" = BÅDE de eksisterende meldingene (rundekommentar/
|
||||
lag-chat-melding/oppslagstavle-post) OG dashbord-feedens oppføringer, (2)
|
||||
kommentarer skal være TRÅDET (svar på svar, flere nivåer), (3) reaksjoner
|
||||
følger samme omfang som kommentarer.
|
||||
|
||||
**Nøkkelfunn fra research:** dashbord-feeden (`/my-feed`, ADR-044
|
||||
Beslutning E) er IKKE en egen entitetstype ("runde fullført"-kort) — hver
|
||||
feed-oppføring ER ganske enkelt én `round_message`-rad, aggregert på tvers
|
||||
av runder. Å bygge reaksjoner/kommentarer på `round_message` dekker derfor
|
||||
AUTOMATISK både rundens egen kommentarseksjon og feeden — ingen egen
|
||||
tredje entitetstype trengtes. De reelle "innleggs"-typene var dermed kun
|
||||
to underliggende tabeller: `round_message` (ADR-044, INGEN
|
||||
`organization_id`/RLS) og `message` (ADR-025, `organization_id` NOT NULL
|
||||
+ RLS, diskriminert på `scope` for lag-chat/oppslagstavle).
|
||||
|
||||
**Beslutning A — To parallelle tabellpar, IKKE én polymorf tabell.**
|
||||
Migrasjon 059: `round_message_reaction`/`round_message_comment` (ingen
|
||||
RLS, speiler `round_message` selv) og `message_reaction`/`message_comment`
|
||||
(RLS'et via `app_current_org()`, speiler `message`). Samme presedens som
|
||||
ADR-044 Beslutning C — en delt tabell på tvers av RLS/ikke-RLS-grensen
|
||||
ville krevd enten en kunstig organisasjon eller et RLS-unntak, og
|
||||
CLAUDE.md sin invariant ("organization_id på alle domenetabeller,
|
||||
håndhevet av RLS") krever egen RLS-håndtering for org-siden uansett.
|
||||
|
||||
**Beslutning B — Reaksjoner: fast kuratert sett, én per bruker per
|
||||
innlegg.** Seks emojier (👍 ❤️ 😂 😮 😢 🙏), validert i app-laget (400 ved
|
||||
ukjent emoji) — IKKE et fritt emoji-utvalg. Bytte av emoji ERSTATTER
|
||||
(UPSERT via `ON CONFLICT (post_id, user_id) DO UPDATE`), stables ikke —
|
||||
matcher "like (eller bruke andre emojier)"-fraseringen (velg ÉN).
|
||||
|
||||
**Beslutning C — Kommentarer: flat lagring, kun tekst, kaskade-sletting.**
|
||||
`parent_comment_id` selv-referanse, lagret FLATT — frontend bygger
|
||||
tre-strukturen fra en kronologisk (eldst-først) liste (motsatt av resten
|
||||
av appens "nyeste først"-strømmer, bevisst: en samtaletråd leses
|
||||
top-til-bunn, ulikt en oppdateringsstrøm). Ingen bilde i selve
|
||||
kommentaren (innlegget har allerede sitt eget). Sletting av en kommentar
|
||||
med svar under KASKADE-SLETTER hele undertråden (FK `ON DELETE CASCADE`)
|
||||
— ikke myk-slett-med-plassholder, samme "kun slett, aldri rediger"-
|
||||
filosofi som `round_message`/`message` selv. Frontend viser en eksplisitt
|
||||
advarsel ("Sletter du denne, forsvinner også svarene under.") før
|
||||
bekreftet sletting av en kommentar med svar.
|
||||
|
||||
**Beslutning D — Autorisasjon speiler eksisterende regler per
|
||||
innleggstype, ingen ny modell.** Hvem kan reagere/kommentere = hvem kan
|
||||
POSTE selve innlegget (rundekommentar: `_can_view_round`/"kan se = kan
|
||||
bidra"; lag-chat: rostret på laget; oppslagstavle: `_may_post_to_feed`).
|
||||
Hvem kan slette en kommentar = samme regel som innleggstypen allerede
|
||||
bruker for å slette selve innlegget (rundekommentar: forfatter ELLER
|
||||
rundeeier; lag-chat: KUN forfatter, ingen unntak; oppslagstavle: forfatter
|
||||
ELLER org-admin via `is_org_admin`). Egen reaksjon fjernes alltid fritt av
|
||||
den som satte den.
|
||||
|
||||
**Beslutning E — Ingen ny WebSocket-payload-kanal.** Samme minimalt-
|
||||
fotavtrykk-valg som ADR-044 Beslutning D: klienten refetcher reaksjons-/
|
||||
kommentaroppsummeringen kun etter EGEN handling, ingen live-push til andre
|
||||
samtidige seere i v1. **Viktig rettelse funnet under nettleserverifisering:**
|
||||
de nye kommentar-endepunktene i `round_messages.py` kalte først
|
||||
`broadcast_round_update()` (kopiert inn ved en inkonsekvens mot egen
|
||||
plan) — dette trigget rundens EKSISTERENDE "noe endret seg"-WS-tick, som
|
||||
`RoundMessages` reagerer på med en full refetch av HELE meldingslisten,
|
||||
og med det kollapset enhver allerede-utvidet kommentartråd ANDRE STEDER
|
||||
på siden ved hver eneste kommentarhandling. Fjernet fra de nye
|
||||
kommentar-endepunktene (reaksjons-endepunktene hadde aldri kallet) —
|
||||
oppdaget og rettet FØR utrulling, takket være ekte nettleserverifisering
|
||||
i steget rett etterpå (ikke bare et API-testskript).
|
||||
|
||||
**Backend:** `app/routers/round_messages.py` fikk
|
||||
`PUT/DELETE /rounds/{id}/messages/{id}/reaction` og
|
||||
`GET/POST /rounds/{id}/messages/{id}/comments` +
|
||||
`DELETE .../comments/{id}`, pluss `reactions`/`comment_count`-felt lagt
|
||||
til på `RoundMessageOut` OG `FeedEntryOut` (grupperte batch-spørringer,
|
||||
ingen N+1). `app/routers/messaging.py` fikk samme sett ×2 (lag-chat +
|
||||
oppslagstavle), importerer `ALLOWED_REACTION_EMOJIS`/`ReactionSummary`/
|
||||
`ReactionIn`/`CommentIn` fra `round_messages.py` for å unngå duplisert
|
||||
typedefinisjon (bevisst IKKE en delt SQL-hjelpemodul på tvers av filene,
|
||||
siden `plain_connection()` vs. `org_connection()` uansett gjør
|
||||
spørringene ulike nok at abstraksjon over filgrensen ville vært kunstig).
|
||||
|
||||
**Frontend:** ny delt komponent `post-engagement.tsx` — reaksjonsrad
|
||||
(kuratert sett, egen valgt fremhevet grønt) + trådet kommentarfelt (lazy-
|
||||
lastet ved utvidelse, "Svar"-knapp per kommentar med innrykk per nivå,
|
||||
kaskade-advarsel ved sletting), bygget via ÉN Claude-skrevet V0-prompt
|
||||
(standard arbeidsfordeling) og integrert hånd-kodet i FIRE eksisterende
|
||||
steder: `round-messages.tsx`, `feed.tsx` (`FeedCard`), `team-chat.tsx`,
|
||||
`public-tournament.tsx` (`TournamentFeed`). To bevisste
|
||||
integrasjonsjusteringer utover ren copy-inn: (1) byttet V0-eksportens
|
||||
egendefinerte inline SVG-ikoner til `lucide-react` (`MessageSquare`/
|
||||
`Reply`/`Trash2`) for å følge DESIGN_SYSTEM.md sin "ingen annen ikonpakke
|
||||
blandet inn"-regel, (2) `feed.tsx` sin `FeedCard` var opprinnelig ÉN stor
|
||||
`<Link>` som pakket inn HELE kortet — `PostEngagement` måtte flyttes UT av
|
||||
selve lenken (til en søskenposisjon under) for å unngå ugyldig nøstet
|
||||
interaktivt innhold og utilsiktet navigering ved klikk på en
|
||||
reaksjonsknapp.
|
||||
|
||||
**Bevisst forenklet, ikke en bug:** `public-tournament.tsx` sin
|
||||
`TournamentFeed` setter `canModerate={false}` alltid — den ekte "forfatter
|
||||
ELLER org-admin"-regelen håndheves korrekt av BACKEND uansett (bekreftet i
|
||||
autorisasjonsmatrisen under), men frontend mangler foreløpig et signal for
|
||||
"er denne innloggede brukeren org-admin i akkurat denne organisasjonen" å
|
||||
style et ekstra slett-ikon med for andres kommentarer. En org-admin som
|
||||
vil moderere andres kommentar kan det (API-et tillater det), men ser ikke
|
||||
en slett-knapp for det i UI-et ennå — en ren UI-fullstendighets-luke, ikke
|
||||
et sikkerhetshull. Kan lukkes senere ved å tilføye ett felt (f.eks. i
|
||||
tournament-info-oppslaget) uten skjemaendring.
|
||||
|
||||
**Scratch-verifisert grundig** (isolert scratch-DB/rolle fra
|
||||
`teecup_db`-mal, egen API-container): full autorisasjonsmatrise for alle
|
||||
tre innleggstyper (reager/bytt-reaksjon-som-upsert/fjern-reaksjon,
|
||||
kommentér på toppnivå, svar på en kommentar, slett en kommentar med svar
|
||||
under og bekreft kaskade, riktig avvisning per type sin faktiske
|
||||
post-/slette-regel — lag-chat kun forfatter, oppslagstavle forfatter-
|
||||
eller-org-admin via ekte org-medlemskap, rundekommentar forfatter-eller-
|
||||
rundeeier). RLS-isolasjon for `message_reaction` bekreftet eksplisitt
|
||||
(annen orgs `app.current_org` ser 0 rader). `/feed`-aggregeringen bekreftet
|
||||
å vise samme reaksjons-/kommentardata som selve rundesiden (samme
|
||||
`round_message.id` gjenbrukt). `test_isolation.sql` 12/12 uendret.
|
||||
Ekte typesjekket produksjonsbuild av frontend kjørt og bekreftet (fanget
|
||||
INGEN nye feil, ren build). **Ekte nettleserverifisering** (egen scratch-
|
||||
Caddy for sesjon/WS): reagert, byttet reaksjon (upsert bekreftet visuelt),
|
||||
postet trådet samtale (rot + svar, riktig innrykk), slettet en kommentar
|
||||
med svar og bekreftet kaskaden fjernet begge fra UI-et, bekreftet
|
||||
`feed.tsx` sin `PostEngagement` IKKE trigger navigering ved reaksjonsklikk
|
||||
(Link-nøsting-fiksen virker), bekreftet `team-chat.tsx` og
|
||||
`public-tournament.tsx` sin Oppslagstavle begge rendrer komponenten
|
||||
korrekt uten konsollfeil. Fant og rettet WS-tick-regresjonen (Beslutning
|
||||
E) i dette steget. Scratch-miljøet (DB/rolle/API-/frontend-/Caddy-
|
||||
containere/images) ryddet opp fullstendig etter bruk.
|
||||
|
||||
**Bevisst utenfor omfang:** push-varsler ved ny kommentar/reaksjon,
|
||||
sanntids-oppdatering hos ANDRE samtidige seere, fritt emoji-utvalg,
|
||||
redigering av kommentarer, myk-slett-med-plassholder.
|
||||
|
||||
---
|
||||
|
||||
## ADR-047: Leaderboard — lukk gapet mot en konkurrentapp-referanse, uten å bygge om fra bunnen
|
||||
|
||||
Brukeren viste en video av GolfGameBook sitt leaderboard (trykk-for-å-
|
||||
utvide rad → fullt hull-for-hull-scorekort inline + sosiale handlinger
|
||||
per spiller) og ba om noe minst like bra i TeeCup — eksplisitt **IKKE**
|
||||
en kopi av selve utseendet.
|
||||
|
||||
**Nøkkelfunn fra research, som endret omfanget vesentlig fra "bygg dette
|
||||
fra bunnen":** det meste av kjernemekanikken referansen viser var
|
||||
ALLEREDE bygget og live i `round-leaderboard.tsx`, fra en tidligere runde
|
||||
(FEATURE_BACKLOG.md "Leaderboard for runder og turneringer", 2026-07-26)
|
||||
som selv var inspirert av samme konkurrentapp — og allerede DA bevisst
|
||||
designet med egen form+farge-språk (sirkel/firkant, grønn/oransje) i
|
||||
stedet for å kopiere referansens fargebruk. `IndividualBoard` (dekker
|
||||
`stroke/stableford/skins/flag/bbb/copenhagen`) hadde allerede utvidbare
|
||||
rader (`IndividualRow`) med et fullt hull-for-hull-rutenett
|
||||
(`HoleByHole`). Det som faktisk manglet: (1) ingen sosial/kommentar-
|
||||
tilgang noe sted i leaderboardet, (2) ingen kompakt oppsummeringslinje
|
||||
under rutenettet, (3) `TwoSidedBoard`/`HighLowBoard` (to-sidede formater)
|
||||
hadde INGEN utvidelse i det hele tatt — kun en statisk hull-strip, (4)
|
||||
`TeamFlightBoard` hadde en flat, ikke-utvidbar råscore-liste.
|
||||
|
||||
**Avklart med bruker (AskUserQuestion, to spørsmål) FØR bygging:**
|
||||
1. **Sosial-kobling:** `post-engagement.tsx` (ADR-046) reagerer/
|
||||
kommenterer på ETT spesifikt `round_message`-innlegg, ikke runden som
|
||||
helhet — det finnes intet "innlegg per deltaker" i skjemaet (og
|
||||
GolfGameBooks egen "Kommentar til spillefeeden"-knapp åpner uansett
|
||||
samme delte feed uansett hvilken rad man trykker på). **Svar: delt
|
||||
tråd, lenket fra hver rad** — ingen ny per-deltaker-entitet, ingen ny
|
||||
migrasjon, ingen backend-endring i det hele tatt denne runden.
|
||||
2. **To-sidede formater:** siden disse kun har 2 "sider" (ikke en
|
||||
spillerliste), gir "én rad per spiller"-mønsteret ikke mening.
|
||||
**Svar: ÉN utvidbar detaljseksjon for hele kampen**, ikke et
|
||||
per-rad-mønster.
|
||||
|
||||
**Bygget, alt i `round-leaderboard.tsx` (+ to eksisterende filer fikk en
|
||||
anker-id):**
|
||||
- **`HoleByHole`** (IndividualBoard) fikk en kompakt oppsummeringslinje
|
||||
(par-sum/totalscore/plassering, egen ordlyd — IKKE referansens) + en
|
||||
"💬 Kommentarer"-tekstlenke.
|
||||
- **`TeamFlightBoard`** sin flate råscore-liste konvertert til samme
|
||||
utvidbare mønster via en ny `TeamMemberRow` (gjenbruker `HoleByHole`
|
||||
uendret, uten rangeringskolonnen — et lags medlemmer rangeres ikke seg
|
||||
imellom der).
|
||||
- **`TwoSidedBoard`/`HighLowBoard`** fikk en ny, delt
|
||||
`MatchDetailSection` — ÉN "Vis hull-for-hull med score"-knapp som,
|
||||
lazy, monterer den nå eksporterte `MatchScorecardGrid`
|
||||
(`round-scorecard.tsx`, samme ADR-045-presedens: eksporter en
|
||||
allerede-godkjent, modul-privat komponent uendret, ren
|
||||
synlighetsendring) + samme kommentar-lenke.
|
||||
- **Type-utvidelse, ikke ny data:** `ApiRoundHeader`/`ApiRoundParticipant`
|
||||
i round-leaderboard.tsx fikk flere felt lagt til (`tee_name_snapshot`,
|
||||
`guest_name`, `display_name`, `is_owner`, `playing_handicap`) — alle
|
||||
allerede returnert av samme `${apiPrefix}/{roundId}`-endepunkt
|
||||
(bekreftet mot `RoundOut`/`PublicRoundOut` i `app/routers/rounds.py`),
|
||||
bare ikke tidligere typet der. `guest_name` er valgfritt (fraværende i
|
||||
`publicMode`, siden `PublicRoundParticipantOut` ikke har feltet) — en
|
||||
ny `toGridRound()`-adapter defaulter det til `null` før
|
||||
`MatchScorecardGrid` mates, trygt siden komponenten aldri leser feltet.
|
||||
- **Anker-scroll:** `round-detail.tsx`/`watch-round.tsx` sin
|
||||
kommentarseksjon fikk `id="kommentarer"`, PLUSS en liten
|
||||
`useEffect(() => { if (round && hash === "#kommentarer")
|
||||
scrollIntoView() }, [round])` i begge — nettleserens EGEN anker-scroll
|
||||
(som skjer FØR async data er lastet) rakk ikke frem til et element som
|
||||
ennå ikke fantes i DOM-en; funnet og rettet under nettleserverifisering.
|
||||
|
||||
**Ingen ny V0-runde, ingen backend-endring.** Alt er gjenbruk/eksport av
|
||||
allerede-eksisterende, allerede godkjente komponenter/mønstre til nye
|
||||
steder — "liten justering av eksisterende komponenter med allerede
|
||||
etablerte mønstre"-unntaket fra den stående V0-arbeidsfordelingen.
|
||||
|
||||
**Bevisst forenklet, ikke en bug:** ingen kommentar-ANTALL-merke på selve
|
||||
leaderboard-lenken (unngår et ekstra API-kall for kun et tall på en
|
||||
lenke).
|
||||
|
||||
**Scratch-verifisert** (isolert DB fra `teecup_db`-mal, egen API-/
|
||||
frontend-/Caddy-container): `tsc --noEmit` rent, ekte produksjonsbuild
|
||||
kjørt og bekreftet ren. Ekte nettleser: **regresjon FØRST** —
|
||||
`IndividualBoard`s eksisterende utvidelse bekreftet identisk for
|
||||
slagspill-formatet (samme tall, samme rad-innhold) FØR noe nytt ble
|
||||
testet. Deretter: ny oppsummeringslinje + kommentar-lenke bekreftet på
|
||||
slagspill (inkl. anker-scroll faktisk fungerende, ikke bare landet på
|
||||
riktig side), ny `MatchDetailSection`/`MatchScorecardGrid`-utvidelse
|
||||
bekreftet på et fourball-format med ekte 4-spiller-data (riktig
|
||||
hull-for-hull-score, riktig løpende matchstatus "1 UP"/"AS"/"Dormie 1"),
|
||||
samme fourball-format bekreftet identisk i `publicMode` (anonym
|
||||
tilskuer-kontekst, egen isolert nettleser-kontekst — kommentar-lenken
|
||||
riktig pekende til `/watch/{id}#kommentarer` i stedet for
|
||||
`/my-rounds/...`). Ingen konsollfeil utover forventet 401 på
|
||||
`/auth/me` for den anonyme leseren (allerede tolerert i koden).
|
||||
|
||||
**Ikke direkte nettleser-testet: `TeamFlightBoard` (shamble/money_ball)
|
||||
og `HighLowBoard` (high_low_high)** — ingen eksisterende testdata for
|
||||
disse formatene i scratch-DB-malen, og å håndkonstruere gyldige
|
||||
runde+deltaker+hull-fixturer for dem fra bunnen ble vurdert som for
|
||||
tidkrevende/feilutsatt for denne verifiseringsrunden. Vurdert lav risiko
|
||||
likevel: `TeamFlightBoard`s nye `TeamMemberRow` gjenbruker `HoleByHole`
|
||||
**uendret** (samme funksjon som allerede ble browser-testet via
|
||||
`IndividualBoard`), og `HighLowBoard`s nye seksjon bruker **nøyaktig
|
||||
samme** `MatchDetailSection`/`toGridRound`/`MatchScorecardGrid`-kobling
|
||||
som allerede ble browser-testet via `TwoSidedBoard` — risikoflaten
|
||||
(prop-tredding, type-adapter, komponent-montering) er identisk, ikke en
|
||||
egen, utestet kodevei. Anbefales likevel spot-sjekket i ekte nettleser
|
||||
neste gang en runde med disse formatene finnes eller opprettes.
|
||||
|
||||
**Merk, funnet under research, IKKE rettet i denne runden:** en
|
||||
skrivefeil i eksisterende kode — `useLeaderboardData`s returnerte
|
||||
variabel heter `viewerParticipantId` men inneholder faktisk den
|
||||
INNLOGGEDE BRUKERENS `user_id` (fra `/auth/me`), ikke en
|
||||
deltaker-id. Kun en forvirrende variabelnavngiving, ikke en funksjonell
|
||||
feil (verdien er korrekt for det den faktisk brukes til alle steder,
|
||||
inkludert det nye `MatchScorecardGrid`-kallet, som nettopp forventer
|
||||
bruker-id under navnet `viewerId`) — la stå urørt, utenfor omfanget av
|
||||
denne runden.
|
||||
|
||||
**Bevisst utenfor omfang:** `RoundLeaderboardMini` (topp-3-widgeten,
|
||||
skal forbli et raskt overblikk), `ScrambleSoloResultView` (eget format,
|
||||
lite utbredt), skudd-nivå-detalj (fairway/putts/GIR) utover det
|
||||
`HoleByHole` allerede viser (den fulle scorekort-siden dekker det),
|
||||
kommentar-antall-merke på leaderboard-lenken, et separat ett-trykks
|
||||
"lik hele runden", "privat melding" (ingen DM-funksjon finnes).
|
||||
|
||||
---
|
||||
|
||||
## ADR-048: Slag-for-slag GPS-avstandsmåling
|
||||
|
||||
Reist av brukeren 2026-08-06: "Måle lenge på slag ... Fra der man er
|
||||
ELLER fra et valgt punkt på et satellittfoto, til der man står ved siden
|
||||
av ballen. Man skal også kunne si hvilken kølle man slo med. Dette kan
|
||||
deles i feeden, eller beholdes i eget grensesnitt." Avklart eksplisitt med
|
||||
bruker (AskUserQuestion, tre spørsmål, 2026-08-07): (1) v1 skal dekke
|
||||
BÅDE individuelle runder OG lagformater (fourball/scramble/foursome m.fl.,
|
||||
der hull henger av `round_side_id`), ikke bare individuell, (2)
|
||||
delingsteksten skal være auto-generert MEN redigerbar, pluss et utsnitt
|
||||
av satellittfotoet med slaget tegnet inn, (3) måling skal være tilgjengelig
|
||||
både som en knapp i scoreførings-veiviseren OG som en alltid-synlig
|
||||
merkelapp på hull-kortet (retroaktiv måling).
|
||||
|
||||
**Beslutning A — Kartleverandør: Mapbox GL JS, ikke Leaflet+Esri.** Esris
|
||||
gratis satellittlag (den vanlige gratis Leaflet-kilden) forbyr eksplisitt
|
||||
kommersiell bruk uten en separat, betalt ArcGIS-lisens — diskvalifiserende
|
||||
gitt TeeCups kommersielle retning. Mapbox: 50 000 gratis kartlastninger/
|
||||
mnd, ingen kortbinding for å starte, ~$5/1000 utover, eksplisitt tillatt
|
||||
for kommersiell bruk.
|
||||
|
||||
**Beslutning B — Kostnadskontroll, tre tiltak.** (1) ETT kart-instans per
|
||||
måle-økt (montert når "velg punkt på kart" åpnes, aldri remontert per
|
||||
tap/pan — Mapbox fakturerer per initialisering, ikke per interaksjon).
|
||||
(2) Kartet lastes KUN ved eksplisitt "velg punkt på kart" — velger
|
||||
spilleren "min posisjon nå" for BEGGE punkt, initialiseres Mapbox GL JS
|
||||
aldri (må verifiseres eksplisitt via nettverksfane under scratch-testing,
|
||||
ikke bare visuelt, se verifiseringsseksjonen for ADR-048 i CHANGELOG.md
|
||||
når den skrives). (3) Selve avstandsberegningen er ren klient-side
|
||||
Haversine (`frontend/lib/geo.ts`), aldri et Mapbox-API-kall.
|
||||
|
||||
**Beslutning C — Ny `round_shot`-tabell (migrasjon 060), IKKE en
|
||||
gjenåpning av ADR-033s avvisning av fri slag-for-slag-logging som
|
||||
hull-statistikk-DATAMODELL.** Dette er en separat, valgfri måle-funksjon;
|
||||
`round_hole` sine faste felt (`club_off_tee`, `putts` osv.) er uberørt.
|
||||
`round_shot` kjenner kun `round_hole_id` — IKKE `round_participant_id`/
|
||||
`round_side_id` direkte — og arver dermed eierskap (individuell ELLER
|
||||
lagformat) TRANSITIVT via `round_hole` sin eksisterende
|
||||
`round_participant_id`/`round_side_id`-XOR (migrasjon 031). Dette dekker
|
||||
begge hull-typer fra v1 uten egen XOR-logikk i den nye tabellen, via
|
||||
speilede API-endepunkter
|
||||
(`/participants/{id}/holes/{n}/shots` og `/sides/{id}/holes/{n}/shots`,
|
||||
samme dobbelte mønster som `RoundHoleOut`/`RoundSideHoleOut` allerede
|
||||
bruker for selve hull-scoringen). Samme non-RLS `plain_connection()`-
|
||||
mønster som resten av det frittstående-runde-subsystemet (ADR-033
|
||||
Beslutning A). `shot_number` er eksplisitt og hull-tolerant (ikke `ORDER
|
||||
BY captured_at`) — unngår at GPS-tidsstempel-drift stille endrer
|
||||
rekkefølgen; sletting midt i sekvensen etterlater et gap, ingen
|
||||
omnummerering. Kun `UPDATE (shared_round_message_id)` er grantet (ett
|
||||
smalt, eksplisitt unntak fra "slett og opprett på nytt, aldri rediger"-
|
||||
prinsippet, se Beslutning E) — ellers ingen UPDATE, samme filosofi som
|
||||
`round_message`.
|
||||
|
||||
**Beslutning D — Koordinater: rene `double precision` lat/lng-par, ikke
|
||||
PostGIS.** Første funksjon i prosjektet som lagrer koordinater i det hele
|
||||
tatt (60 migrasjoner uten presedens frem til nå). Volumet (maks noen
|
||||
titalls rader per runde) og operasjonssettet (kun punkt-til-punkt
|
||||
storsirkel-avstand, regnet klient-side) rettferdiggjør ikke en ny, tung
|
||||
geo-avhengighet.
|
||||
|
||||
**Beslutning E — Deling gjenbruker `round_message` (ADR-044) uendret, via
|
||||
en lenke.** Et nytt `POST /rounds/{id}/shots/{id}/share`-endepunkt
|
||||
genererer satellitt-utsnittet SERVER-SIDE (Mapbox Static Images API, egen
|
||||
HEMMELIG `TEECUP_MAPBOX_SECRET_TOKEN` — atskilt fra frontendens
|
||||
OFFENTLIGE, URL-restriktere `NEXT_PUBLIC_MAPBOX_TOKEN` som Mapbox GL JS
|
||||
bruker i nettleseren), laster bildet opp via eksisterende
|
||||
`storage.upload_image()`, og setter INN en vanlig `round_message`-rad —
|
||||
ingen ny feed-mekanisme. `shared_round_message_id` er en LENKE (ikke et
|
||||
bool-flagg) slik at "delt" alltid reflekterer om meldingen faktisk
|
||||
finnes (`ON DELETE SET NULL` hvis meldingen slettes separat i feeden).
|
||||
Rekkefølgen er bevisst: slaget opprettes ALLTID først (uten deling);
|
||||
deling er et frivillig, påfølgende steg — unngår at en feilet
|
||||
slag-opprettelse kan etterlate en foreldreløs feed-melding. Mapbox-kallet
|
||||
degraderer grasiøst til ren tekst uten satellittbilde ved feil/manglende
|
||||
token (samme mønster som SMTP/push i `config.py`), blokkerer ikke selve
|
||||
delingen.
|
||||
|
||||
**Backend:** `app/routers/rounds.py` fikk `ShotIn`/`ShotOut`/`ShotShareIn`
|
||||
og seks nye endepunkter (deltaker-GET/POST, side-GET/POST, DELETE, share),
|
||||
alle bygget på den eksisterende `_get_accessible_round_or_404` (samme
|
||||
"eier ELLER lenket medspiller kan føre for hele flighten"-regel som
|
||||
`update_hole`/side-PATCH allerede bruker). `_resolve_round_message_author_name`
|
||||
flyttet fra `round_messages.py` til `rounds.py` (som allerede var den
|
||||
importerte, ikke-importerende parten av de to filene) for å unngå en
|
||||
sirkulær import da `share_shot` trengte å gjenbruke den uendret.
|
||||
|
||||
**Frontend:** `frontend/lib/geo.ts` (ren Haversine-funksjon, ingen
|
||||
avhengigheter). Eksisterende kølle-plukker-mønster i `ScoringWizard`
|
||||
(round-detail.tsx) trukket ut til en delt `ClubPicker`-komponent (ren
|
||||
refaktor, bekreftet typesjekk uendret) slik at den nye måle-flyten kan
|
||||
gjenbruke nøyaktig samme pill-knapp-interaksjon. `mapbox-gl` lagt til
|
||||
(pakken har egne typedefinisjoner -- den separate `@types/mapbox-gl`-
|
||||
pakken er en utdatert stub, bevisst IKKE installert), `NEXT_PUBLIC_MAPBOX_TOKEN` tredd gjennom
|
||||
`frontend/Dockerfile` (build-tid, IKKE runtime — `NEXT_PUBLIC_`-variabler
|
||||
bakes inn i klient-bundlen ved `next build`, samme fallgruve-mønster som
|
||||
`TEECUP_API_ORIGIN` allerede dokumenterer i Dockerfile-kommentarene, men
|
||||
speilet KUN i builder-steget her siden bruken er ren klient-side) og
|
||||
`docker-compose.yml` sitt `teecup_frontend.build.args`. Selve
|
||||
`ShotMeasurementSheet`-komponenten (alle steg + kart-plassholder) går via
|
||||
én Claude-skrevet V0-prompt (standard arbeidsfordeling) — ekte Mapbox GL
|
||||
JS-integrasjon i kart-steget kobles på hånd-kodet ETTER V0-eksporten, ikke
|
||||
av V0 selv (V0-prompten ber eksplisitt om en visuell plassholder, ikke et
|
||||
ekte kartbibliotek, for å unngå at V0 genererer en selvstendig
|
||||
kart-komponent som remonterer per interaksjon og dermed bryter
|
||||
Beslutning B).
|
||||
|
||||
**Bevisst utenfor omfang:** redigering av et allerede logget slag (slett
|
||||
og mål på nytt i stedet), en historisk "vis alle slag på kart etter
|
||||
runden"-visualisering.
|
||||
|
||||
Disse må avklares før eller under de relevante fasene:
|
||||
|
||||
|
|
|
|||
745
CHANGELOG.md
|
|
@ -8006,5 +8006,746 @@ Neste steg:
|
|||
fra prosjektroten uten å bli spurt, samme rutine som alltid for merget
|
||||
V0-eksport).
|
||||
|
||||
**IKKE rullet ut mot ekte `teecup_db` i denne runden** -- venter på
|
||||
brukerens eksplisitte bekreftelse, samme rutine som alltid.
|
||||
**Rullet ut live 2026-08-06**, bruker bekreftet eksplisitt: migrasjon
|
||||
058 kjørt mot ekte `teecup_db` (`round_message`-tabellen bekreftet
|
||||
tilstede etterpå), `docker compose up -d --build teecup_api
|
||||
teecup_frontend`, begge containere boot-et rent. `/health`/`/my-rounds/
|
||||
new`/`/my-feed` → 200 over https, `GET https://teecup.golf/feed`
|
||||
bekreftet å proxye korrekt til backend (401 JSON `NOT_AUTHENTICATED`,
|
||||
IKKE frontend-sidens HTML -- bekrefter rewrite-fiksen virker i
|
||||
produksjon, ikke bare i scratch), `teeoff.no` upåvirket.
|
||||
|
||||
26. **To bugfikser, 2026-08-06, rullet ut samme dag:**
|
||||
- **"Venner på banen"-kortet lenket til feil rute.** Brukeren
|
||||
rapporterte (video vedlagt) at et klikk på en venns pågående runde
|
||||
på dashbordet ga "Denne runden finnes ikke, eller du har ikke
|
||||
tilgang til den" -- selv om runden faktisk var synlig for
|
||||
vedkommende (`GET /friends/on-course` viste den korrekt). Rotårsak:
|
||||
`dashboard.tsx` sin `LiveFriends`-komponent lenket til
|
||||
`/my-rounds/{id}` (eier/medspiller-only-visningen,
|
||||
`_get_accessible_round_or_404`) i stedet for `/watch/{id}` (den
|
||||
allerede eksisterende tredjeparts-visningen bygget for nettopp
|
||||
dette, ADR-036 fase 2). Samme feilmønster funnet OG rettet i den
|
||||
nybygde `/my-feed`-siden (punkt 25) -- `FeedCard` lenket likeens
|
||||
alltid til `/my-rounds/{id}` uansett eierskap. Begge rettet til å
|
||||
route riktig basert på om runden faktisk er brukerens egen.
|
||||
- **Kamera lot seg ikke aktivere ved bildeopplasting på mobil**, seks
|
||||
steder i appen (rundekommentarer, org-feed, avatar, lag-chat,
|
||||
turnering-hero/sponsor-bilder). Rotårsak: `<input type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif">` -- flere
|
||||
eksplisitte MIME-typer i `accept` gjør at mange mobilnettlesere kun
|
||||
tilbyr galleri, ikke kamera, i filvelgeren. Rettet til
|
||||
`accept="image/*"` alle seks steder (server-siden validerer uansett
|
||||
strengt mot `storage.ALLOWED_INPUT_CONTENT_TYPES`, så dette svekker
|
||||
ingen kontroll).
|
||||
|
||||
Begge ren frontend, ingen migrasjon. Ekte typesjekket produksjonsbuild
|
||||
kjørt før utrulling. **Rullet ut live 2026-08-06** (kun
|
||||
`teecup_frontend` bygget/restartet), `/dashboard` → 200, `teeoff.no`
|
||||
upåvirket.
|
||||
|
||||
27. **Ekte TeeCup-logo tatt i bruk som app-ikon, erstatter den midlertidige
|
||||
grønne golfflagg-placeholderen — 2026-08-06.** Brukeren lastet opp
|
||||
`TeeCup logo.svg` (ikon+ordmerke i én fil, Inkscape-eksport med
|
||||
raster-baserte masker/filtre, ikke rene vektor-tekstobjekter) og
|
||||
spurte om splitting av ikon fra tekst var mulig uten flere opplastede
|
||||
varianter.
|
||||
|
||||
**Splitting løst programmatisk, ikke manuelt:** brukte nettleserens
|
||||
`getBBox()` på hver topp-nivå-`<g>` i den innlastede SVG-en for å
|
||||
måle faktiske posisjoner -- avslørte at ikonet (ball/pokal/tee) består
|
||||
av tre atskilte grupper (x: 0–169 av 656 totalt) mens "TeeCup"-
|
||||
ordmerket er seks separate bokstav-grupper (x: 179–407) -- ingen
|
||||
gjetning nødvendig. Beskåret til et eget ikon-SVG via et rent
|
||||
`viewBox`-endring på en kopi av originalfilen (ingen gruppe fjernet
|
||||
eller XML-struktur røsket i -- alle filter-/maske-/clipPath-
|
||||
definisjoner ligger nestet INNI selve gruppene, bekreftet empirisk:
|
||||
et forsøk på å trimme bort de ubrukte tekst-gruppene for å spare
|
||||
filstørrelse brøt renderingen umiddelbart, samme "hele filen eller
|
||||
ingenting"-begrensning som Inkscape sin raster-trace-eksport gir --
|
||||
reverserte til den fullstendige, verifisert fungerende versjonen i
|
||||
stedet for å risikere en ødelagt fil for en fil-størrelse-optimering).
|
||||
`icon.svg` er dermed 416 KB (arves fra kildefilens embedded
|
||||
raster-masker) -- fungerer korrekt, men en ekte vektor-reeksport fra
|
||||
designer ville gitt en vesentlig lettere fil om ønskelig senere.
|
||||
|
||||
**Ekte nettleser brukt til rasterisering** (ingen lokal SVG->PNG-
|
||||
rasterizer tilgjengelig i miljøet): Chrome DevTools sitt
|
||||
skjermbilde-verktøy, med små HTML-wrapper-sider satt til eksakt
|
||||
mål-pikselstørrelse per format, generert alle seks filene appen
|
||||
faktisk bruker: `icon.svg` (transparent, favicon), `icon-light-
|
||||
32x32.png`/`icon-dark-32x32.png` (transparent, samme innhold --
|
||||
fungerer i begge fargetema), `apple-icon.png` (180×180, opak hvit
|
||||
bakgrunn -- iOS støtter ikke transparens), `icons/icon-192.png` og
|
||||
`icons/icon-512.png` (PWA-manifest, opak hvit bakgrunn), `icons/
|
||||
icon-maskable-512.png` (ikonet holdt innenfor sentrale ~63 % --
|
||||
trygt innenfor W3C sin ~80 %-sikre-sone for maskable-ikoner som
|
||||
OS-en klipper med egen maske). Hvert format visuelt bekreftet
|
||||
(`Read` på hver genererte PNG) før plassering.
|
||||
|
||||
Hele ordmerket (ikon+"TeeCup"-tekst) lagret uendret som `public/
|
||||
teecup-wordmark.svg` for senere bruk (f.eks. innloggingsside),
|
||||
ikke koblet inn noe sted ennå -- kun selve app-ikon-oppgaven løst nå.
|
||||
`app/manifest.ts` sin kommentar oppdatert til å reflektere at
|
||||
placeholder-fasen er over.
|
||||
|
||||
Ren frontend, ingen migrasjon. Ekte typesjekket produksjonsbuild
|
||||
kjørt. **Rullet ut live 2026-08-06** (kun `teecup_frontend`),
|
||||
`/icon.svg`/`/apple-icon.png`/`/icons/icon-512.png` → 200 over https,
|
||||
`teeoff.no` upåvirket.
|
||||
|
||||
28. **Tilskuer-visning (`/watch/[id]`): tre faner + "Spillere og runde",
|
||||
full paritet med eiersiden (ADR-045) — BYGGET OG SCRATCH-VERIFISERT
|
||||
2026-08-06, venter på utrulling.** Direkte oppfølging av punkt 26 sin
|
||||
bug (feil lenke fra "Venner på banen") -- brukeren ba samtidig om at
|
||||
selve tilskuer-siden (fram til da ÉN enkelt side, kun kompakt status)
|
||||
skulle få samme tre faner som eiersiden (Score/Scorekort/Leaderboard)
|
||||
og "Spillere og runde" (deltakerliste, HCP/utslag/tildelte slag,
|
||||
flight-gruppering), uten eier-kun-handlingene. Bekreftet med bruker
|
||||
(AskUserQuestion, to spørsmål): full formatspesifikk detalj på
|
||||
Scorekort-fanen (ikke en forenklet fellesvisning), og
|
||||
flight-gruppering bygget nå (ikke utsatt).
|
||||
|
||||
**Nøkkelbeslutning, funnet under research (Explore-agent + egen
|
||||
lesning av begge filene):** `round-scorecard.tsx` (`RoundScorecard`)
|
||||
og `round-leaderboard.tsx` (`RoundLeaderboard`) sin faktiske rutenett-
|
||||
/tavle-rendring var ALLEREDE ren, skrivefri visning -- redigering
|
||||
skjer et helt annet sted. Det eneste som hindret gjenbruk for
|
||||
tilskuere var at alle interne data-hentinger (fire hooks/effekter
|
||||
per fil, pluss WS-tilkoblingen) hardkodet `/rounds/*`-prefikset. I
|
||||
stedet for å bygge nye, forenklede tilskuer-komponenter (ville gitt
|
||||
UFULLSTENDIG formatparitet eller duplisert ~1000+ linjer formatlogikk),
|
||||
la til en valgfri `publicMode`-prop på begge de EKSISTERENDE
|
||||
komponentene -- default `false` (INGEN endring i eiersidens
|
||||
oppførsel), `true` bytter alle interne URL-er til `/public/rounds/*`
|
||||
og WS-kanalen til den offentlige. Samme kode, to datakilder, ekte
|
||||
full formatparitet (alle 17 formater) uten duplisert vedlikehold.
|
||||
|
||||
**Backend:** ett nytt endepunkt, `GET /public/rounds/{id}/
|
||||
flight-group` (`app/routers/rounds.py`) -- speiler den eksisterende
|
||||
autentiserte varianten, men med en ny `_viewable_flight_rows`
|
||||
(bruker `_can_view_round` PER søsken-flight uavhengig, siden en
|
||||
flight-gruppe kan ha søsken med ulik `visibility_mode`). Ingen
|
||||
migrasjon.
|
||||
|
||||
**Frontend, ny fil-for-fil:** `watch-tabs.tsx` (ny, liten fane-stripe
|
||||
-- BEVISST ikke en gjenbruk av `RoundHeader`, som alltid render et
|
||||
"Administrer"-tannhjul/admin-dialog uansett hvilke handlere som gis
|
||||
inn -- den nye komponenten inneholder rett og slett ingen slik kode).
|
||||
`watch-players.tsx` (ny "Spillere og runde", IKKE et forsøk på å
|
||||
gjøre `round-detail.tsx` sin lokale, ikke-eksporterte `PlayerList`
|
||||
gjenbrukbar via `canManage=false` -- egen, read-only-only komponent,
|
||||
ingen +Medspiller-/Rediger-/Fullfør-/Slett-kode i det hele tatt).
|
||||
`watch-round.tsx` omskrevet: Score-fanen viser nå fane-stripen +
|
||||
kompakt status + "Spillere og runde" -- selve matchstatus-/skins-/
|
||||
slagspill-visningen FLYTTET til den nye Leaderboard-fanen (erstattet
|
||||
av `RoundLeaderboard publicMode`, som dekker alle formater -- lukker
|
||||
et eksisterende gap der den gamle `watch-round.tsx` sin lokale
|
||||
`TWO_SIDED_FORMATS`-liste kun dekket 8 av 17 formater). To nye tynne
|
||||
ruter, `/watch/[id]/scorecard` og `/watch/[id]/leaderboard`
|
||||
(`watch-scorecard.tsx`/`watch-leaderboard.tsx`, hver bare fane-stripe
|
||||
+ `RoundScorecard`/`RoundLeaderboard` med `embedded publicMode`).
|
||||
|
||||
**Verifisering, samme scratch-Caddy-disiplin som ADR-044:** 6/6
|
||||
håndregnede sjekker for det nye flight-group-endepunktet (anonym ser
|
||||
kun offentlige søsken-flighter, venn med riktig kategori ser i
|
||||
tillegg `friends`-synlige, eier ser alle, fremmed uten vennskap ser
|
||||
kun offentlige, privat anker-runde avvist helt). `tsc --noEmit` rent
|
||||
på hele frontend-prosjektet. Ekte nettleser: **regresjon FØRST** --
|
||||
eiersidens `/my-rounds/{id}/scorecard`/`/leaderboard` bekreftet
|
||||
PIKSEL-IDENTISK (samme tall, samme layout) FØR noe nytt ble testet.
|
||||
Deretter tilskuer-sidene: alle tre faner, "Spillere og runde" uten
|
||||
eier-knapper, ETT allerede-dekket format (`stroke`) OG ETT tidligere
|
||||
udekket format (`flag`) begge bekreftet rendret korrekt gjennom
|
||||
`/watch/{id}/leaderboard` -- beviser formatparitet-lukkingen direkte.
|
||||
En privat rundes tilskuer-sider (alle tre) bekreftet korrekt avvist
|
||||
for en ANONYM leser i en egen, isolert nettleser-kontekst (ikke bare
|
||||
et API-kall) med riktig feilmelding og "Tilbake til dashbordet"-
|
||||
lenke. `test_isolation.sql` uendret 12/12. Scratch-miljøet (DB/rolle/
|
||||
MinIO/API-/frontend-/Caddy-containere og -images) ryddet opp
|
||||
fullstendig etter bruk.
|
||||
|
||||
**Rullet ut live 2026-08-06**, bruker bekreftet eksplisitt: `docker
|
||||
compose up -d --build teecup_api teecup_frontend` (ingen migrasjon),
|
||||
begge containere boot-et rent. `/health` → 200, `/dashboard` → 200,
|
||||
nytt endepunkt bekreftet (`/public/rounds/{tilfeldig-id}/flight-group`
|
||||
→ 404 for en ikke-eksisterende runde), alle tre nye/endrede
|
||||
`/watch/[id]`-ruter → 200, `teeoff.no` upåvirket.
|
||||
|
||||
29. **Bugfiks: kommentarer/bilder (ADR-044) fantes ikke i tilskuer-
|
||||
visningen — 2026-08-06.** Brukeren rapporterte at en person en runde
|
||||
er delt med ikke kunne se selve samtalen. Rotårsak: `RoundMessages`
|
||||
ble aldri montert på `/watch/[id]` -- kun på `round-detail.tsx` sin
|
||||
egen Score-fane (eier/deltaker). Backend var allerede riktig
|
||||
(`GET`/`POST /rounds/{id}/messages` bruker `_get_viewable_round_or_
|
||||
404`, synlighets-gatet, ikke eier/deltaker-only, siden ADR-044 ble
|
||||
bygget) -- ren frontend-mangel.
|
||||
|
||||
**Én reell backend-mangel funnet underveis:** `PublicRoundOut`
|
||||
eksponerte `owner_display_name` men ALDRI eierens faktiske
|
||||
`user_id` -- `RoundMessages` sin "forfatter ELLER rundeeier kan
|
||||
slette"-sjekk (`roundOwnerUserId === currentUserId`) hadde dermed
|
||||
ingen korrekt verdi å sammenligne mot fra tilskuer-siden. Lagt til
|
||||
`owner_user_id: str` på `PublicRoundOut` (samme personvernsnivå som
|
||||
det allerede eksponerte visningsnavnet -- en ugjennomsiktig UUID
|
||||
avslører ingenting nytt).
|
||||
|
||||
**Rettet:** `watch-round.tsx` monterer nå `<RoundMessages>` på
|
||||
Score-fanen, med `roundOwnerUserId={round.owner_user_id}` og
|
||||
`currentUserId` fra en ny `/auth/me`-henting (tolerant for anonym,
|
||||
samme mønster som resten av appen).
|
||||
|
||||
**Verifisert, isolert scratch-miljø:** 6/6 håndregnede sjekker
|
||||
(`/public/rounds/{id}` eksponerer korrekt `owner_user_id`, en venn
|
||||
kan poste via synlighet -- ikke eier/deltaker, begge kan lese, eier
|
||||
kan moderere/slette vennens innlegg). `tsc --noEmit` rent. Ekte
|
||||
nettleser: kommentarseksjonen bekreftet synlig på `/watch/{id}`,
|
||||
ingen konsollfeil utover forventet 401 på `/auth/me` for en anonym
|
||||
leser (allerede tolerert i koden). Scratch-miljøet ryddet opp.
|
||||
|
||||
30. **Bugfiks: kamera manglet helt ved bildeopplasting, ekte "prøv igjen"-
|
||||
feil skjult bak en generisk melding — 2026-08-06.** Brukeren
|
||||
rapporterte (skjermdump fra ekte enhet, Pixel 8 Pro, TeeCup installert
|
||||
som PWA i Chrome) at "Legg til bilde" ALDRI viste noe kamera-
|
||||
alternativ, kun galleri -- og at selve posten deretter feilet med
|
||||
"Kunne ikke poste kommentaren. Prøv igjen." uten noen forklaring.
|
||||
|
||||
**To atskilte, reelle rotårsaker, begge bekreftet:**
|
||||
1. Siden Android 13 bruker Chrome (og andre nettlesere) systemets
|
||||
eget "Photo Picker" for `<input type=file accept="image/*">` UTEN
|
||||
`capture`-attributt -- denne velgeren viser KUN galleri, ALDRI et
|
||||
kamera-alternativ, uansett hvilken `accept`-verdi som er satt
|
||||
(den tidligere fiksen, `accept="image/*"` alene, var altså
|
||||
nødvendig, men ikke tilstrekkelig for Android 13+). Rettet ved å
|
||||
splitte den ene filvelgeren i to atskilte inputs/knapper -- én
|
||||
med `capture="environment"` (rett til kamera), én uten (rett til
|
||||
galleri) -- i `round-messages.tsx`, `team-chat.tsx`, og
|
||||
`public-tournament.tsx` sin `TournamentFeed` (de tre stedene som
|
||||
faktisk brukes aktivt for bildeopplasting fra mobil; `account-
|
||||
settings.tsx`/`tournament-presentation.tsx` sine engangs-avatar-/
|
||||
hero-bilde-opplastinger har fortsatt kun galleri -- lavere
|
||||
prioritet, tas senere om ønskelig).
|
||||
2. Selve feilmeldingen ved en mislykket post var en blindt generisk
|
||||
`catch { setPostError("Kunne ikke poste... Prøv igjen.") }` --
|
||||
den faktiske, spesifikke backend-feilen (f.eks. "Bildet er for
|
||||
stort (maks 8 MB)") ble aldri lest fra svaret og dermed aldri
|
||||
vist. Rettet til å faktisk lese `{"detail":{"code","message"}}`-
|
||||
kontrakten (samme form som resten av API-et, `app/errors.py`) og
|
||||
vise den ekte meldingen, med en generisk norsk fallback kun hvis
|
||||
svaret mot formodning ikke er JSON. Lagt til et klient-side
|
||||
størrelsessjekk (samme 8 MB-grense som `storage.MAX_UPLOAD_BYTES`)
|
||||
for umiddelbar, presis feilmelding uten en unødvendig tur-retur
|
||||
til serveren for noe som uansett ville blitt avvist -- sannsynlig
|
||||
den FAKTISKE årsaken til brukerens opprinnelige feil, siden
|
||||
moderne telefonkamera-bilder lett kan overstige 8 MB.
|
||||
|
||||
**Oppfølging samme dag:** brukeren spurte -- helt riktig -- om selve
|
||||
8 MB-grensen egentlig var et praktisk problem, siden alle bilder
|
||||
uansett konverteres til AVIF (mye mindre) før lagring. Bekreftet i
|
||||
`app/storage.py`: `MAX_UPLOAD_BYTES`-sjekken skjer på RÅ input, FØR
|
||||
konverteringen -- den beskytter altså ikke lagringsstørrelsen (det
|
||||
gjør AVIF-steget, uavhengig av inputstørrelse), den var bare en
|
||||
vilkårlig øvre grense som kunne avvise et helt normalt telefonbilde
|
||||
før det fikk sjansen til å konverteres ned. Hevet til 20 MB (server-
|
||||
siden `app/storage.py` OG de tre nye klient-side sjekkene over,
|
||||
samt feilteksten) -- rikelig for moderne telefonkamera-JPEG-er,
|
||||
fortsatt en reell grense mot noe genuint urimelig stort.
|
||||
|
||||
Ren frontend + denne ene backend-konstanten, ingen migrasjon.
|
||||
`tsc --noEmit` rent, `py_compile` rent. **Rullet ut live 2026-08-06**,
|
||||
bruker bekreftet eksplisitt: `docker compose up -d --build teecup_api
|
||||
teecup_frontend`, begge containere boot-et rent. `/health` → 200,
|
||||
`/dashboard` → 200, `teeoff.no` upåvirket.
|
||||
|
||||
31. **Kommentarstrømmen på en runde sortert om: nyeste øverst — 2026-08-06.**
|
||||
Brukeren ba om dette rett etter forrige punkt -- `round_message`-
|
||||
listen (`round-messages.tsx`) var kronologisk eldst-først (samme
|
||||
mønster som en vanlig chat), brukeren ville ha nyeste øverst i
|
||||
stedet (en oppdateringsstrøm, ikke en samtale man leser fra start).
|
||||
Endret `ORDER BY created_at` → `created_at DESC` i `GET /rounds/{id}/
|
||||
messages` (`app/routers/round_messages.py`), og en ny post legges nå
|
||||
øverst i listen lokalt (`[created, ...prev]` i stedet for `[...prev,
|
||||
created]`). Lag-chatten (`messaging.py`) og org-oppslagstavlen
|
||||
(`public-tournament.tsx`) er BEVISST uendret -- en ekte samtale
|
||||
leses fortsatt kronologisk.
|
||||
|
||||
Verifisert i isolert scratch-miljø (postet tre meldinger, bekreftet
|
||||
`GET`-rekkefølgen er nyeste-først). `tsc`/`py_compile` rent.
|
||||
**Rullet ut live 2026-08-06**, bruker bekreftet eksplisitt (samme
|
||||
utrulling som punkt 30). `teeoff.no` upåvirket.
|
||||
|
||||
32. **"Nyeste først" utvidet til ALLE meldingsstrømmer — 2026-08-06,
|
||||
overstyrer punkt 31 sin "bevisst uendret"-begrunnelse.** Brukeren:
|
||||
"Nyeste først. Over alt." -- en eksplisitt, direkte instruks om at
|
||||
lag-chatten og org-oppslagstavlen (som punkt 31 bevisst lot forbli
|
||||
kronologiske, med en UX-begrunnelse om at "en ekte samtale leses
|
||||
fortsatt kronologisk") OGSÅ skulle bli nyeste-først, på tvers av min
|
||||
egen tidligere vurdering.
|
||||
|
||||
Endret `ORDER BY created_at` → `created_at DESC` for BÅDE
|
||||
`list_team_messages` og `get_feed` (`app/routers/messaging.py`).
|
||||
`team-chat.tsx`: WS-mottak og lokal post-oppdatering endret fra
|
||||
append (`[...prev, msg]`) til prepend (`[msg, ...prev]`); fjernet
|
||||
`bottomRef`-baserte auto-scroll-til-bunn-`useEffect`en (unødvendig
|
||||
når nyeste alltid er øverst, synlig uten scrolling).
|
||||
`public-tournament.tsx` sin `TournamentFeed`: samme prepend-endring i
|
||||
BÅDE WS-mottak og `send()`.
|
||||
|
||||
Verifisert i isolert scratch-DB (fra `teecup_db`-mal): satte inn tre
|
||||
meldinger med forskjøvne tidsstempler direkte i `message`-tabellen
|
||||
for både `scope='team'` og `scope='tournament_feed'`, kjørte de
|
||||
EKSAKTE SELECT-spørringene fra koden, bekreftet nyeste-først-
|
||||
rekkefølge for begge. `tsc --noEmit`/`py_compile` rent.
|
||||
`test_isolation.sql` uendret 12/12. Scratch-DB/rolle ryddet opp.
|
||||
|
||||
**Rullet ut live 2026-08-06**, bruker bekreftet eksplisitt ("Ja
|
||||
takk."): `docker compose up -d --build teecup_api teecup_frontend`,
|
||||
begge containere boot-et rent (ingen migrasjon). `teeoff.no`
|
||||
upåvirket.
|
||||
|
||||
33. **Emoji-reaksjoner + trådede kommentarer på innlegg — 2026-08-06, se
|
||||
ADR-046 for full begrunnelse/beslutningsdetalj.** Samme melding som
|
||||
punkt 32 ("... Husk også at man skal kunne like (eller bruke andre
|
||||
emojier) og kommentere på innlegg."). Migrasjon 059
|
||||
(`round_message_reaction`/`_comment`, `message_reaction`/`_comment`
|
||||
-- to tabellpar, ett uten RLS speiler `round_message`, ett RLS'et
|
||||
speiler `message`). Nye endepunkter i `round_messages.py` og
|
||||
`messaging.py`: `PUT`/`DELETE .../reaction` (upsert, ett fast
|
||||
kuratert emoji-sett, én reaksjon per bruker per innlegg), `GET`/
|
||||
`POST .../comments` + `DELETE .../comments/{id}` (flat lagring,
|
||||
frontend bygger tre-strukturen, kaskade-sletting av svar).
|
||||
Autorisasjon speiler hver innleggstypes EKSISTERENDE post-/
|
||||
slette-regler uendret (ingen ny modell).
|
||||
|
||||
**Én reell regresjon funnet og rettet FØR utrulling, under ekte
|
||||
nettleserverifisering (ikke fanget av API-testskriptet alene):** de
|
||||
nye kommentar-endepunktene i `round_messages.py` kalte først
|
||||
`broadcast_round_update()` ved en inkonsekvens mot egen plan (planen
|
||||
sa eksplisitt ingen live-push for kommentarer/reaksjoner i v1) --
|
||||
dette trigget rundens eksisterende WS-tick, som fikk `RoundMessages`
|
||||
til å refetche HELE meldingslisten og med det kollapse enhver
|
||||
allerede-utvidet kommentartråd andre steder på siden ved hver eneste
|
||||
kommentarhandling. Fjernet fra de nye endepunktene, bekreftet rettet
|
||||
ved re-test (tråd forble utvidet gjennom en påfølgende reaksjon/
|
||||
kommentar).
|
||||
|
||||
**Frontend:** ny delt `post-engagement.tsx` (via ÉN Claude-skrevet
|
||||
V0-prompt), integrert i `round-messages.tsx`, `feed.tsx`
|
||||
(`FeedCard`), `team-chat.tsx`, `public-tournament.tsx`
|
||||
(`TournamentFeed`). To integrasjonsjusteringer utover ren copy-inn:
|
||||
byttet V0-eksportens egendefinerte SVG-ikoner til `lucide-react`
|
||||
(DESIGN_SYSTEM.md sin "ingen annen ikonpakke"-regel), og flyttet
|
||||
`PostEngagement` UT av `feed.tsx` sin `FeedCard`-`<Link>` (var
|
||||
opprinnelig nøstet inni hele kort-lenken -- ville trigget navigering
|
||||
ved reaksjonsklikk og vært ugyldig nøstet interaktivt innhold).
|
||||
|
||||
**Kjent, bevisst forenkling (ikke en bug):** `public-tournament.tsx`
|
||||
sin Oppslagstavle setter `canModerate={false}` alltid i UI-et --
|
||||
backend håndhever fortsatt korrekt "forfatter ELLER org-admin"
|
||||
uansett, men en org-admin ser ikke en slett-knapp for ANDRES
|
||||
kommentarer der ennå (ren UI-fullstendighets-luke, ikke et
|
||||
sikkerhetshull).
|
||||
|
||||
**Scratch-verifisert grundig, to runder:** (1) API-nivå -- full
|
||||
autorisasjonsmatrise for alle tre innleggstyper (upsert-reaksjon
|
||||
bekreftet ikke-stablende, tråding, kaskade-sletting, riktig
|
||||
avvisning per type sin faktiske regel, RLS-isolasjon for
|
||||
`message_reaction` eksplisitt bekreftet), `test_isolation.sql`
|
||||
12/12, ekte produksjonsbuild av frontend kjørt og bekreftet ren.
|
||||
(2) Ekte nettleser (egen scratch-Caddy for sesjon/WS) -- reagert,
|
||||
byttet reaksjon, postet trådet samtale, slettet med kaskade-
|
||||
bekreftelse, bekreftet `feed.tsx` sin Link-nøsting-fiks IKKE
|
||||
trigger navigering, bekreftet komponenten rendrer korrekt i alle
|
||||
fire flater uten konsollfeil. Begge scratch-miljøer (DB/rolle/API-/
|
||||
frontend-/Caddy-containere/images) ryddet opp fullstendig.
|
||||
|
||||
**Rullet ut live 2026-08-06**, bruker bekreftet eksplisitt ("Ja
|
||||
takk"): migrasjon 059 kjørt mot ekte `teecup_db` (fire nye tabeller,
|
||||
additiv, ingen endring av eksisterende data), deretter `docker
|
||||
compose up -d --build teecup_api teecup_frontend`. Begge containere
|
||||
boot-et rent. Bekreftet via live `openapi.json`: alle ni nye ruter
|
||||
(`.../reaction` × 3, `.../comments` × 3, `.../comments/{id}` × 3)
|
||||
riktig registrert og nåbare. `teeoff.no` upåvirket.
|
||||
|
||||
34. **Leaderboard lukket gapet mot en konkurrentapp-referanse — 2026-08-06,
|
||||
se ADR-047 for full begrunnelse/beslutningsdetalj.** Brukeren viste en
|
||||
video av GolfGameBook sitt leaderboard (trykk-for-å-utvide rad → fullt
|
||||
hull-for-hull-scorekort + sosiale handlinger per spiller), ba om noe
|
||||
minst like bra i TeeCup -- eksplisitt IKKE en kopi av utseendet.
|
||||
Research avdekket at kjernemekanikken (utvidbare rader med
|
||||
hull-for-hull-rutenett, egen form+farge-språk) allerede var bygget og
|
||||
live for `IndividualBoard` siden 2026-07-26 -- denne runden lukket de
|
||||
reelle gapene i stedet for å bygge om fra bunnen: ingen sosial-tilgang
|
||||
noe sted i leaderboardet, ingen kompakt oppsummeringslinje,
|
||||
`TwoSidedBoard`/`HighLowBoard` (to-sidede formater) hadde INGEN
|
||||
utvidelse i det hele tatt, `TeamFlightBoard` hadde en flat,
|
||||
ikke-utvidbar råscore-liste.
|
||||
|
||||
Avklart eksplisitt med bruker (AskUserQuestion) før bygging: (1)
|
||||
`post-engagement.tsx` (ADR-046) reagerer på ETT spesifikt innlegg, ikke
|
||||
runden -- alle utvidede rader lenker derfor til den SAMME, allerede
|
||||
eksisterende kommentarseksjonen på rundesiden i stedet for en
|
||||
(ikke-eksisterende) tråd per spiller. (2) To-sidede formater fikk ÉN
|
||||
utvidbar detaljseksjon for hele kampen, ikke et per-rad-mønster (kun 2
|
||||
sider, ikke en spillerliste).
|
||||
|
||||
Alt i `frontend/components/round-leaderboard.tsx` + anker-id på
|
||||
kommentarseksjonen i `round-detail.tsx`/`watch-round.tsx`: `HoleByHole`
|
||||
fikk en oppsummeringslinje + "Kommentarer"-lenke; `TeamFlightBoard`
|
||||
sin råscore-liste konvertert til utvidbare rader (ny `TeamMemberRow`,
|
||||
gjenbruker `HoleByHole` uendret); `TwoSidedBoard`/`HighLowBoard` fikk
|
||||
en ny, delt `MatchDetailSection` som lazy-monterer den nå eksporterte
|
||||
`MatchScorecardGrid` (`round-scorecard.tsx`, samme ADR-045-presedens).
|
||||
Ingen ny V0-runde, INGEN backend-endring -- ren gjenbruk/eksport av
|
||||
allerede godkjente komponenter.
|
||||
|
||||
**Funnet og rettet under nettleserverifisering:** nettleserens egen
|
||||
anker-scroll (`#kommentarer`) rakk ikke frem siden elementet ikke
|
||||
fantes i DOM-en før async rundedata var lastet -- løst med en liten
|
||||
`useEffect` som trigger scrollet på nytt når dataen ankommer.
|
||||
|
||||
**Verifisert:** `tsc --noEmit` rent, ekte produksjonsbuild rent.
|
||||
Isolert scratch-miljø, ekte nettleser: regresjon FØRST (slagspill-
|
||||
formatets eksisterende utvidelse identisk før noe nytt ble testet),
|
||||
deretter ny oppsummeringslinje+lenke (inkl. faktisk fungerende
|
||||
anker-scroll) og ny `MatchDetailSection`/`MatchScorecardGrid`-utvidelse
|
||||
bekreftet på et fourball-format med ekte 4-spiller-data, samme format
|
||||
bekreftet identisk i `publicMode` (anonym tilskuer, egen isolert
|
||||
nettleser-kontekst, kommentar-lenken riktig pekende til
|
||||
`/watch/{id}#kommentarer`). `TeamFlightBoard`/`HighLowBoard` ikke
|
||||
direkte browser-testet (ingen testdata for disse formatene i
|
||||
scratch-DB-malen) -- vurdert lav risiko siden de gjenbruker nøyaktig
|
||||
samme, allerede-testede kodeveier (`HoleByHole` uendret;
|
||||
`MatchDetailSection`/`toGridRound`/`MatchScorecardGrid` uendret), se
|
||||
ADR-047 for full begrunnelse.
|
||||
|
||||
**Egen feil funnet og rettet underveis, verdt å notere:** det første
|
||||
forsøket på scratch-frontend-bygg pekte ved en kopier-lim-feil
|
||||
`TEECUP_API_ORIGIN` mot den EKTE produksjons-API-en (`teecup_api`) i
|
||||
stedet for scratch-containeren -- oppdaget da innlogging som en
|
||||
scratch-testbruker konsekvent feilet. All interaksjon i det vinduet
|
||||
var lesing (sidevisning/rad-utvidelse, ingen skriving/mutasjon), men
|
||||
bildet ble likevel bygget på nytt med riktig scratch-API-peker og HELE
|
||||
nettleser-verifiseringen kjørt om igjen fra bunnen mot den korrekt
|
||||
isolerte stacken før noe ble konkludert.
|
||||
|
||||
Ren frontend, ingen migrasjon. **Rullet ut live 2026-08-06**, bruker
|
||||
bekreftet eksplisitt ("Ja takk"): `docker compose up -d --build
|
||||
teecup_frontend` (Compose gjenskapte også `teecup_api`-containeren i
|
||||
samme kommando -- ingen kodeendring der denne runden, kun en ren
|
||||
restart). Begge containere boot-et rent, `/health` → 200. `teeoff.no`
|
||||
upåvirket.
|
||||
|
||||
35. **Reaksjonsraden viste kun et antall, ikke HVEM som reagerte —
|
||||
2026-08-07.** Brukeren, rett etter å ha godkjent V0-eksperimentet på
|
||||
`/logg-inn`: "Vi kan ikke se HVEM som liker noe i feeden. Det må på
|
||||
plass." En reell, riktig funnet mangel i ADR-046-bygget fra dagen
|
||||
før.
|
||||
|
||||
Løst uten noen ny tur-retur til serveren: `ReactionSummary` fikk et
|
||||
nytt felt `reactors: list[str]` (fulle navn, roster-kontekst --
|
||||
CLAUDE.md sin navneformat-regel), fylt i SAMME grupperte batch-
|
||||
spørring som allerede beregnet antallet (`array_agg(...) ORDER BY
|
||||
created_at`), i både `round_messages.py` (JOIN `app_user`, samme
|
||||
"fornavn+etternavn eller display_name"-fallback som
|
||||
`_resolve_round_message_author_name`) og `messaging.py` (LEFT JOIN
|
||||
`player` for org-spesifikt visningsnavn, samme mønster som
|
||||
`_resolve_author_display_name` -- fallback til e-postens lokaldel).
|
||||
|
||||
`post-engagement.tsx`: et alltid-synlig sammendrag under
|
||||
reaksjonsraden ("Kari Nordmann og 3 andre reagerte" --
|
||||
`formatReactorSummary()`, ALDRI kun synlig ved hover/tap alene,
|
||||
tilgjengelighetsregelen), utvidbart (44px trykkflate) til en full
|
||||
liste gruppert per emoji. Ingen ny henting -- dataen var allerede i
|
||||
`reactionRow`.
|
||||
|
||||
**Scratch-verifisert:** isolert DB fra `teecup_db`-mal, egen API-
|
||||
container. Bekreftet for begge tabellpar (`round_message_reaction`:
|
||||
én reaksjon → riktig navn; `message_reaction`, org-scopet: to
|
||||
brukere reagerer med samme emoji → begge navn i riktig rekkefølge,
|
||||
org-spesifikt spillernavn brukt, ikke kontoens globale navn).
|
||||
`tsc --noEmit`/`py_compile` rent. Ekte nettleser: sammendragslinjen
|
||||
og den utvidbare per-emoji-listen begge bekreftet fungerende, ingen
|
||||
konsollfeil. Scratch ryddet opp fullstendig.
|
||||
|
||||
Ren tillegg til eksisterende `ReactionSummary`-kontrakt (ingen
|
||||
migrasjon, ingen brytende endring) + `post-engagement.tsx`.
|
||||
|
||||
**Rullet ut live 2026-08-07**, bruker bekreftet eksplisitt ("Ja
|
||||
takk"): `docker compose up -d --build teecup_api teecup_frontend`.
|
||||
Begge containere boot-et rent, `/health` → 200. `teeoff.no`
|
||||
upåvirket.
|
||||
|
||||
36. **"Ingen"/"Alle"-hurtigknapp for venne-kategori-velgeren i
|
||||
rundedeling — 2026-08-07.** Brukeren: "Der man, i et rundeoppsett,
|
||||
velger hvem man skal dele runden med, så bør det være en 'bryter'
|
||||
med 'Ingen (helt privat)' eller 'Alle'. Deretter velger eller
|
||||
fravelger man etter det som passer en best." Avklart eksplisitt med
|
||||
bruker (AskUserQuestion) hvilket av to funn passet: Privat/Venner/
|
||||
Offentlig-hovedvalget forblir uendret (3-veis, uendret semantikk);
|
||||
hurtigknappen legges TIL kategori-pillisten som vises når "Venner"
|
||||
er valgt.
|
||||
|
||||
Fant samtidig et reelt, eksisterende avvik mellom de to stedene
|
||||
denne velgeren finnes: opprettelses-veiviseren (`new-round.tsx`)
|
||||
forhåndsvelger alle 10 kategoriene med opt-out-ordlyd, mens
|
||||
rediger-dialogen (`round-detail.tsx`) verken har noen hurtigknapp
|
||||
eller noe "ingen valgt"-varsel i det hele tatt (kun veiviseren hadde
|
||||
det varselet fra før). Begge fikk nå identisk `Pill`-/knapp-basert
|
||||
"Ingen"/"Alle" (setter kategorisettet til tomt/alle ti, finjusteres
|
||||
deretter med enkeltpiller som før) OG samme "ingen kategori valgt"-
|
||||
varsel -- lukker det avviket som en naturlig del av samme endring,
|
||||
ikke en egen runde.
|
||||
|
||||
Ren frontend, ingen migrasjon, ingen ny backend-logikk (API-et tok
|
||||
allerede imot tomme/fulle `visible_categories`-lister uendret).
|
||||
`tsc --noEmit` rent. Scratch-verifisert i ekte nettleser for
|
||||
rediger-dialogen (isolert DB-mal, egen API-/frontend-/Caddy-
|
||||
container): "Ingen" tømmer alle ti pillene og viser varselet,
|
||||
"Alle" fyller alle ti, begge fremhever seg selv korrekt når
|
||||
tilstanden allerede matcher. Veiviserens versjon (identisk kode-
|
||||
mønster, samme delte `Pill`-komponent) bekreftet via kodegjennomgang
|
||||
i stedet for full klikk-gjennom (banesøk mot ekte teeoff-oppslag
|
||||
gjorde en full E2E-kjøring uforholdsmessig tidkrevende for en
|
||||
kodemessig identisk endring). Scratch ryddet opp fullstendig.
|
||||
|
||||
**Rullet ut live 2026-08-07**, bruker bekreftet ("ja"): `docker
|
||||
compose up -d --build teecup_frontend`. Begge containere boot-et
|
||||
rent, `/health` → 200. `teeoff.no` upåvirket.
|
||||
|
||||
37. **Slag-for-slag GPS-avstandsmåling — backend + skjema, 2026-08-07, se
|
||||
ADR-048 for full begrunnelse/beslutningsdetalj.** Brukeren: "Måle
|
||||
lenge på slag ... Fra der man er ELLER fra et valgt punkt på et
|
||||
satellittfoto, til der man står ved siden av ballen. Man skal også
|
||||
kunne si hvilken kølle man slo med. Dette kan deles i feeden."
|
||||
Formell planleggingsrunde kjørt (Explore-agenter for
|
||||
`round_hole`-skjema/scoreførings-UI/`BAG_CLUBS`/geolocation-bruk,
|
||||
Plan-agent for konkret design), etterfulgt av AskUserQuestion for tre
|
||||
gjenstående valg: v1 dekker BÅDE individuelle runder OG lagformater
|
||||
fra start, delingstekst er auto-generert-men-redigerbar PLUSS et
|
||||
satellitt-utsnitt av slaget, og måling er tilgjengelig både i
|
||||
scoreførings-veiviseren og som en retroaktiv hull-kort-handling.
|
||||
|
||||
Migrasjon `060_round_shot.sql`: ny tabell, kjenner kun
|
||||
`round_hole_id` (arver eierskap individuell/lagformat transitivt via
|
||||
`round_hole` sin eksisterende XOR, migrasjon 031 -- ingen egen
|
||||
XOR-logikk trengs). Eksplisitt, hull-tolerant `shot_number` (ikke
|
||||
`ORDER BY captured_at`). Kun `UPDATE (shared_round_message_id)`
|
||||
grantet -- ellers samme "slett og opprett på nytt"-prinsipp som
|
||||
`round_message`.
|
||||
|
||||
Backend (`app/routers/rounds.py`): `ShotIn`/`ShotOut`/`ShotShareIn` +
|
||||
seks endepunkter -- deltaker-GET/POST og side-GET/POST (speiler
|
||||
`RoundHoleOut`/`RoundSideHoleOut` sitt eksisterende doble mønster for
|
||||
å dekke lagformater), eierskap-agnostisk DELETE (join via
|
||||
`round_hole` → `round_participant`/`round_side` for å bekrefte
|
||||
tilhørighet til riktig runde), og `POST .../shots/{id}/share` som
|
||||
genererer et satellitt-utsnitt SERVER-SIDE (Mapbox Static Images
|
||||
API, ny `TEECUP_MAPBOX_SECRET_TOKEN`-setting i `config.py`, egen
|
||||
polyline-encoder skrevet for topunkts-overlayet, URL-format
|
||||
verifisert mot Mapbox sin offisielle dokumentasjon via WebFetch
|
||||
fremfor antatt fra hukommelse) og laster opp via eksisterende
|
||||
`storage.upload_image()` inn i en vanlig `round_message`-rad.
|
||||
Rekkefølgen (slag opprettes ALLTID først, uten deling) unngår en
|
||||
foreldreløs feed-melding ved en feilet slag-opprettelse. Mapbox-
|
||||
kallet degraderer grasiøst til tekst-only ved manglende token/feil
|
||||
(samme mønster som SMTP/push). `_resolve_round_message_author_name`
|
||||
flyttet fra `round_messages.py` til `rounds.py` (unngår sirkulær
|
||||
import, `rounds.py` var allerede den importerte parten) slik at
|
||||
`share_shot` kunne gjenbruke navnefeltet uendret i stedet for en
|
||||
duplisert kopi.
|
||||
|
||||
Frontend (kun det som IKKE avhenger av V0-eksporten ennå):
|
||||
`frontend/lib/geo.ts` (ren Haversine, ingen avhengigheter). Det
|
||||
eksisterende kølle-plukker-pill-mønsteret i `ScoringWizard`
|
||||
(`round-detail.tsx`) trukket ut til en delt `ClubPicker`-komponent
|
||||
(ren refaktor, `tsc --noEmit` rent før/etter). `mapbox-gl` lagt til
|
||||
`package.json` (versjon slått opp direkte mot npm-registeret, ikke
|
||||
gjettet) -- `@types/mapbox-gl` bevisst IKKE lagt til, `pnpm install`
|
||||
varslet at den er en utdatert stub siden `mapbox-gl` nå leverer egne
|
||||
typedefinisjoner. `pnpm-lock.yaml` regenerert (`pnpm install
|
||||
--lockfile-only`) -- oppdaget under scratch-frontend-bygg at den
|
||||
ELLERS ville vært ute av synk med `package.json`, noe som ville feilet
|
||||
`pnpm install --frozen-lockfile` i BÅDE scratch- og ekte Docker-bygg
|
||||
(fanget her, ikke først ved ekte utrulling). `NEXT_PUBLIC_MAPBOX_TOKEN`
|
||||
tredd gjennom `frontend/Dockerfile` (KUN builder-steget, siden bruken
|
||||
er ren klient-side -- speiler `TEECUP_API_ORIGIN`s build-tid-fallgruve
|
||||
som allerede var dokumentert der) og `docker-compose.yml`. To tomme
|
||||
plassholder-linjer lagt til i `.env` (`NEXT_PUBLIC_MAPBOX_TOKEN`,
|
||||
`TEECUP_MAPBOX_SECRET_TOKEN`) -- MÅ fylles inn med reelle Mapbox-
|
||||
kontoverdier før kart-/delings-bilde-flyten kan testes fullt ut eller
|
||||
rulles ut live.
|
||||
|
||||
V0-prompt for selve `ShotMeasurementSheet` (alle steg + et bevisst
|
||||
MOCK satellitt-kart-plassholder, ikke et ekte kartbibliotek -- ekte
|
||||
Mapbox GL JS kobles på hånd-kodet etter eksport, for å unngå at V0
|
||||
genererer en kart-komponent som remonterer per interaksjon og bryter
|
||||
ADR-048 Beslutning B) skrevet og sendt til bruker. **Venter på
|
||||
V0-eksport** før inngangspunktene (veiviser-knapp + hull-kort-merkelapp)
|
||||
kan kobles til i `round-detail.tsx`.
|
||||
|
||||
**Scratch-verifisert grundig, kun backend** (isolert `teecup_scratch7`-
|
||||
DB, alle 60 migrasjoner kjørt friskt, isolert `teecup_app_scratch7`-
|
||||
rolle, isolert scratch-MinIO, engangs API-container). **Ett reelt
|
||||
funn og umiddelbar retting UNDER selve scratch-oppsettet:**
|
||||
migrasjon 002 hardkoder BÅDE rollenavn OG databasenavn i sin
|
||||
`GRANT CONNECT ON DATABASE teecup_db`-linje -- rutinemessig
|
||||
sed-omdøping av kun rollenavnet (`teecup_app` → `teecup_app_scratch7`)
|
||||
før migrasjonene kjøres mot scratch-DB-en resulterte i at den nye
|
||||
scratch-rollen fikk CONNECT-rettighet på den EKTE `teecup_db`
|
||||
(kun en tilkoblings-rettighet, ingen tabell-/dataadgang fulgte med,
|
||||
men uansett et utilsiktet avtrykk på ekte database uten bekreftelse).
|
||||
Oppdaget og revokert umiddelbart (`REVOKE CONNECT ON DATABASE
|
||||
teecup_db FROM teecup_app_scratch7`), verifisert at `teecup_db` sin
|
||||
`datacl` var tilbake til nøyaktig samme tilstand som før. Ingen data i
|
||||
`teecup_db` ble noensinne lest/skrevet. Notert her i tråd med
|
||||
CLAUDE.md sin åpenhetsplikt -- vil unngå denne fallgruven ved å
|
||||
ekskludere/håndtere migrasjon 002 sin databasenavn-linje separat neste
|
||||
gang en fersk scratch-DB settes opp fra migrasjoner 001+.
|
||||
|
||||
Testmatrise kjørt mot ekte HTTP (magic-link-innlogging, ekte
|
||||
sesjonscookie, `TEECUP_DEV_LOG_MAGIC_LINKS=true`): opprett slag
|
||||
gps/gps og map_tap/gps (deltaker-eid hull), opprett slag på side-eid
|
||||
hull (fourball-format), avstand >500m avvist (422), ugyldig lat
|
||||
avvist (422), slett midt i sekvensen + bekreftet gap (neste slag fikk
|
||||
nummer 3, ikke gjenbruk av 1), id-gjetting på tvers av runder avvist
|
||||
(404), ikke-medlem avvist (403), linket medspiller kan måle for hele
|
||||
flighten (bekreftet med en andre, faktisk innlogget testbruker),
|
||||
slett/liste av ikke-eksisterende ressurser gir 404, delings-endepunkt
|
||||
kjørt uten Mapbox-token satt (bekreftet grasiøs tekst-only-degradering,
|
||||
`shared_image_url: null`, meldingen dukket opp korrekt i
|
||||
`/rounds/{id}/messages` med riktig `author_display_name`-fallback).
|
||||
`test_isolation.sql` 12/12 uendret. `py_compile` rent. Scratch-miljøet
|
||||
(DB/rolle/API-/MinIO-container/image) ryddet opp fullstendig
|
||||
etterpå, bekreftet tomt.
|
||||
|
||||
**Ikke testet i denne runden** (avhenger av ting bruker/V0 ennå ikke
|
||||
har levert): selve satellitt-bilde-genereringen (krever en ekte
|
||||
`TEECUP_MAPBOX_SECRET_TOKEN`), hele frontend-flyten (avhenger av
|
||||
V0-eksporten), ekte nettleserverifisering av "null Mapbox-kall på
|
||||
ren-GPS-veien"-kostnadskontroll-invarianten (ADR-048 Beslutning B) --
|
||||
gjøres når V0-eksporten er integrert.
|
||||
|
||||
38. **Fast bunn-navigasjon manglet på fem av hovedsidene — 2026-08-07.**
|
||||
Brukeren spurte, i forbindelse med dashbord-V0-forhåndsvisningen,
|
||||
"hva skjedde med den sticky menyen i bunnen?" på andre sider enn
|
||||
dashbordet. Undersøkt: `BottomTabBar` (eksportert fra
|
||||
`dashboard.tsx`, migrasjon 2026-08-01) var kun faktisk importert i
|
||||
`dashboard.tsx` selv og `more-menu.tsx` -- til tross for at BÅDE
|
||||
`dashboard.tsx` sin egen kodekommentar ("vist på ALLE skjermer som
|
||||
importerer denne") og CHANGELOG-oppføringen fra samme dag ("brukt av
|
||||
flere sider") beskrev en bredere utrulling enn det som faktisk ble
|
||||
koblet inn. Ingen ADR/CHANGELOG-oppføring dokumenterte en bevisst
|
||||
innsnevring -- vurdert som en ufullstendig utrulling, ikke en
|
||||
beslutning, og rettet direkte uten en egen avklaringsrunde (bekreftet
|
||||
med bruker: fiks nå).
|
||||
|
||||
Lagt til i `own-rounds.tsx` (`active="rounds"` -- direkte fanemål),
|
||||
`account-settings.tsx` (`active="profile"` -- direkte fanemål, Profil-
|
||||
fanen peker allerede til `/account`), og `friends.tsx`/`feed.tsx`/
|
||||
`notifications.tsx` (`active="more"` -- ingen av de tre er et direkte
|
||||
fanemål, men alle tre er allerede listet som "Mer"-menyens egne
|
||||
undersider i `more-menu.tsx`, så dette matcher appens eksisterende
|
||||
IA i stedet for å oppfinne en ny). `<main>`-bunnpolstring justert til
|
||||
`pb-24` (samme verdi som dashbordet selv bruker for å ikke overlappe
|
||||
den faste raden) i de fire filene som ikke allerede hadde nok
|
||||
(`notifications.tsx` hadde fra før `pb-28`, urørt). Ren tillegging av
|
||||
en allerede ferdig, gjenbrukt komponent -- ingen ny logikk.
|
||||
|
||||
**Reelt funn og rettet UNDER selve arbeidet, ikke i selve
|
||||
bunn-nav-fiksen:** `frontend/pnpm-lock.yaml` hadde vært ute av synk
|
||||
med `package.json` siden `mapbox-gl`/`@types/mapbox-gl` ble lagt til
|
||||
(ADR-048, punkt 37 over) -- oppdaget først her fordi dette var første
|
||||
gang siden den endringen at en full `pnpm install --frozen-lockfile`-
|
||||
Docker-bygg faktisk ble kjørt. Ville ha feilet ETHVERT fremtidig
|
||||
scratch- ELLER ekte frontend-bygg. Rettet: `@types/mapbox-gl` fjernet
|
||||
helt (pnpm varslet at den er en utdatert stub -- `mapbox-gl` leverer
|
||||
egne typer nå), `mapbox-gl` endret fra `^3.28.1` til eksakt `3.27.0`
|
||||
(3.28.x var under ett døgn gammel og feilet pnpms egen
|
||||
minimumReleaseAge-leverandørkjede-policy inni Docker-bygget -- en
|
||||
fornuftig sikkerhetssperre mot ferskpubliserte pakker, ikke en feil
|
||||
i verktøyet), lockfile
|
||||
regenerert (`pnpm clean --lockfile && pnpm install`).
|
||||
|
||||
**Scratch-verifisert i ekte nettleser** (åttende scratch-miljø denne
|
||||
økten: isolert DB fra migrasjoner 001-060 kjørt friskt, isolert
|
||||
`teecup_app_scratch8`-rolle, isolert scratch-MinIO, scratch-API- og
|
||||
-frontend-container -- frontend bygget med riktig scratch-
|
||||
`TEECUP_API_ORIGIN`). Logget inn med ekte magic-link-flyt, fullførte
|
||||
profil, besøkte alle fem rettede sider (`/my-rounds`, `/my-friends`,
|
||||
`/account`, `/my-feed`, `/my-notifications`) pluss dashbordet --
|
||||
bunn-raden vises korrekt nederst uten å overlappe innhold på noen av
|
||||
dem, riktig fane fremhevet grønt på hver (Runder/Profil/Mer×3), ingen
|
||||
konsollfeil på noen av sidene. Scratch-miljøet ryddet opp fullstendig
|
||||
etterpå (denne runden traff samme fallgruve som punkt 37 med
|
||||
migrasjon 002s hardkodede `teecup_db`-referanse -- rettet i den
|
||||
kopierte migrasjonsfilen FØR kjøring denne gangen, ikke etterpå, og
|
||||
eksplisitt bekreftet at ekte `teecup_db` sin `datacl` var uendret
|
||||
etterpå).
|
||||
|
||||
**Ikke rullet ut ennå** -- venter på samlet bekreftelse sammen med
|
||||
resten av det som gjenstår i denne økten (slagmåling-frontend,
|
||||
dashbord-V0-beslutning).
|
||||
|
||||
39. **Dashbordet reskinnet til "clubhouse"-paletten — 2026-08-07, bruker
|
||||
bekreftet eksplisitt "full overtagelse, også bakgrunnen".** Egen,
|
||||
parallell V0-utforskning (ikke samme retning som "Forest Green" fra
|
||||
2026-08-01/02) — sendt som en eksplisitt EKSPLORERENDE prompt ("samme
|
||||
ånd som login-siden-utforskningen... ikke bedt om å matche eksisterende
|
||||
stil"), forhåndsvist for bruker (screenshots av en `npm install`+
|
||||
`next dev`-kjøring av selve V0-eksporten, mock-data) FØR noe ble
|
||||
integrert i den ekte, datakoblede `dashboard.tsx`.
|
||||
|
||||
**Viktig funn før integrering:** paletten (`--tee`/`--tee-strong`/
|
||||
`--cup`/`--cup-strong`/`--clubhouse-*`) og `TeeCupWordmark`-komponenten
|
||||
fantes ALLEREDE i prosjektet — fra en tidligere, ennå ikke besluttet
|
||||
`/logg-inn`-utforskning (egen isolert sammenligningsside, additive
|
||||
CSS-tokens i `globals.css`, rørte ikke skya-tokens). V0 gjenbrukte dem
|
||||
konsekvent for dashbord-eksporten fordi de allerede var etablert i
|
||||
prosjektkonteksten, ikke fordi det gjenbrukte "gamle" farger — bekreftet
|
||||
ved faktisk pikselsampling av skjermdumpen (`#2f6b1e`/`#ff5427`, begge
|
||||
forskjellige fra "Forest Green" sin `#1f6b08`/ingen oransje i det hele
|
||||
tatt). `/logg-inn`s egen etablerte presedens ble fulgt: Bricolage-
|
||||
visningsfonten IKKE lagt til (samme utelatelse som der), kun paletten
|
||||
og wordmarken.
|
||||
|
||||
**Omfangsgrenser, avklart eksplisitt med bruker underveis (ikke
|
||||
antatt):** `RoundCard`/`TournamentCard` (delt med `/my-rounds`) IKKE
|
||||
re-stylet — beholder sin eksisterende shadcn-token-baserte styling
|
||||
(viste seg uansett visuelt kompatibelt, siden begge alt bruker et
|
||||
grønt-for-primær/oransje-for-sekundær-mønster). `BottomTabBar` (delt
|
||||
med de fem sidene fra punkt 38) IKKE re-stylet direkte av meg — bruker
|
||||
ba eksplisitt om en egen V0-prompt for den ("lag et prompt til V0, og
|
||||
la den bestemme utseendet") fremfor at jeg skulle avgjøre det selv,
|
||||
siden komponenten må fungere rimelig godt mot BÅDE den nye og den gamle
|
||||
paletten avhengig av hvilken side den vises på. Egen prompt skrevet og
|
||||
sendt, venter på eksport.
|
||||
|
||||
**Faktisk re-stylet:** header (erstattet flagg-i-sirkel med ekte
|
||||
`TeeCupWordmark`), hilsen, `InstallPrompt` (KUN dens egen selvstendige
|
||||
JSX-retur, mørkegrønn gradient endret til en tee-strong-forankret
|
||||
gradient — all ekte iOS/Android-deteksjons-/14-dagers-utsettelses-logikk
|
||||
uendret, samme "logikk uendret, kun styling"-disiplin som Forest Green-
|
||||
runden fulgte), `QuickAction` (fikk `primary`/`expanded`-varianter som
|
||||
matcher V0-eksportens mønster — "Ny runde" solid, de to andre kort-
|
||||
stil), delt seksjon-"chrome" (`SectionHeader`/`SeeAllLink`/`EmptyState`/
|
||||
`ShortcutButton`), "Venner på banen", `Sparkline`/`StatTile`/
|
||||
`StatsSection`, "Spilte baner", "Venner"-oppsummeringen, og
|
||||
organisasjon-foten. `AVATAR_HUES` (venne-avatar-fargene) beholdt
|
||||
uendret — allerede nøytrale nok til å fungere i begge paletter.
|
||||
|
||||
`tsc --noEmit` rent. **Scratch-verifisert i ekte nettleser** (niende
|
||||
scratch-miljø denne økten, samme mønster som punkt 37/38 — inkludert
|
||||
samme forsiktighet med migrasjon 002s databasenavn-linje, bekreftet
|
||||
`teecup_db` uendret både før og etter): logget inn, fullført profil,
|
||||
besøkt dashbordet i både tom tilstand og med ekte seed-data (pågående
|
||||
runde, aktiv turnering som arrangør, statistikk, spilt bane) —
|
||||
korrekt rendret i begge tilstander, `RoundCard`/`TournamentCard` sitter
|
||||
visuelt fint på den nye bakgrunnen, "Ny turnering"-hurtighandlingen
|
||||
utvidet korrekt og det eksisterende skjemaet (uendret) fungerte som før.
|
||||
Ingen konsollfeil. Scratch-miljøet ryddet opp fullstendig.
|
||||
|
||||
**Ikke rullet ut ennå** -- venter på BottomTabBar-V0-eksporten (så
|
||||
hele dashbordet, inkludert navigasjonen, kan rulles ut samlet i én
|
||||
omgang i stedet for i to synlig usammenhengende steg) og på samlet
|
||||
bekreftelse sammen med resten av det som gjenstår denne økten.
|
||||
|
|
|
|||
19
CLAUDE.md
|
|
@ -13,11 +13,13 @@ Les dette først i hver økt. Det koder hva vi har bestemt og hvordan vi jobber.
|
|||
bruksanvisning.
|
||||
- Endres en beslutning: legg til en ny ADR, ikke slett historikk. Hold
|
||||
disse filene oppdatert når noe avgjøres.
|
||||
- **Regelverk for HCP/slagfordeling — tre PDF-er lastet opp av brukeren til
|
||||
prosjektroten 2026-07-19** (ikke innsjekket i git, kun lokale filer på
|
||||
serveren): `spilletyper-og-spilleformer-2023.pdf`,
|
||||
`Live Tourney _ A Guide to Handicap Scoring in Golf for Tournaments.pdf`,
|
||||
`SCGA Club Digest.pdf`. Brukeren: disse tre gir til sammen en tydelig
|
||||
- **Regelverk for HCP/slagfordeling — tre PDF-er lastet opp av brukeren
|
||||
2026-07-19** (ikke innsjekket i git, kun lokale filer på serveren, flyttet
|
||||
fra prosjektroten til `Temp-uploads/` 2026-08-07 — se eget notat om denne
|
||||
mappen lenger ned): `Temp-uploads/spilletyper-og-spilleformer-2023.pdf`,
|
||||
`Temp-uploads/Live Tourney _ A Guide to Handicap Scoring in Golf for
|
||||
Tournaments.pdf`, `Temp-uploads/SCGA Club Digest.pdf`. Brukeren: disse tre
|
||||
gir til sammen en tydelig
|
||||
beskrivelse av hvordan HCP og mottatte/tildelte slag skal beregnes/
|
||||
fordeles. Les disse FØR videre arbeid med `handicap_engine.py`,
|
||||
`app/handicap.py`, allowance-strategier (ADR-005/014) eller
|
||||
|
|
@ -38,6 +40,13 @@ Les dette først i hver økt. Det koder hva vi har bestemt og hvordan vi jobber.
|
|||
kildekoden (DESIGN_SYSTEM.md beskriver den), men spec-dokumentets §3
|
||||
"Compliance-pass" er en konkret sjekkliste over kjente avvik — les den FØR
|
||||
videre visuelt arbeid på scorekort-/score-entry-skjermene.
|
||||
- **`Temp-uploads/`-mappen (opprettet av bruker 2026-08-07, gitignored)**
|
||||
er der brukeren laster opp støttefiler — skjermdumper, videoer, PDF-er,
|
||||
V0-eksport-zip-er og lignende — ment som illustrasjon/kildemateriale til
|
||||
Claude, ikke som permanente prosjektfiler. Sjekk denne mappen når
|
||||
brukeren nevner at de "har lastet opp" noe uten å oppgi full sti. Flytt
|
||||
nye støttefiler dit etter hvert som de dukker opp i prosjektroten,
|
||||
fremfor å la dem hope seg opp løst i roten.
|
||||
|
||||
## Sikkerhetsregler (ufravikelige)
|
||||
- Rør ALDRI `teeoff`-databasen eller den ekte `teecup_db` uten at brukeren
|
||||
|
|
|
|||
|
|
@ -1947,14 +1947,14 @@ forskjell mellom scramble og greensome i noen av domenene.
|
|||
| Video | 💤 | Fortsatt utsatt, egen ADR om/når etterspurt. |
|
||||
| 1-til-1 direktemeldinger | 💤 | Fortsatt utsatt, ikke etterspurt. |
|
||||
| Moderering (offentlig feed) | ✅ LIVE | Forfatteren selv, ELLER org-eier/admin, kan slette et innlegg. Lag-chatten har ingen moderering utover forfatteren (rommet er privat, org-admin har uansett ikke lesetilgang). |
|
||||
| Bilder + kommentarer i frittstående (personlige) runder + samlet `/my-feed` | ✅ BYGGET, SCRATCH-VERIFISERT, venter på utrulling mot ekte `teecup_db` | ADR-044, migrasjon 058. Skriverett = kan-se-kan-poste (ikke kun eier/medspillere), se ADR-044 for full begrunnelse. |
|
||||
| Bilder + kommentarer i frittstående (personlige) runder + samlet `/my-feed` | ✅ LIVE 2026-08-06 | ADR-044, migrasjon 058. Skriverett = kan-se-kan-poste (ikke kun eier/medspillere), se ADR-044 for full begrunnelse. |
|
||||
|
||||
Se ARCHITECTURE_DECISIONS.md ADR-025 for alle fire hovedbeslutningene og
|
||||
CHANGELOG.md for full byggerunde (datamodell, autorisasjon, scratch-
|
||||
verifisering med 20 automatiserte sjekker inkl. reell WebSocket-sanntid,
|
||||
og utrulling).
|
||||
|
||||
### Bilder + kommentarer i frittstående runder + samlet feed — ✅ BYGGET OG SCRATCH-VERIFISERT 2026-08-06 (ADR-044, migrasjon 058), venter på utrulling mot ekte `teecup_db`
|
||||
### Bilder + kommentarer i frittstående runder + samlet feed — ✅ HELT FERDIG, BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-08-06 (ADR-044, migrasjon 058)
|
||||
|
||||
Brukeren: "I org-modulen har vi muligheter til bilder og kommentarer. Jeg
|
||||
ønsker at dette også skal være mulig i single runder... Det må kanskje
|
||||
|
|
@ -1985,8 +1985,8 @@ teste dette). Én kritisk rute-/rewrite-kollisjon (`/feed` vs. API-ets
|
|||
`GET /feed`) funnet og rettet under verifisering — se ADR-044 for detalj.
|
||||
`test_isolation.sql` uendret 12/12.
|
||||
|
||||
**Ikke rullet ut mot ekte `teecup_db` ennå** — venter på brukerens
|
||||
eksplisitte bekreftelse, samme rutine som alltid. Se CHANGELOG.md
|
||||
**Rullet ut live 2026-08-06**, bruker bekreftet eksplisitt: migrasjon 058
|
||||
kjørt mot ekte `teecup_db`, begge containere redeployet. Se CHANGELOG.md
|
||||
2026-08-06 for full byggelogg.
|
||||
|
||||
---
|
||||
|
|
@ -3427,6 +3427,140 @@ eller bygget -- eneste konkrete tiltak er en kode-KOMMENTAR i
|
|||
siden av GIR-merket, slik at en fremtidig avstand-indikator kan legges
|
||||
til uten en layout-endring. Ingen data, ingen funksjonalitet.
|
||||
|
||||
**Brainstorm-runde 2026-08-06 -- fortsatt IKKE designet/besluttet, kun
|
||||
retning avklart.** Brukeren ba eksplisitt om å starte konkretisering av
|
||||
avstandsmåling, pekte ut ekte spiller-GPS-posisjon (ikke bare statiske
|
||||
banepunkter) som første prioritet, og ba meg lese en opplastet PDF
|
||||
("Golfbane API for internasjonal app.pdf" -- en eksportert Gemini-
|
||||
samtale om internasjonal banedata for TeeCup) som grunnlag.
|
||||
|
||||
**Fra PDF-en (fakta, ikke min vurdering):** anbefalt dataleverandør for
|
||||
banedata utenfor Norge er golfapi.io (~42 000 baner, REST-API ELLER
|
||||
CSV-database-eksport). Priser: API 29 €/mnd (50 kall) opp til 399 €/mnd
|
||||
(ubegrenset, årsabonnement), eller en ikke-utløpende engangspakke på
|
||||
500 kall for 199 €. CSV-eksport MED koordinater: 2 995 € (Europa) til
|
||||
5 995 € (alle baner) -- betydelig dyrere engangskostnad. **Avgjørende
|
||||
lisensdetalj:** golfapi.io tillater eksplisitt permanent caching av
|
||||
hentede banedata ("no need to call the API to fetch the same course
|
||||
multiple times") -- ett kall per bane NOEN GANG er nok, ikke ett per
|
||||
oppslag. Alternativ leverandør nevnt: iGolf (40 000+ baner, har
|
||||
"greenkart" -- trolig ekte polygon-geometri, ikke bare punkter -- men
|
||||
eksplisitt "ofte dyrt for mindre apper", B2B/enterprise).
|
||||
|
||||
**Min vurdering (Claude, denne datoen) -- IKKE besluttet, kun anbefalt:**
|
||||
1. Caching-tillatelsen gjør API-veien (billig inngangspakke, 199 €)
|
||||
klart mer kostnadseffektiv enn CSV-kjøpet i tidlig fase -- enig med
|
||||
PDF-ens egen anbefaling. **Merk arkitektur-nyansen:** dette er en
|
||||
BEVISST ANNERLEDES policy enn teeoff-regelen i CLAUDE.md ("banedata
|
||||
hentes fra teeoff via lesende API, ikke delt database") -- teeoff
|
||||
forblir levende oppslag uendret, golfapi.io (skulle det velges) ville
|
||||
fått sin EGEN, ny policy (persistent cache tillatt av leverandørens
|
||||
egen lisens). Bør få en egen ADR den dagen dette faktisk bygges, ikke
|
||||
stille anta at det er samme regel.
|
||||
2. **Brukerens hovedbekymring (kostnad ved å illustrere hvert hull) er
|
||||
en ANNEN datatype enn selve avstandsdataene, og bør skilles fra dem:**
|
||||
punkt-koordinater (pin/green-senter/hazard) for AVSTANDSTALL er
|
||||
billig og dekket av planen over. Et ekte VISUELT, konturriktig
|
||||
hullkart (fairway-/bunker-/green-FORM, ikke bare punkter) er
|
||||
polygon-geometri -- en annen, dyrere datatype som verken golfapi.io
|
||||
sin liste eller PDF-en nevner at de har (det er nettopp det
|
||||
iGolf/"Golf Intelligence" sin enterprise-prising dekker).
|
||||
3. **Anbefalt v1, for å unngå den kostnaden brukeren er bekymret for:**
|
||||
bygg avstandsmåling som REN TALL-/TEKSTVISNING (f.eks. "142 m til
|
||||
green"), IKKE et tegnet hullkart -- løser hele "avstandsmåling"-
|
||||
ambisjonen med kun punktdata, ingen kartrendring-kostnad i det hele
|
||||
tatt. Et ekte visuelt hullkart bør være et bevisst SENERE, adskilt
|
||||
steg. Når/hvis det steget kommer: undersøk OpenStreetMap sin
|
||||
`golf=*`-tagging (fairway/green/bunker-polygoner, community-kartlagt,
|
||||
GRATIS via Overpass API, rendret med et gratis bibliotek som Leaflet)
|
||||
for banene brukerne faktisk spiller, FØR proprietær enterprise-
|
||||
geometri (iGolf/Golf Intelligence) vurderes -- OSM-dekning varierer
|
||||
per bane (bedre for kjente baner), men koster 0 kr der den finnes.
|
||||
|
||||
**Fortsatt åpent, ikke avklart:** eksakt datakilde-valg (golfapi.io vs.
|
||||
alternativ), om/når et visuelt hullkart faktisk skal bygges, hvordan
|
||||
spiller-GPS-posisjon (Geolocation API) kombineres med de lagrede
|
||||
punktene til en løpende avstandsberegning, og UI-plassering (samme
|
||||
reserverte plass ved GIR-merket nevnt 2026-07-25, eller noe nytt).
|
||||
|
||||
**Brainstorm-runde 2026-08-07 -- "slaglengde-måling" konkretisert
|
||||
(fortsatt IKKE designet/besluttet).** Brukeren beskrev en konkret flyt:
|
||||
mål avstand FRA enten (a) spillerens live GPS-posisjon, eller (b) et
|
||||
manuelt valgt punkt på et satellittkart, TIL der spilleren står ved
|
||||
ballen -- pluss hvilken kølle som ble brukt. Kan deles i feeden eller
|
||||
holdes privat.
|
||||
|
||||
**Nøkkelinnsikt fra research:** dette er BILLIGERE enn "avstand til
|
||||
faste banepunkter" (notatet over) -- det trenger INGEN forhåndskjent
|
||||
banegeometri (pin/hazard-koordinater), kun to AD HOC-punkter nær
|
||||
spilleren der og da. Kartet trenger bare sentreres på spillerens egen
|
||||
posisjon, ikke vite noe om hullet på forhånd.
|
||||
|
||||
**Avklart med bruker (AskUserQuestion, to spørsmål):**
|
||||
1. **Omfang v1:** IKKE bare utslaget -- full slag-for-slag-logg fra
|
||||
start (ethvert slag på hullet skal kunne måles, ikke kun det første).
|
||||
Krever en NY, egen tabell (dagens `round_hole` har kun kategoriske
|
||||
per-hull-felt som `club_off_tee`/`tee_shot_result`, ingen ekte
|
||||
slag-for-slag-logg med avstand). Kølle-valg gjenbruker den
|
||||
eksisterende, faste 28-køllers `BAG_CLUBS`-listen
|
||||
(`app/routers/auth.py`) uendret.
|
||||
2. **Kart-leverandør:** usikker, ba om videre diskusjon FØR beslutning
|
||||
(se under).
|
||||
|
||||
**Kart-leverandør-sammenligning (ferske, verifiserte tall/vilkår,
|
||||
2026-08-07 -- IKKE fra treningsdata alene):**
|
||||
- **Mapbox GL JS:** 50 000 gratis kart-innlastinger/mnd, INGEN
|
||||
betalingskort kreves for å starte, kommersiell bruk eksplisitt tillatt.
|
||||
Over grensen: ca. $5/1000 ekstra innlastinger.
|
||||
- **Leaflet + Esri World Imagery** (den vanlige "gratis"-kombinasjonen):
|
||||
**reelt IKKE gratis for et kommersielt produkt** -- Esri sine egne
|
||||
vilkår sier eksplisitt at World Imagery-laget ikke er tillatt for
|
||||
kommersiell bruk uten egen ArcGIS-lisens. Siden TeeCup skal
|
||||
kommersialiseres, diskvalifiserer dette denne kombinasjonen som et
|
||||
reelt gratis-alternativ, selv om Leaflet-BIBLIOTEKET selv er gratis.
|
||||
- **Anbefaling:** Mapbox GL JS -- eneste av de to reelt gratis OG
|
||||
ToS-trygt for et kommersielt produkt på TeeCups sannsynlige skala.
|
||||
|
||||
**Kostnadsoverslag ved 100 000 brukere (grovt anslag, IKKE en garanti):**
|
||||
Nøkkelen er at én "kart-innlasting" telles per gang kartkomponenten
|
||||
INITIALISERES, ikke per slag/trykk -- ÉTT kartobjekt holdt levende
|
||||
gjennom en hel runde (oppdatert med nye punkter per slag, ikke
|
||||
re-montert) gjør at 14 slag i én runde koster like mye som ett. Med
|
||||
konservative antakelser (30 % månedlig aktive, 30 % bruker funksjonen,
|
||||
3 runder/mnd) lander man rundt 27 000 innlastinger/mnd -- under
|
||||
gratisgrensen. Et aggressivt anslag (50/50/4) lander rundt 100 000/mnd,
|
||||
altså ~$250/mnd i overforbruk -- en normal, forutsigbar
|
||||
infrastrukturkostnad ved den brukerskalaen, ikke noe som truer en
|
||||
forretningsmodell med betalende klubber/bedrifter. To konkrete grep som
|
||||
holder volumet nede uansett skala: (1) kartet vises KUN når brukeren
|
||||
eksplisitt velger "velg punkt på kart" i stedet for "bruk min posisjon"
|
||||
-- ren GPS krever ingen kart-innlasting i det hele tatt, (2) selve
|
||||
avstandsberegningen (Haversine-formel) er ren matte og treffer aldri
|
||||
Mapbox. Ved genuint mye større skala (millioner av brukere): forhandle
|
||||
en Enterprise-avtale direkte med Mapbox, eller revurder da -- ikke bygg
|
||||
inn kompleksitet for det nå.
|
||||
|
||||
**AVKLART OG DESIGNET 2026-08-07, se ADR-048 i ARCHITECTURE_DECISIONS.md
|
||||
for den fulle beslutningen.** De tre gjenstående spørsmålene over ble
|
||||
avklart med bruker (AskUserQuestion): v1 dekker BÅDE individuelle runder
|
||||
OG lagformater fra start (ikke bare individuell), delingsteksten er
|
||||
auto-generert MEN redigerbar pluss et satellitt-utsnitt av slaget, og
|
||||
måling er tilgjengelig både i scoreførings-veiviseren og som en
|
||||
retroaktiv hull-kort-handling. Skjema: ny `round_shot`-tabell (migrasjon
|
||||
060), skjema-detaljene i ADR-048.
|
||||
|
||||
**Status: backend FERDIG og scratch-verifisert 2026-08-07** (migrasjon,
|
||||
alle endepunkter, delings-flyt med satellitt-bilde-generering) — se
|
||||
CHANGELOG.md punkt 37 for full detalj. Frontend delvis (Haversine-
|
||||
hjelper, delt `ClubPicker`, Mapbox-avhengighet + token-plumbing).
|
||||
**Gjenstår:** V0-eksport for selve `ShotMeasurementSheet` (prompt sendt
|
||||
til bruker, venter på svar), kobling av inngangspunktene i
|
||||
`round-detail.tsx`, reelle Mapbox-token i `.env` (kun tomme
|
||||
plassholdere satt inn foreløpig), ekte nettleserverifisering av
|
||||
kostnadskontroll-invarianten (ADR-048 Beslutning B), og til slutt
|
||||
utrulling til ekte database/containere (krever migrasjon 060 kjørt mot
|
||||
ekte `teecup_db` — IKKE gjort ennå, venter på eksplisitt bekreftelse).
|
||||
|
||||
**Se ADR-033 i ARCHITECTURE_DECISIONS.md for den fulle, besluttede
|
||||
arkitekturen** (eierskapsmønster, statistikk-datamodell, HCP-indeksmotor).
|
||||
Brukeren bekreftet 2026-07-22 at teeoff-banedata skal hentes via LIVE
|
||||
|
|
@ -4224,6 +4358,24 @@ utrulling), bruker bekreftet eksplisitt.
|
|||
|
||||
---
|
||||
|
||||
## Tilskuer-visning (`/watch/[id]`): tre faner + "Spillere og runde" — ✅ HELT FERDIG, BYGGET, SCRATCH-VERIFISERT OG LIVE 2026-08-06 (ADR-045)
|
||||
|
||||
To bugger fanget opp samme dag (video-illustrasjon fra bruker): "Venner på
|
||||
banen" på dashbordet lenket til feil rute (`/my-rounds/{id}` i stedet for
|
||||
`/watch/{id}`), og selve `/watch/[id]` var kun ÉN enkelt side (kompakt
|
||||
status + leaderboard/matchstatus, kun 8 av 17 formater dekket). Begge
|
||||
rettet -- se ADR-045 for full detalj. Tilskueren får nå samme tre faner
|
||||
som eieren (Score/Scorekort/Leaderboard, ekte full formatparitet via
|
||||
`RoundScorecard`/`RoundLeaderboard` sin nye `publicMode`-prop) og
|
||||
"Spillere og runde" (deltakerliste, HCP/utslag/tildelte slag, flight-
|
||||
gruppering via et nytt `GET /public/rounds/{id}/flight-group`-endepunkt),
|
||||
uten noen av eier-handlingene (Rediger/Fullfør/Slett runde, +Medspiller).
|
||||
|
||||
**Rullet ut live 2026-08-06**, bruker bekreftet eksplisitt. Se
|
||||
CHANGELOG.md 2026-08-06 for full byggelogg.
|
||||
|
||||
---
|
||||
|
||||
## Bevisst endret fra opprinnelige (Gemini-)råd
|
||||
|
||||
- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte
|
||||
|
|
@ -4233,3 +4385,47 @@ utrulling), bruker bekreftet eksplisitt.
|
|||
- 🔀 **Tenant-modell:** organisasjon som tenant med RLS, ikke bare «turnering-ID».
|
||||
- 🔀 **Sesjons-secret:** egne secrets for TeeCup, ikke fallback til teeoffs
|
||||
(teeoff selv bruker en slik fallback — bevisst unngått her).
|
||||
|
||||
## Visuell redesign-utforskning — parallell `/logg-inn`-side (V0), 2026-08-07
|
||||
|
||||
Brukeren er lite fornøyd med appens visuelle uttrykk generelt, og ba om
|
||||
en test: gi V0 en ny chat med (1) et fyldig merkevare-/personlighets-
|
||||
brief om hva TeeCup er og skal bli, (2) en presis, mekanisk teknisk
|
||||
spesifikasjon av hva innloggingsskjermen faktisk må inneholde/gjøre
|
||||
(hentet fra ekte kode, `login-form.tsx`/`verify-form.tsx` — fire
|
||||
tilstander: magic-link, lenke-sendt, passord-fallback, bli-med-via-kode),
|
||||
og (3) selve logoen, uten noen ytterligere beskrivelse av den — bevisst
|
||||
IKKE bundet til dagens låste "Forest Green"-palett, for å se om et friskt
|
||||
blikk gir noe bedre. V0-prompten er skrevet av Claude, sendt til bruker
|
||||
for innliming i en NY, egen V0-chat (samme "Frontend via V0"-arbeidsdeling
|
||||
som ellers, men her eksplisitt uten dagens design-tokens som ledetekst).
|
||||
|
||||
**Resultat:** V0 leverte en selvstendig, fungerende sluttbrukerflyt på
|
||||
`app/logg-inn/page.tsx` + `components/teecup/{teecup-auth,wordmark}.tsx`
|
||||
— alle fire tilstander bygget med ekte klientsidig validering/cooldown/
|
||||
feilhåndtering (mocket backend internt, siden dette er en isolert
|
||||
design-utforskning, ikke koblet til ekte `/auth/*`-endepunkter ennå).
|
||||
Egen ordmerke tegnet på nytt som rene SVG-baner fra den opplastede
|
||||
logoen ("Tee" i grønt, "Cup" i oransjerødt), og en egen, SKOPET
|
||||
"clubhouse"-fargepalett (`--tee-strong: #2f6b1e`, `--cup-strong:
|
||||
#cf3c17`, varm off-white bakgrunn) — en helt annen visuell retning enn
|
||||
dagens låste palett.
|
||||
|
||||
**Integrert av Claude som parallell, isolert rute** (`/logg-inn`, IKKE en
|
||||
overskriving av `/`): kopiert inn uendret, "clubhouse"-fargetokens lagt
|
||||
til ADDITIVT nederst i `app/globals.css` (skoper kun til denne siden,
|
||||
rører ingen eksisterende tokens). Typesjekket rent, ekte produksjonsbuild
|
||||
kjørt og bekreftet ren, alle fire tilstander browserverifisert i en
|
||||
isolert forhåndsvisnings-container (ingen konsollfeil).
|
||||
|
||||
**Rullet ut live 2026-08-07**, bruker bekreftet eksplisitt ("Ja takk"):
|
||||
`docker compose up -d --build teecup_frontend`. Begge containere boot-et
|
||||
rent, `/logg-inn` → 200, `/` (dagens innlogging, uendret) → 200 fortsatt.
|
||||
Nåbar på `teecup.golf/logg-inn` for sammenligning på ekte enhet.
|
||||
|
||||
**Ingen beslutning tatt ennå om å faktisk ta i bruk denne retningen** —
|
||||
kun en parallell sammenligning. Ingen backend-kobling gjort — hvis denne
|
||||
retningen velges, må `teecup-auth.tsx` sine mockede API-kall kobles til
|
||||
de ekte `/auth/request-link`/`/auth/verify-link`/`/auth/login-password`/
|
||||
`/public/tournaments/by-code`-endepunktene (samme kontrakt som
|
||||
`login-form.tsx` allerede bruker) før den kan erstatte dagens `/`.
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
|
@ -106,5 +106,13 @@ class Settings:
|
|||
VAPID_SUBJECT: str = os.getenv("TEECUP_VAPID_SUBJECT", "https://teecup.golf")
|
||||
PUSH_CONFIGURED: bool = bool(VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY)
|
||||
|
||||
# Mapbox Static Images API (ADR-048) -- serverside genererte satellitt-
|
||||
# utsnitt for delte slag-målinger. Egen, HEMMELIG token -- IKKE samme
|
||||
# som frontendens offentlige NEXT_PUBLIC_MAPBOX_TOKEN (URL-restriktert,
|
||||
# trygt eksponert i nettleseren). Denne skal ALDRI nå klienten. Valgfritt,
|
||||
# samme grasiøs-degraderings-mønster som SMTP/push: deling uten satellitt-
|
||||
# bilde faller tilbake til ren tekst hvis ikke satt.
|
||||
MAPBOX_SECRET_TOKEN: str | None = os.environ.get("TEECUP_MAPBOX_SECRET_TOKEN", "").strip() or None
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from ..realtime import live_sockets_for
|
|||
from ..team_authz import is_org_admin, user_is_rostered_on_team
|
||||
from .. import storage
|
||||
from .registration import check_visibility, code_matches, is_participant, resolve_org
|
||||
from .round_messages import ALLOWED_REACTION_EMOJIS, CommentIn, ReactionIn, ReactionSummary
|
||||
|
||||
router = APIRouter(tags=["messaging"])
|
||||
public_router = APIRouter(prefix="/public/tournaments", tags=["public-messaging"])
|
||||
|
|
@ -51,9 +52,13 @@ class MessageOut(BaseModel):
|
|||
body: str | None
|
||||
image_url: str | None
|
||||
created_at: str
|
||||
reactions: list[ReactionSummary] = []
|
||||
comment_count: int = 0
|
||||
|
||||
|
||||
def _row_to_message(row) -> MessageOut:
|
||||
def _row_to_message(
|
||||
row, reactions: list[ReactionSummary] | None = None, comment_count: int = 0
|
||||
) -> MessageOut:
|
||||
return MessageOut(
|
||||
id=row["id"],
|
||||
author_user_id=row["author_user_id"],
|
||||
|
|
@ -61,9 +66,92 @@ def _row_to_message(row) -> MessageOut:
|
|||
body=row["body"],
|
||||
image_url=storage.public_url(row["image_key"]) if row["image_key"] else None,
|
||||
created_at=row["created_at"].isoformat(),
|
||||
reactions=reactions or [],
|
||||
comment_count=comment_count,
|
||||
)
|
||||
|
||||
|
||||
# --- Reaksjoner/kommentarer (ADR-046) -- delt mellom lag-chat og ---------
|
||||
# --- oppslagstavle, siden begge lagrer i `message` med scope-diskriminator.
|
||||
|
||||
_MESSAGE_COMMENT_COLUMNS = """
|
||||
id::text, message_id::text, parent_comment_id::text, author_user_id::text,
|
||||
author_display_name, body, created_at
|
||||
"""
|
||||
|
||||
|
||||
class CommentOut(BaseModel):
|
||||
id: str
|
||||
message_id: str
|
||||
parent_comment_id: str | None
|
||||
author_user_id: str
|
||||
author_display_name: str
|
||||
body: str
|
||||
created_at: str
|
||||
|
||||
|
||||
def _row_to_message_comment(row) -> CommentOut:
|
||||
return CommentOut(
|
||||
id=row["id"],
|
||||
message_id=row["message_id"],
|
||||
parent_comment_id=row["parent_comment_id"],
|
||||
author_user_id=row["author_user_id"],
|
||||
author_display_name=row["author_display_name"],
|
||||
body=row["body"],
|
||||
created_at=row["created_at"].isoformat(),
|
||||
)
|
||||
|
||||
|
||||
async def _message_reactions(
|
||||
conn, message_ids: list[str], viewer_user_id: str | None
|
||||
) -> dict[str, list[ReactionSummary]]:
|
||||
if not message_ids:
|
||||
return {}
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT mr.message_id::text AS message_id, mr.emoji, count(*) AS count,
|
||||
COALESCE(bool_or(mr.user_id = $2), false) AS reacted_by_me,
|
||||
array_agg(
|
||||
COALESCE(p.display_name, split_part(au.email, '@', 1))
|
||||
ORDER BY mr.created_at
|
||||
) AS reactors
|
||||
FROM message_reaction mr
|
||||
JOIN app_user au ON au.id = mr.user_id
|
||||
LEFT JOIN player p ON p.organization_id = mr.organization_id AND p.user_id = mr.user_id
|
||||
WHERE mr.message_id = ANY($1::uuid[])
|
||||
GROUP BY mr.message_id, mr.emoji
|
||||
""",
|
||||
message_ids,
|
||||
viewer_user_id,
|
||||
)
|
||||
result: dict[str, list[ReactionSummary]] = {}
|
||||
for r in rows:
|
||||
result.setdefault(r["message_id"], []).append(
|
||||
ReactionSummary(
|
||||
emoji=r["emoji"],
|
||||
count=r["count"],
|
||||
reacted_by_me=r["reacted_by_me"],
|
||||
reactors=list(r["reactors"]),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def _message_comment_counts(conn, message_ids: list[str]) -> dict[str, int]:
|
||||
if not message_ids:
|
||||
return {}
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT message_id::text AS message_id, count(*) AS count
|
||||
FROM message_comment
|
||||
WHERE message_id = ANY($1::uuid[])
|
||||
GROUP BY message_id
|
||||
""",
|
||||
message_ids,
|
||||
)
|
||||
return {r["message_id"]: r["count"] for r in rows}
|
||||
|
||||
|
||||
async def _resolve_author_display_name(conn, organization_id: str, user_id: str) -> str:
|
||||
"""Fryses ved skrivetidspunkt (samme prinsipp som handicap_index_snapshot,
|
||||
ADR-007) -- avsenderens player.display_name i DENNE org-en hvis den
|
||||
|
|
@ -129,15 +217,23 @@ async def list_team_messages(
|
|||
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
|
||||
if not await user_is_rostered_on_team(conn, team_id, user.user_id):
|
||||
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
||||
# Nyeste øverst (2026-08-06, brukerens eksplisitte ønske -- "over
|
||||
# alt", samme rekkefølge som round_message og tournament_feed nå).
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT {_MESSAGE_COLUMNS} FROM message
|
||||
WHERE team_id = $1 AND scope = 'team'
|
||||
ORDER BY created_at
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
team_id,
|
||||
)
|
||||
return [_row_to_message(r) for r in rows]
|
||||
message_ids = [r["id"] for r in rows]
|
||||
reactions_by_id = await _message_reactions(conn, message_ids, user.user_id)
|
||||
comment_counts = await _message_comment_counts(conn, message_ids)
|
||||
return [
|
||||
_row_to_message(r, reactions_by_id.get(r["id"]), comment_counts.get(r["id"], 0))
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post(
|
||||
|
|
@ -219,6 +315,176 @@ async def delete_team_message(
|
|||
await conn.execute("DELETE FROM message WHERE id = $1", message_id)
|
||||
|
||||
|
||||
# --- Reaksjoner/kommentarer på lag-chat-meldinger (ADR-046) ---------------
|
||||
# Samme "hvem kan poste = hvem kan reagere/kommentere"-modell som selve
|
||||
# meldingen (rostret på laget); sletting av kommentar er kun forfatteren,
|
||||
# samme "ekte privat rom" begrunnelse som `delete_team_message`.
|
||||
|
||||
|
||||
async def _get_team_message_or_404(conn, team_id: str, message_id: str) -> None:
|
||||
exists = await conn.fetchval(
|
||||
"SELECT id FROM message WHERE id = $1 AND team_id = $2 AND scope = 'team'",
|
||||
message_id,
|
||||
team_id,
|
||||
)
|
||||
if exists is None:
|
||||
raise app_error(404, "NOT_FOUND", "Meldingen finnes ikke.")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/reaction",
|
||||
response_model=list[ReactionSummary],
|
||||
)
|
||||
async def set_team_message_reaction(
|
||||
team_id: str,
|
||||
organization_id: str,
|
||||
message_id: str,
|
||||
payload: ReactionIn,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[ReactionSummary]:
|
||||
if payload.emoji not in ALLOWED_REACTION_EMOJIS:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Ugyldig emoji.")
|
||||
async with org_connection(organization_id) as conn:
|
||||
if not await user_is_rostered_on_team(conn, team_id, user.user_id):
|
||||
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
||||
await _get_team_message_or_404(conn, team_id, message_id)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO message_reaction (organization_id, message_id, user_id, emoji)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (message_id, user_id) DO UPDATE SET emoji = EXCLUDED.emoji, created_at = now()
|
||||
""",
|
||||
organization_id,
|
||||
message_id,
|
||||
user.user_id,
|
||||
payload.emoji,
|
||||
)
|
||||
summaries = await _message_reactions(conn, [message_id], user.user_id)
|
||||
return summaries.get(message_id, [])
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/reaction",
|
||||
response_model=list[ReactionSummary],
|
||||
)
|
||||
async def remove_team_message_reaction(
|
||||
team_id: str,
|
||||
organization_id: str,
|
||||
message_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[ReactionSummary]:
|
||||
async with org_connection(organization_id) as conn:
|
||||
if not await user_is_rostered_on_team(conn, team_id, user.user_id):
|
||||
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
||||
await _get_team_message_or_404(conn, team_id, message_id)
|
||||
await conn.execute(
|
||||
"DELETE FROM message_reaction WHERE message_id = $1 AND user_id = $2",
|
||||
message_id,
|
||||
user.user_id,
|
||||
)
|
||||
summaries = await _message_reactions(conn, [message_id], user.user_id)
|
||||
return summaries.get(message_id, [])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/comments",
|
||||
response_model=list[CommentOut],
|
||||
)
|
||||
async def list_team_message_comments(
|
||||
team_id: str,
|
||||
organization_id: str,
|
||||
message_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[CommentOut]:
|
||||
async with org_connection(organization_id) as conn:
|
||||
if not await user_is_rostered_on_team(conn, team_id, user.user_id):
|
||||
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
||||
await _get_team_message_or_404(conn, team_id, message_id)
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT {_MESSAGE_COMMENT_COLUMNS} FROM message_comment
|
||||
WHERE message_id = $1 ORDER BY created_at ASC
|
||||
""",
|
||||
message_id,
|
||||
)
|
||||
return [_row_to_message_comment(r) for r in rows]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/comments",
|
||||
response_model=CommentOut,
|
||||
status_code=201,
|
||||
)
|
||||
async def post_team_message_comment(
|
||||
team_id: str,
|
||||
organization_id: str,
|
||||
message_id: str,
|
||||
payload: CommentIn,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> CommentOut:
|
||||
body = payload.body.strip()
|
||||
if not body:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Kommentaren kan ikke være tom.")
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
if not await user_is_rostered_on_team(conn, team_id, user.user_id):
|
||||
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
|
||||
await _get_team_message_or_404(conn, team_id, message_id)
|
||||
if payload.parent_comment_id is not None:
|
||||
parent_exists = await conn.fetchval(
|
||||
"SELECT id FROM message_comment WHERE id = $1 AND message_id = $2",
|
||||
payload.parent_comment_id,
|
||||
message_id,
|
||||
)
|
||||
if parent_exists is None:
|
||||
raise app_error(404, "NOT_FOUND", "Kommentaren du svarer på finnes ikke.")
|
||||
|
||||
display_name = await _resolve_author_display_name(conn, organization_id, user.user_id)
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
INSERT INTO message_comment
|
||||
(organization_id, message_id, parent_comment_id, author_user_id, author_display_name, body)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING {_MESSAGE_COMMENT_COLUMNS}
|
||||
""",
|
||||
organization_id,
|
||||
message_id,
|
||||
payload.parent_comment_id,
|
||||
user.user_id,
|
||||
display_name,
|
||||
body,
|
||||
)
|
||||
return _row_to_message_comment(row)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/comments/{comment_id}",
|
||||
status_code=204,
|
||||
)
|
||||
async def delete_team_message_comment(
|
||||
team_id: str,
|
||||
organization_id: str,
|
||||
message_id: str,
|
||||
comment_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
"""Kun forfatteren selv, INGEN unntak -- samme "ekte privat rom"-
|
||||
begrunnelse som `delete_team_message`."""
|
||||
async with org_connection(organization_id) as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT author_user_id::text AS author_user_id FROM message_comment
|
||||
WHERE id = $1 AND message_id = $2
|
||||
""",
|
||||
comment_id,
|
||||
message_id,
|
||||
)
|
||||
if row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Kommentaren finnes ikke.")
|
||||
if row["author_user_id"] != user.user_id:
|
||||
raise app_error(403, "NOT_COMMENT_AUTHOR", "Du kan kun slette dine egne kommentarer.")
|
||||
await conn.execute("DELETE FROM message_comment WHERE id = $1", comment_id)
|
||||
|
||||
|
||||
@router.websocket("/ws/orgs/{organization_id}/teams/{team_id}/messages")
|
||||
async def team_chat_ws(websocket: WebSocket, organization_id: str, team_id: str) -> None:
|
||||
user = await get_current_user_from_websocket(websocket)
|
||||
|
|
@ -284,15 +550,23 @@ async def get_feed(
|
|||
if not code_matches(row["join_code"], code):
|
||||
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
||||
|
||||
# Nyeste øverst (2026-08-06, brukerens eksplisitte ønske -- "over
|
||||
# alt", samme rekkefølge som round_message og lag-chatten nå).
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT {_MESSAGE_COLUMNS} FROM message
|
||||
WHERE tournament_id = $1 AND scope = 'tournament_feed'
|
||||
ORDER BY created_at
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
tournament_id,
|
||||
)
|
||||
return [_row_to_message(r) for r in rows]
|
||||
message_ids = [r["id"] for r in rows]
|
||||
reactions_by_id = await _message_reactions(conn, message_ids, user.user_id if user else None)
|
||||
comment_counts = await _message_comment_counts(conn, message_ids)
|
||||
return [
|
||||
_row_to_message(r, reactions_by_id.get(r["id"]), comment_counts.get(r["id"], 0))
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@public_router.post("/{tournament_id}/feed", response_model=MessageOut, status_code=201)
|
||||
|
|
@ -381,6 +655,192 @@ async def delete_feed_message(
|
|||
await conn.execute("DELETE FROM message WHERE id = $1", message_id)
|
||||
|
||||
|
||||
# --- Reaksjoner/kommentarer på oppslagstavle-innlegg (ADR-046) -----------
|
||||
# Samme "hvem kan poste = hvem kan reagere/kommentere"-modell som selve
|
||||
# innlegget (`_may_post_to_feed`); sletting av kommentar er forfatter
|
||||
# ELLER org-admin, samme moderasjonsvei som `delete_feed_message`.
|
||||
|
||||
|
||||
async def _get_feed_message_or_404(conn, tournament_id: str, message_id: str) -> None:
|
||||
exists = await conn.fetchval(
|
||||
"SELECT id FROM message WHERE id = $1 AND tournament_id = $2 AND scope = 'tournament_feed'",
|
||||
message_id,
|
||||
tournament_id,
|
||||
)
|
||||
if exists is None:
|
||||
raise app_error(404, "NOT_FOUND", "Meldingen finnes ikke.")
|
||||
|
||||
|
||||
@public_router.put("/{tournament_id}/feed/{message_id}/reaction", response_model=list[ReactionSummary])
|
||||
async def set_feed_message_reaction(
|
||||
tournament_id: str,
|
||||
message_id: str,
|
||||
payload: ReactionIn,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[ReactionSummary]:
|
||||
if payload.emoji not in ALLOWED_REACTION_EMOJIS:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Ugyldig emoji.")
|
||||
organization_id = await resolve_org(tournament_id)
|
||||
async with org_connection(organization_id) as conn:
|
||||
if not await _may_post_to_feed(conn, user, organization_id, tournament_id):
|
||||
raise app_error(
|
||||
403,
|
||||
"NOT_A_PARTICIPANT",
|
||||
"Du må være medlem av organisasjonen eller delta i turneringen for å reagere.",
|
||||
)
|
||||
await _get_feed_message_or_404(conn, tournament_id, message_id)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO message_reaction (organization_id, message_id, user_id, emoji)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (message_id, user_id) DO UPDATE SET emoji = EXCLUDED.emoji, created_at = now()
|
||||
""",
|
||||
organization_id,
|
||||
message_id,
|
||||
user.user_id,
|
||||
payload.emoji,
|
||||
)
|
||||
summaries = await _message_reactions(conn, [message_id], user.user_id)
|
||||
return summaries.get(message_id, [])
|
||||
|
||||
|
||||
@public_router.delete("/{tournament_id}/feed/{message_id}/reaction", response_model=list[ReactionSummary])
|
||||
async def remove_feed_message_reaction(
|
||||
tournament_id: str,
|
||||
message_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[ReactionSummary]:
|
||||
organization_id = await resolve_org(tournament_id)
|
||||
async with org_connection(organization_id) as conn:
|
||||
if not await _may_post_to_feed(conn, user, organization_id, tournament_id):
|
||||
raise app_error(
|
||||
403,
|
||||
"NOT_A_PARTICIPANT",
|
||||
"Du må være medlem av organisasjonen eller delta i turneringen for å reagere.",
|
||||
)
|
||||
await _get_feed_message_or_404(conn, tournament_id, message_id)
|
||||
await conn.execute(
|
||||
"DELETE FROM message_reaction WHERE message_id = $1 AND user_id = $2",
|
||||
message_id,
|
||||
user.user_id,
|
||||
)
|
||||
summaries = await _message_reactions(conn, [message_id], user.user_id)
|
||||
return summaries.get(message_id, [])
|
||||
|
||||
|
||||
@public_router.get("/{tournament_id}/feed/{message_id}/comments", response_model=list[CommentOut])
|
||||
async def list_feed_message_comments(
|
||||
tournament_id: str,
|
||||
message_id: str,
|
||||
code: str | None = None,
|
||||
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||
) -> list[CommentOut]:
|
||||
organization_id = await resolve_org(tournament_id)
|
||||
async with org_connection(organization_id) as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
|
||||
)
|
||||
if row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||
if not code_matches(row["join_code"], code):
|
||||
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
||||
await _get_feed_message_or_404(conn, tournament_id, message_id)
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT {_MESSAGE_COMMENT_COLUMNS} FROM message_comment
|
||||
WHERE message_id = $1 ORDER BY created_at ASC
|
||||
""",
|
||||
message_id,
|
||||
)
|
||||
return [_row_to_message_comment(r) for r in rows]
|
||||
|
||||
|
||||
@public_router.post("/{tournament_id}/feed/{message_id}/comments", response_model=CommentOut, status_code=201)
|
||||
async def post_feed_message_comment(
|
||||
tournament_id: str,
|
||||
message_id: str,
|
||||
payload: CommentIn,
|
||||
code: str | None = None,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> CommentOut:
|
||||
body = payload.body.strip()
|
||||
if not body:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Kommentaren kan ikke være tom.")
|
||||
organization_id = await resolve_org(tournament_id)
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
row = await conn.fetchrow(
|
||||
"SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
|
||||
)
|
||||
if row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||
if not code_matches(row["join_code"], code):
|
||||
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
|
||||
if not await _may_post_to_feed(conn, user, organization_id, tournament_id):
|
||||
raise app_error(
|
||||
403,
|
||||
"NOT_A_PARTICIPANT",
|
||||
"Du må være medlem av organisasjonen eller delta i turneringen for å kommentere.",
|
||||
)
|
||||
await _get_feed_message_or_404(conn, tournament_id, message_id)
|
||||
if payload.parent_comment_id is not None:
|
||||
parent_exists = await conn.fetchval(
|
||||
"SELECT id FROM message_comment WHERE id = $1 AND message_id = $2",
|
||||
payload.parent_comment_id,
|
||||
message_id,
|
||||
)
|
||||
if parent_exists is None:
|
||||
raise app_error(404, "NOT_FOUND", "Kommentaren du svarer på finnes ikke.")
|
||||
|
||||
display_name = await _resolve_author_display_name(conn, organization_id, user.user_id)
|
||||
inserted = await conn.fetchrow(
|
||||
f"""
|
||||
INSERT INTO message_comment
|
||||
(organization_id, message_id, parent_comment_id, author_user_id, author_display_name, body)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING {_MESSAGE_COMMENT_COLUMNS}
|
||||
""",
|
||||
organization_id,
|
||||
message_id,
|
||||
payload.parent_comment_id,
|
||||
user.user_id,
|
||||
display_name,
|
||||
body,
|
||||
)
|
||||
return _row_to_message_comment(inserted)
|
||||
|
||||
|
||||
@public_router.delete("/{tournament_id}/feed/{message_id}/comments/{comment_id}", status_code=204)
|
||||
async def delete_feed_message_comment(
|
||||
tournament_id: str,
|
||||
message_id: str,
|
||||
comment_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
"""Forfatteren selv, ELLER org-eier/admin -- samme moderasjonsvei som
|
||||
`delete_feed_message`."""
|
||||
organization_id = await resolve_org(tournament_id)
|
||||
async with org_connection(organization_id) as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT author_user_id::text AS author_user_id FROM message_comment
|
||||
WHERE id = $1 AND message_id = $2
|
||||
""",
|
||||
comment_id,
|
||||
message_id,
|
||||
)
|
||||
if row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Kommentaren finnes ikke.")
|
||||
if row["author_user_id"] != user.user_id and not await is_org_admin(
|
||||
conn, organization_id, user.user_id
|
||||
):
|
||||
raise app_error(
|
||||
403,
|
||||
"NOT_COMMENT_AUTHOR",
|
||||
"Du kan kun slette dine egne kommentarer (eller som organisasjonsadministrator).",
|
||||
)
|
||||
await conn.execute("DELETE FROM message_comment WHERE id = $1", comment_id)
|
||||
|
||||
|
||||
@router.websocket("/ws/public/tournaments/{tournament_id}/feed")
|
||||
async def feed_ws(websocket: WebSocket, tournament_id: str, code: str | None = None) -> None:
|
||||
user = await get_current_user_from_websocket(websocket)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ from ..auth import CurrentUser, get_current_user, get_current_user_optional
|
|||
from ..db import plain_connection
|
||||
from ..errors import app_error, translate_db_errors
|
||||
from ..realtime import broadcast_round_update
|
||||
from .rounds import _get_viewable_round_or_404
|
||||
from .rounds import _get_viewable_round_or_404, _resolve_round_message_author_name
|
||||
|
||||
router = APIRouter(tags=["round-messages"])
|
||||
|
||||
|
|
@ -40,6 +40,26 @@ _ROUND_MESSAGE_COLUMNS = """
|
|||
id::text, round_id::text, author_user_id::text, author_display_name, body, image_key, created_at
|
||||
"""
|
||||
|
||||
# ADR-046: ett fast, kuratert reaksjonssett -- "like (eller andre emojier)",
|
||||
# ikke et fritt emoji-utvalg. Delt med messaging.py (importert derfra).
|
||||
ALLOWED_REACTION_EMOJIS = {"👍", "❤️", "😂", "😮", "😢", "🙏"}
|
||||
|
||||
|
||||
class ReactionIn(BaseModel):
|
||||
emoji: str
|
||||
|
||||
|
||||
class ReactionSummary(BaseModel):
|
||||
emoji: str
|
||||
count: int
|
||||
reacted_by_me: bool
|
||||
# Fulle navn (roster-kontekst, ikke direkte adressering -- CLAUDE.md
|
||||
# navneformat-regel), i rekkefølgen de reagerte -- brukeren ba
|
||||
# eksplisitt 2026-08-07 om å kunne se HVEM som reagerte, ikke bare et
|
||||
# antall. Hentes i SAMME grupperte batch-spørring som selve
|
||||
# oppsummeringen (array_agg), ingen ekstra tur-retur ved visning.
|
||||
reactors: list[str] = []
|
||||
|
||||
|
||||
class RoundMessageOut(BaseModel):
|
||||
id: str
|
||||
|
|
@ -49,9 +69,13 @@ class RoundMessageOut(BaseModel):
|
|||
body: str | None
|
||||
image_url: str | None
|
||||
created_at: str
|
||||
reactions: list[ReactionSummary] = []
|
||||
comment_count: int = 0
|
||||
|
||||
|
||||
def _row_to_round_message(row) -> RoundMessageOut:
|
||||
def _row_to_round_message(
|
||||
row, reactions: list[ReactionSummary] | None = None, comment_count: int = 0
|
||||
) -> RoundMessageOut:
|
||||
return RoundMessageOut(
|
||||
id=row["id"],
|
||||
round_id=row["round_id"],
|
||||
|
|
@ -60,22 +84,60 @@ def _row_to_round_message(row) -> RoundMessageOut:
|
|||
body=row["body"],
|
||||
image_url=storage.public_url(row["image_key"]) if row["image_key"] else None,
|
||||
created_at=row["created_at"].isoformat(),
|
||||
reactions=reactions or [],
|
||||
comment_count=comment_count,
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_round_message_author_name(conn, user_id: str) -> str:
|
||||
"""Frosset ved skrivetidspunkt, samme prinsipp som
|
||||
`message.author_display_name` -- her FULLT navn (forfatter-liste, ikke
|
||||
direkte adressering, CLAUDE.md navneformat-regel). Ingen org-scopet
|
||||
`player.display_name` å falle tilbake på her (runder er ikke
|
||||
org-scopet) -- `app_user.display_name` er NOT NULL og alltid til
|
||||
stede, så ingen ekstra fallback trengs."""
|
||||
row = await conn.fetchrow(
|
||||
"SELECT first_name, last_name, display_name FROM app_user WHERE id = $1", user_id
|
||||
async def _round_message_reactions(
|
||||
conn, message_ids: list[str], viewer_user_id: str | None
|
||||
) -> dict[str, list[ReactionSummary]]:
|
||||
"""Grupperte reaksjonsoppsummeringer for en batch med round_message-ID-er
|
||||
-- unngår N+1 når en meldingsliste rendres."""
|
||||
if not message_ids:
|
||||
return {}
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT rmr.round_message_id::text AS message_id, rmr.emoji, count(*) AS count,
|
||||
COALESCE(bool_or(rmr.user_id = $2), false) AS reacted_by_me,
|
||||
array_agg(
|
||||
COALESCE(NULLIF(btrim(au.first_name || ' ' || au.last_name), ''), au.display_name)
|
||||
ORDER BY rmr.created_at
|
||||
) AS reactors
|
||||
FROM round_message_reaction rmr
|
||||
JOIN app_user au ON au.id = rmr.user_id
|
||||
WHERE rmr.round_message_id = ANY($1::uuid[])
|
||||
GROUP BY rmr.round_message_id, rmr.emoji
|
||||
""",
|
||||
message_ids,
|
||||
viewer_user_id,
|
||||
)
|
||||
if row["first_name"] and row["last_name"]:
|
||||
return f"{row['first_name']} {row['last_name']}"
|
||||
return row["display_name"]
|
||||
result: dict[str, list[ReactionSummary]] = {}
|
||||
for r in rows:
|
||||
result.setdefault(r["message_id"], []).append(
|
||||
ReactionSummary(
|
||||
emoji=r["emoji"],
|
||||
count=r["count"],
|
||||
reacted_by_me=r["reacted_by_me"],
|
||||
reactors=list(r["reactors"]),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def _round_message_comment_counts(conn, message_ids: list[str]) -> dict[str, int]:
|
||||
if not message_ids:
|
||||
return {}
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT round_message_id::text AS message_id, count(*) AS count
|
||||
FROM round_message_comment
|
||||
WHERE round_message_id = ANY($1::uuid[])
|
||||
GROUP BY round_message_id
|
||||
""",
|
||||
message_ids,
|
||||
)
|
||||
return {r["message_id"]: r["count"] for r in rows}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -90,11 +152,21 @@ async def list_round_messages(
|
|||
) -> list[RoundMessageOut]:
|
||||
async with plain_connection() as conn:
|
||||
await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
|
||||
# Nyeste øverst (2026-08-06, brukerens eksplisitte ønske) -- dette
|
||||
# er en oppdateringsstrøm om runden, ikke en samtale man leser
|
||||
# kronologisk fra start (ulikt lag-chatten, som beholder eldst-
|
||||
# først -- se app/routers/messaging.py).
|
||||
rows = await conn.fetch(
|
||||
f"SELECT {_ROUND_MESSAGE_COLUMNS} FROM round_message WHERE round_id = $1 ORDER BY created_at",
|
||||
f"SELECT {_ROUND_MESSAGE_COLUMNS} FROM round_message WHERE round_id = $1 ORDER BY created_at DESC",
|
||||
round_id,
|
||||
)
|
||||
return [_row_to_round_message(r) for r in rows]
|
||||
message_ids = [r["id"] for r in rows]
|
||||
reactions_by_id = await _round_message_reactions(conn, message_ids, user.user_id if user else None)
|
||||
comment_counts = await _round_message_comment_counts(conn, message_ids)
|
||||
return [
|
||||
_row_to_round_message(r, reactions_by_id.get(r["id"]), comment_counts.get(r["id"], 0))
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/rounds/{round_id}/messages", response_model=RoundMessageOut, status_code=201)
|
||||
|
|
@ -169,6 +241,210 @@ async def delete_round_message(
|
|||
await broadcast_round_update(round_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reaksjoner (ADR-046) -- "kan se = kan bidra", samme som selve innlegget.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _get_round_message_or_404(conn, round_id: str, message_id: str) -> None:
|
||||
exists = await conn.fetchval(
|
||||
"SELECT id FROM round_message WHERE id = $1 AND round_id = $2", message_id, round_id
|
||||
)
|
||||
if exists is None:
|
||||
raise app_error(404, "NOT_FOUND", "Innlegget finnes ikke.")
|
||||
|
||||
|
||||
@router.put("/rounds/{round_id}/messages/{message_id}/reaction", response_model=list[ReactionSummary])
|
||||
async def set_round_message_reaction(
|
||||
round_id: str,
|
||||
message_id: str,
|
||||
payload: ReactionIn,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[ReactionSummary]:
|
||||
if payload.emoji not in ALLOWED_REACTION_EMOJIS:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Ugyldig emoji.")
|
||||
async with plain_connection() as conn:
|
||||
await _get_viewable_round_or_404(conn, round_id, user.user_id)
|
||||
await _get_round_message_or_404(conn, round_id, message_id)
|
||||
# Én reaksjon per bruker per innlegg -- bytte av emoji erstatter,
|
||||
# stables ikke (ADR-046).
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO round_message_reaction (round_message_id, user_id, emoji)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (round_message_id, user_id) DO UPDATE SET emoji = EXCLUDED.emoji, created_at = now()
|
||||
""",
|
||||
message_id,
|
||||
user.user_id,
|
||||
payload.emoji,
|
||||
)
|
||||
summaries = await _round_message_reactions(conn, [message_id], user.user_id)
|
||||
return summaries.get(message_id, [])
|
||||
|
||||
|
||||
@router.delete("/rounds/{round_id}/messages/{message_id}/reaction", response_model=list[ReactionSummary])
|
||||
async def remove_round_message_reaction(
|
||||
round_id: str,
|
||||
message_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[ReactionSummary]:
|
||||
async with plain_connection() as conn:
|
||||
await _get_viewable_round_or_404(conn, round_id, user.user_id)
|
||||
await _get_round_message_or_404(conn, round_id, message_id)
|
||||
await conn.execute(
|
||||
"DELETE FROM round_message_reaction WHERE round_message_id = $1 AND user_id = $2",
|
||||
message_id,
|
||||
user.user_id,
|
||||
)
|
||||
summaries = await _round_message_reactions(conn, [message_id], user.user_id)
|
||||
return summaries.get(message_id, [])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trådede kommentarer (ADR-046) -- flat lagring (parent_comment_id), frontend
|
||||
# bygger tre-strukturen. Kronologisk (eldst først) -- en samtaletråd leses
|
||||
# top-til-bunn, ulikt de kronologisk OMVENDTE innleggs-/meldingsstrømmene
|
||||
# ("nyeste først, over alt", 2026-08-06) som dette henger under.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ROUND_MESSAGE_COMMENT_COLUMNS = """
|
||||
id::text, round_message_id::text, parent_comment_id::text, author_user_id::text,
|
||||
author_display_name, body, created_at
|
||||
"""
|
||||
|
||||
|
||||
class CommentIn(BaseModel):
|
||||
body: str
|
||||
parent_comment_id: str | None = None
|
||||
|
||||
|
||||
class CommentOut(BaseModel):
|
||||
id: str
|
||||
round_message_id: str
|
||||
parent_comment_id: str | None
|
||||
author_user_id: str
|
||||
author_display_name: str
|
||||
body: str
|
||||
created_at: str
|
||||
|
||||
|
||||
def _row_to_round_message_comment(row) -> CommentOut:
|
||||
return CommentOut(
|
||||
id=row["id"],
|
||||
round_message_id=row["round_message_id"],
|
||||
parent_comment_id=row["parent_comment_id"],
|
||||
author_user_id=row["author_user_id"],
|
||||
author_display_name=row["author_display_name"],
|
||||
body=row["body"],
|
||||
created_at=row["created_at"].isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/rounds/{round_id}/messages/{message_id}/comments", response_model=list[CommentOut])
|
||||
async def list_round_message_comments(
|
||||
round_id: str,
|
||||
message_id: str,
|
||||
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||
) -> list[CommentOut]:
|
||||
async with plain_connection() as conn:
|
||||
await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
|
||||
await _get_round_message_or_404(conn, round_id, message_id)
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT {_ROUND_MESSAGE_COMMENT_COLUMNS} FROM round_message_comment
|
||||
WHERE round_message_id = $1 ORDER BY created_at ASC
|
||||
""",
|
||||
message_id,
|
||||
)
|
||||
return [_row_to_round_message_comment(r) for r in rows]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rounds/{round_id}/messages/{message_id}/comments", response_model=CommentOut, status_code=201
|
||||
)
|
||||
async def post_round_message_comment(
|
||||
round_id: str,
|
||||
message_id: str,
|
||||
payload: CommentIn,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> CommentOut:
|
||||
body = payload.body.strip()
|
||||
if not body:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Kommentaren kan ikke være tom.")
|
||||
|
||||
async with plain_connection() as conn, translate_db_errors():
|
||||
await _get_viewable_round_or_404(conn, round_id, user.user_id)
|
||||
await _get_round_message_or_404(conn, round_id, message_id)
|
||||
if payload.parent_comment_id is not None:
|
||||
parent_exists = await conn.fetchval(
|
||||
"SELECT id FROM round_message_comment WHERE id = $1 AND round_message_id = $2",
|
||||
payload.parent_comment_id,
|
||||
message_id,
|
||||
)
|
||||
if parent_exists is None:
|
||||
raise app_error(404, "NOT_FOUND", "Kommentaren du svarer på finnes ikke.")
|
||||
|
||||
display_name = await _resolve_round_message_author_name(conn, user.user_id)
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
INSERT INTO round_message_comment
|
||||
(round_message_id, parent_comment_id, author_user_id, author_display_name, body)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING {_ROUND_MESSAGE_COMMENT_COLUMNS}
|
||||
""",
|
||||
message_id,
|
||||
payload.parent_comment_id,
|
||||
user.user_id,
|
||||
display_name,
|
||||
body,
|
||||
)
|
||||
comment = _row_to_round_message_comment(row)
|
||||
# INGEN broadcast_round_update her (ADR-046) -- kommentarer/reaksjoner
|
||||
# har bevisst ingen live-push til andre samtidige seere i v1 (kun egen
|
||||
# handling refetcher). Ville ellers trigget RoundMessages sin
|
||||
# "noe endret seg"-refetch for ALLE meldinger på siden ved hver eneste
|
||||
# kommentar, og med det kollapset enhver allerede utvidet tråd andre
|
||||
# steder på siden -- utilsiktet UX-regresjon oppdaget under
|
||||
# nettleserverifisering.
|
||||
return comment
|
||||
|
||||
|
||||
@router.delete("/rounds/{round_id}/messages/{message_id}/comments/{comment_id}", status_code=204)
|
||||
async def delete_round_message_comment(
|
||||
round_id: str,
|
||||
message_id: str,
|
||||
comment_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
"""Forfatteren selv, ELLER rundens eier -- samme modell som selve
|
||||
innlegget (`delete_round_message`). Kaskade-sletter eventuelle svar
|
||||
under (FK ON DELETE CASCADE, ADR-046)."""
|
||||
async with plain_connection() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT c.author_user_id::text AS author_user_id, r.owner_user_id::text AS owner_user_id
|
||||
FROM round_message_comment c
|
||||
JOIN round_message rm ON rm.id = c.round_message_id
|
||||
JOIN round r ON r.id = rm.round_id
|
||||
WHERE c.id = $1 AND c.round_message_id = $2 AND rm.round_id = $3
|
||||
""",
|
||||
comment_id,
|
||||
message_id,
|
||||
round_id,
|
||||
)
|
||||
if row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Kommentaren finnes ikke.")
|
||||
if user.user_id not in (row["author_user_id"], row["owner_user_id"]):
|
||||
raise app_error(
|
||||
403,
|
||||
"NOT_COMMENT_AUTHOR",
|
||||
"Du kan kun slette dine egne kommentarer (eller som rundeeier).",
|
||||
)
|
||||
await conn.execute("DELETE FROM round_message_comment WHERE id = $1", comment_id)
|
||||
# INGEN broadcast_round_update her -- se samme begrunnelse i
|
||||
# post_round_message_comment over.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Samlet feed -- egne runder + venners synlige runder (ADR-036 fase 2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -186,6 +462,8 @@ class FeedEntryOut(BaseModel):
|
|||
body: str | None
|
||||
image_url: str | None
|
||||
created_at: str
|
||||
reactions: list[ReactionSummary] = []
|
||||
comment_count: int = 0
|
||||
|
||||
|
||||
@router.get("/feed", response_model=list[FeedEntryOut])
|
||||
|
|
@ -251,6 +529,9 @@ async def get_feed(
|
|||
before,
|
||||
limit,
|
||||
)
|
||||
entry_ids = [r["id"] for r in rows]
|
||||
reactions_by_id = await _round_message_reactions(conn, entry_ids, user.user_id)
|
||||
comment_counts = await _round_message_comment_counts(conn, entry_ids)
|
||||
return [
|
||||
FeedEntryOut(
|
||||
id=r["id"],
|
||||
|
|
@ -264,6 +545,8 @@ async def get_feed(
|
|||
body=r["body"],
|
||||
image_url=storage.public_url(r["image_key"]) if r["image_key"] else None,
|
||||
created_at=r["created_at"].isoformat(),
|
||||
reactions=reactions_by_id.get(r["id"], []),
|
||||
comment_count=comment_counts.get(r["id"], 0),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
|
|
|||
|
|
@ -36,15 +36,17 @@ import json
|
|||
import math
|
||||
import secrets
|
||||
import traceback
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
from .. import teeoff_client
|
||||
from .. import storage, teeoff_client
|
||||
from ..auth import CurrentUser, get_current_user, get_current_user_from_websocket, get_current_user_optional
|
||||
from ..config import settings
|
||||
from ..db import plain_connection
|
||||
|
|
@ -5075,6 +5077,334 @@ async def update_hole(
|
|||
return RoundHoleOut(**dict(row), strokes_received=strokes_received)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slag-for-slag GPS-avstandsmåling (ADR-048) -- helt frikoblet fra
|
||||
# round_hole sine faste hull-aggregater over. round_shot kjenner kun
|
||||
# round_hole_id, og arver dermed eierskap (deltaker ELLER side) transitivt
|
||||
# via round_hole sin eksisterende round_participant_id/round_side_id-XOR
|
||||
# (migrasjon 031) -- ingen egen XOR-sjekk trengs her, se 060_round_shot.sql.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ShotIn(BaseModel):
|
||||
club: str = Field(max_length=50)
|
||||
distance_meters: float = Field(gt=0, lt=500)
|
||||
start_method: Literal["gps", "map_tap"]
|
||||
start_lat: float = Field(ge=-90, le=90)
|
||||
start_lng: float = Field(ge=-180, le=180)
|
||||
end_lat: float = Field(ge=-90, le=90)
|
||||
end_lng: float = Field(ge=-180, le=180)
|
||||
|
||||
|
||||
class ShotOut(BaseModel):
|
||||
id: str
|
||||
shot_number: int
|
||||
club: str
|
||||
distance_meters: float
|
||||
start_method: str
|
||||
start_lat: float
|
||||
start_lng: float
|
||||
end_lat: float
|
||||
end_lng: float
|
||||
shared_round_message_id: str | None
|
||||
# Avledet fra shared_round_message_id -> round_message.image_key, ikke
|
||||
# lagret på round_shot selv (samme "aldri lagre det som kan avledes"-
|
||||
# prinsipp som storage.public_url() ellers brukes for).
|
||||
shared_image_url: str | None
|
||||
captured_at: str
|
||||
|
||||
|
||||
_SHOT_SELECT = """
|
||||
SELECT rs.id::text AS id, rs.shot_number, rs.club, rs.distance_meters, rs.start_method,
|
||||
rs.start_lat, rs.start_lng, rs.end_lat, rs.end_lng,
|
||||
rs.shared_round_message_id::text AS shared_round_message_id,
|
||||
rs.captured_at, rm.image_key AS shared_image_key
|
||||
FROM round_shot rs
|
||||
LEFT JOIN round_message rm ON rm.id = rs.shared_round_message_id
|
||||
"""
|
||||
|
||||
|
||||
def _shot_out(row: asyncpg.Record) -> ShotOut:
|
||||
return ShotOut(
|
||||
id=row["id"],
|
||||
shot_number=row["shot_number"],
|
||||
club=row["club"],
|
||||
distance_meters=float(row["distance_meters"]),
|
||||
start_method=row["start_method"],
|
||||
start_lat=row["start_lat"],
|
||||
start_lng=row["start_lng"],
|
||||
end_lat=row["end_lat"],
|
||||
end_lng=row["end_lng"],
|
||||
shared_round_message_id=row["shared_round_message_id"],
|
||||
shared_image_url=storage.public_url(row["shared_image_key"]) if row["shared_image_key"] else None,
|
||||
captured_at=row["captured_at"].isoformat(),
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_round_message_author_name(conn, user_id: str) -> str:
|
||||
"""Frosset ved skrivetidspunkt, samme prinsipp som
|
||||
`message.author_display_name` -- her FULLT navn (forfatter-liste, ikke
|
||||
direkte adressering, CLAUDE.md navneformat-regel). Ingen org-scopet
|
||||
`player.display_name` å falle tilbake på her (runder er ikke
|
||||
org-scopet) -- `app_user.display_name` er NOT NULL og alltid til
|
||||
stede, så ingen ekstra fallback trengs.
|
||||
|
||||
Flyttet hit fra round_messages.py (som importerer den tilbake herfra)
|
||||
slik at share_shot under kan gjenbruke den uten sirkulær import --
|
||||
rounds.py var allerede den importerte, ikke-importerende parten."""
|
||||
row = await conn.fetchrow(
|
||||
"SELECT first_name, last_name, display_name FROM app_user WHERE id = $1", user_id
|
||||
)
|
||||
if row["first_name"] and row["last_name"]:
|
||||
return f"{row['first_name']} {row['last_name']}"
|
||||
return row["display_name"]
|
||||
|
||||
|
||||
async def _resolve_participant_hole_id(conn, round_id: str, participant_id: str, hole_number: int) -> str:
|
||||
participant_exists = await conn.fetchval(
|
||||
"SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2", participant_id, round_id
|
||||
)
|
||||
if not participant_exists:
|
||||
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
|
||||
hole_id = await conn.fetchval(
|
||||
"SELECT id::text FROM round_hole WHERE round_participant_id = $1 AND hole_number = $2",
|
||||
participant_id, hole_number,
|
||||
)
|
||||
if hole_id is None:
|
||||
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.")
|
||||
return hole_id
|
||||
|
||||
|
||||
async def _resolve_side_hole_id(conn, round_id: str, side_id: str, hole_number: int) -> str:
|
||||
side_exists = await conn.fetchval("SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", side_id, round_id)
|
||||
if not side_exists:
|
||||
raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
|
||||
hole_id = await conn.fetchval(
|
||||
"SELECT id::text FROM round_hole WHERE round_side_id = $1 AND hole_number = $2",
|
||||
side_id, hole_number,
|
||||
)
|
||||
if hole_id is None:
|
||||
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne siden.")
|
||||
return hole_id
|
||||
|
||||
|
||||
async def _list_shots_for_hole(conn, hole_id: str) -> list[ShotOut]:
|
||||
rows = await conn.fetch(_SHOT_SELECT + " WHERE rs.round_hole_id = $1 ORDER BY rs.shot_number", hole_id)
|
||||
return [_shot_out(r) for r in rows]
|
||||
|
||||
|
||||
async def _insert_shot(conn, hole_id: str, user_id: str, body: ShotIn) -> ShotOut:
|
||||
async with translate_db_errors():
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO round_shot (
|
||||
round_hole_id, recorded_by_user_id, shot_number, club, distance_meters,
|
||||
start_method, start_lat, start_lng, end_lat, end_lng
|
||||
)
|
||||
VALUES (
|
||||
$1, $2,
|
||||
COALESCE((SELECT MAX(shot_number) FROM round_shot WHERE round_hole_id = $1), 0) + 1,
|
||||
$3, $4, $5, $6, $7, $8, $9
|
||||
)
|
||||
RETURNING id::text AS id
|
||||
""",
|
||||
hole_id, user_id, body.club, body.distance_meters,
|
||||
body.start_method, body.start_lat, body.start_lng, body.end_lat, body.end_lng,
|
||||
)
|
||||
full_row = await conn.fetchrow(_SHOT_SELECT + " WHERE rs.id = $1", row["id"])
|
||||
return _shot_out(full_row)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/rounds/{round_id}/participants/{participant_id}/holes/{hole_number}/shots",
|
||||
response_model=list[ShotOut],
|
||||
)
|
||||
async def list_participant_shots(
|
||||
round_id: str, participant_id: str, hole_number: int,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[ShotOut]:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
hole_id = await _resolve_participant_hole_id(conn, round_id, participant_id, hole_number)
|
||||
return await _list_shots_for_hole(conn, hole_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rounds/{round_id}/participants/{participant_id}/holes/{hole_number}/shots",
|
||||
response_model=ShotOut,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_participant_shot(
|
||||
round_id: str, participant_id: str, hole_number: int, body: ShotIn,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> ShotOut:
|
||||
async with plain_connection() as conn:
|
||||
# Samme begrunnelse som update_hole -- en lenket medspiller kan
|
||||
# måle slag for hele flighten, ikke bare sin egen rad.
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
hole_id = await _resolve_participant_hole_id(conn, round_id, participant_id, hole_number)
|
||||
shot = await _insert_shot(conn, hole_id, user.user_id, body)
|
||||
await broadcast_round_update(round_id)
|
||||
return shot
|
||||
|
||||
|
||||
@router.get(
|
||||
"/rounds/{round_id}/sides/{side_id}/holes/{hole_number}/shots",
|
||||
response_model=list[ShotOut],
|
||||
)
|
||||
async def list_side_shots(
|
||||
round_id: str, side_id: str, hole_number: int,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[ShotOut]:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
hole_id = await _resolve_side_hole_id(conn, round_id, side_id, hole_number)
|
||||
return await _list_shots_for_hole(conn, hole_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/rounds/{round_id}/sides/{side_id}/holes/{hole_number}/shots",
|
||||
response_model=ShotOut,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_side_shot(
|
||||
round_id: str, side_id: str, hole_number: int, body: ShotIn,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> ShotOut:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
hole_id = await _resolve_side_hole_id(conn, round_id, side_id, hole_number)
|
||||
shot = await _insert_shot(conn, hole_id, user.user_id, body)
|
||||
await broadcast_round_update(round_id)
|
||||
return shot
|
||||
|
||||
|
||||
@router.delete("/rounds/{round_id}/shots/{shot_id}", status_code=204)
|
||||
async def delete_shot(
|
||||
round_id: str, shot_id: str, user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
# Eierskap-agnostisk sletting -- round_shot kjenner kun round_hole_id,
|
||||
# så tilhørighet til DENNE runden bekreftes via round_hole sin
|
||||
# round_participant- ELLER round_side-kjede (forsvar mot id-gjetting
|
||||
# på tvers av runder).
|
||||
deleted = await conn.fetchval(
|
||||
"""
|
||||
DELETE FROM round_shot rs
|
||||
USING round_hole rh
|
||||
LEFT JOIN round_participant rp ON rp.id = rh.round_participant_id
|
||||
LEFT JOIN round_side rsd ON rsd.id = rh.round_side_id
|
||||
WHERE rs.round_hole_id = rh.id
|
||||
AND rs.id = $1
|
||||
AND COALESCE(rp.round_id, rsd.round_id) = $2
|
||||
RETURNING rs.id
|
||||
""",
|
||||
shot_id, round_id,
|
||||
)
|
||||
if deleted is None:
|
||||
raise app_error(404, "NOT_FOUND", "Slaget finnes ikke på denne runden.")
|
||||
await broadcast_round_update(round_id)
|
||||
|
||||
|
||||
def _encode_polyline(points: list[tuple[float, float]]) -> str:
|
||||
"""Google sin polyline-algoritme (5 desimaler) -- kun det appen faktisk
|
||||
trenger, nøyaktig to punkter for et rett slag-overlay på Mapbox Static
|
||||
Images API (path-overlay-parameteren krever encoded polyline, se
|
||||
https://docs.mapbox.com/api/maps/static-images/)."""
|
||||
result: list[str] = []
|
||||
prev_lat = prev_lng = 0
|
||||
for lat, lng in points:
|
||||
lat_i = round(lat * 1e5)
|
||||
lng_i = round(lng * 1e5)
|
||||
for value, prev in ((lat_i, prev_lat), (lng_i, prev_lng)):
|
||||
delta = value - prev
|
||||
shifted = ~(delta << 1) if delta < 0 else (delta << 1)
|
||||
chunk = ""
|
||||
while shifted >= 0x20:
|
||||
chunk += chr((0x20 | (shifted & 0x1F)) + 63)
|
||||
shifted >>= 5
|
||||
chunk += chr(shifted + 63)
|
||||
result.append(chunk)
|
||||
prev_lat, prev_lng = lat_i, lng_i
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def _mapbox_static_snippet_url(start_lat: float, start_lng: float, end_lat: float, end_lng: float) -> str:
|
||||
encoded = urllib.parse.quote(_encode_polyline([(start_lat, start_lng), (end_lat, end_lng)]), safe="")
|
||||
overlay = (
|
||||
f"pin-s-a+ff5a1f({start_lng},{start_lat}),"
|
||||
f"pin-s-b+2f7a3f({end_lng},{end_lat}),"
|
||||
f"path-4+ff5a1f-0.9({encoded})"
|
||||
)
|
||||
return (
|
||||
f"https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/static/{overlay}/auto/600x400@2x"
|
||||
f"?padding=60&access_token={settings.MAPBOX_SECRET_TOKEN}"
|
||||
)
|
||||
|
||||
|
||||
class ShotShareIn(BaseModel):
|
||||
# Brukerredigert, forhåndsutfylt i klienten med en auto-generert tekst
|
||||
# ("⛳ {klubb}, {avstand} m – hull {n}") -- helt fritt herfra, samme
|
||||
# "ingen lengdebegrensning utover dette" som round_messages.py.
|
||||
body: str = Field(max_length=2000)
|
||||
|
||||
|
||||
@router.post("/rounds/{round_id}/shots/{shot_id}/share", response_model=ShotOut)
|
||||
async def share_shot(
|
||||
round_id: str, shot_id: str, body: ShotShareIn, user: CurrentUser = Depends(get_current_user),
|
||||
) -> ShotOut:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
shot_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT rs.id::text AS id, rs.start_lat, rs.start_lng, rs.end_lat, rs.end_lng
|
||||
FROM round_shot rs
|
||||
JOIN round_hole rh ON rh.id = rs.round_hole_id
|
||||
LEFT JOIN round_participant rp ON rp.id = rh.round_participant_id
|
||||
LEFT JOIN round_side rsd ON rsd.id = rh.round_side_id
|
||||
WHERE rs.id = $1 AND COALESCE(rp.round_id, rsd.round_id) = $2
|
||||
""",
|
||||
shot_id, round_id,
|
||||
)
|
||||
if shot_row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Slaget finnes ikke på denne runden.")
|
||||
|
||||
author_display_name = await _resolve_round_message_author_name(conn, user.user_id)
|
||||
|
||||
image_key = None
|
||||
if settings.MAPBOX_SECRET_TOKEN:
|
||||
url = _mapbox_static_snippet_url(
|
||||
shot_row["start_lat"], shot_row["start_lng"], shot_row["end_lat"], shot_row["end_lng"]
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
image_key = await storage.upload_image("round_messages", round_id, resp.content)
|
||||
except httpx.HTTPError:
|
||||
# Deling degraderer grasiøst til ren tekst uten satellitt-
|
||||
# utsnitt -- samme prinsipp som SMTP/push (config.py) --
|
||||
# fremfor å blokkere hele delingen på et Mapbox-utfall.
|
||||
image_key = None
|
||||
|
||||
async with translate_db_errors():
|
||||
message_row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO round_message (round_id, author_user_id, author_display_name, body, image_key)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id::text AS id
|
||||
""",
|
||||
round_id, user.user_id, author_display_name, body.body, image_key,
|
||||
)
|
||||
await conn.execute(
|
||||
"UPDATE round_shot SET shared_round_message_id = $1 WHERE id = $2",
|
||||
message_row["id"], shot_id,
|
||||
)
|
||||
full_row = await conn.fetchrow(_SHOT_SELECT + " WHERE rs.id = $1", shot_id)
|
||||
await broadcast_round_update(round_id)
|
||||
return _shot_out(full_row)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fullføring -- Adjusted Gross Score / Score Differential (ADR-033 Beslutning G)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -5345,6 +5675,12 @@ class PublicRoundOut(BaseModel):
|
|||
play_format: str
|
||||
completed_at: str | None
|
||||
owner_display_name: str
|
||||
# Lagt til 2026-08-06 -- RoundMessages (ADR-044) sin "forfatter ELLER
|
||||
# rundeeier kan slette"-sjekk trenger eierens app_user.id, ikke bare
|
||||
# visningsnavnet. Ikke en større personvern-innrømmelse enn navnet
|
||||
# selv (allerede eksponert over) -- en ugjennomsiktig UUID, sammenlignet
|
||||
# mot innlogget bruker sin egen id, avslører ingenting nytt.
|
||||
owner_user_id: str
|
||||
participants: list[PublicRoundParticipantOut]
|
||||
sides: list[RoundSideOut]
|
||||
|
||||
|
|
@ -5390,6 +5726,7 @@ async def _build_public_round_out(conn, round_id: str) -> PublicRoundOut:
|
|||
play_format=round_row["play_format"],
|
||||
completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
|
||||
owner_display_name=owner_display_name or "Ukjent",
|
||||
owner_user_id=round_row["owner_user_id"],
|
||||
participants=[PublicRoundParticipantOut(**dict(r)) for r in participant_rows],
|
||||
sides=[RoundSideOut(**dict(r)) for r in side_rows],
|
||||
)
|
||||
|
|
@ -5450,6 +5787,72 @@ async def get_public_format_result(
|
|||
return await _build_format_result(conn, round_id)
|
||||
|
||||
|
||||
async def _viewable_flight_rows(conn, round_id: str, viewer_user_id: str | None):
|
||||
"""Offentlig/tredjeparts-variant av `_accessible_flight_rows` (samme
|
||||
"avslør aldri en søsken-flight brukeren ikke har tilgang til"-prinsipp),
|
||||
men bruker `_can_view_round` (eier/medspiller/`public`/riktig-
|
||||
kategorisert `friends`-venn) i stedet for den strengere eier-eller-
|
||||
medspiller-sjekken -- dette endepunktet betjener også anonyme/
|
||||
tredjeparts lesere (ADR-036 fase 2), ikke bare eieren selv. Hver
|
||||
søsken-flight sjekkes UAVHENGIG (kan ha forskjellig visibility_mode
|
||||
enn anker-runden), samme prinsipp som resten av tilskuer-visningen."""
|
||||
anchor = await conn.fetchrow(
|
||||
"SELECT id::text AS id, flight_group_id::text AS flight_group_id FROM round WHERE id = $1",
|
||||
round_id,
|
||||
)
|
||||
group_id = anchor["flight_group_id"]
|
||||
if group_id is None:
|
||||
sibling_ids = [round_id]
|
||||
else:
|
||||
sibling_rows = await conn.fetch(
|
||||
"SELECT id::text AS id FROM round WHERE flight_group_id = $1 ORDER BY created_at", group_id
|
||||
)
|
||||
sibling_ids = [r["id"] for r in sibling_rows]
|
||||
rows = []
|
||||
for sibling_id in sibling_ids:
|
||||
if not await _can_view_round(conn, sibling_id, viewer_user_id):
|
||||
continue
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT r.id::text AS id, r.name, r.course_name_snapshot, r.played_at,
|
||||
r.started_at, r.completed_at
|
||||
FROM round r WHERE r.id = $1
|
||||
""",
|
||||
sibling_id,
|
||||
)
|
||||
if row is not None:
|
||||
rows.append(row)
|
||||
return group_id, rows
|
||||
|
||||
|
||||
@router.get("/public/rounds/{round_id}/flight-group", response_model=FlightGroupOut)
|
||||
async def get_public_flight_group(
|
||||
round_id: str,
|
||||
user: CurrentUser | None = Depends(get_current_user_optional),
|
||||
) -> FlightGroupOut:
|
||||
async with plain_connection() as conn:
|
||||
await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
|
||||
group_id, rows = await _viewable_flight_rows(conn, round_id, user.user_id if user else None)
|
||||
flights = []
|
||||
for row in rows:
|
||||
participant_count = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM round_participant WHERE round_id = $1", row["id"]
|
||||
)
|
||||
flights.append(
|
||||
FlightSummaryOut(
|
||||
round_id=row["id"],
|
||||
name=row["name"],
|
||||
course_name_snapshot=row["course_name_snapshot"],
|
||||
played_at=row["played_at"],
|
||||
started_at=row["started_at"].isoformat() if row["started_at"] else None,
|
||||
completed_at=row["completed_at"].isoformat() if row["completed_at"] else None,
|
||||
participant_count=participant_count,
|
||||
is_anchor=row["id"] == round_id,
|
||||
)
|
||||
)
|
||||
return FlightGroupOut(flight_group_id=group_id, flights=flights)
|
||||
|
||||
|
||||
@router.websocket("/ws/public/rounds/{round_id}/live")
|
||||
async def public_round_live_ws(websocket: WebSocket, round_id: str) -> None:
|
||||
user = await get_current_user_from_websocket(websocket)
|
||||
|
|
|
|||
|
|
@ -28,7 +28,13 @@ from .config import settings
|
|||
from .errors import app_error
|
||||
|
||||
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
|
||||
# 20 MB rå input, FØR konvertering -- denne grensen beskytter ikke selve
|
||||
# lagringsstørrelsen (det gjør AVIF-konverteringen under, som uansett gir en
|
||||
# liten fil uavhengig av inputstørrelse), den er kun en øvre sanity-grense.
|
||||
# Hevet fra 8 MB 2026-08-06 -- moderne telefonkameraer (bl.a. Pixel 8 Pro i
|
||||
# høy kvalitet/HDR+) produserer rutinemessig JPEG-er i 8-15 MB-klassen, som
|
||||
# den gamle grensen avviste FØR bildet fikk sjansen til å konverteres ned.
|
||||
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||
_MAX_DIMENSION = 2400 # nedskaler urimelig store bilder før konvertering
|
||||
_AVIF_QUALITY = 65
|
||||
|
||||
|
|
@ -69,7 +75,7 @@ async def read_optional_image(image: UploadFile | None) -> bytes | None:
|
|||
raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")
|
||||
raw = await image.read(MAX_UPLOAD_BYTES + 1)
|
||||
if len(raw) > MAX_UPLOAD_BYTES:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).")
|
||||
raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 20 MB).")
|
||||
return raw
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ services:
|
|||
TEECUP_VAPID_PRIVATE_KEY: ${TEECUP_VAPID_PRIVATE_KEY}
|
||||
TEECUP_VAPID_PUBLIC_KEY: ${TEECUP_VAPID_PUBLIC_KEY}
|
||||
TEECUP_VAPID_SUBJECT: ${TEECUP_VAPID_SUBJECT}
|
||||
TEECUP_MAPBOX_SECRET_TOKEN: ${TEECUP_MAPBOX_SECRET_TOKEN}
|
||||
networks:
|
||||
- teeoff_default
|
||||
restart: unless-stopped
|
||||
|
|
@ -48,6 +49,7 @@ services:
|
|||
context: ./frontend
|
||||
args:
|
||||
TEECUP_API_ORIGIN: http://teecup_api:8000
|
||||
NEXT_PUBLIC_MAPBOX_TOKEN: ${NEXT_PUBLIC_MAPBOX_TOKEN}
|
||||
container_name: teecup_frontend
|
||||
networks:
|
||||
- teeoff_default
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ COPY . .
|
|||
# bare som en runtime env-var på "docker run".
|
||||
ARG TEECUP_API_ORIGIN=http://teecup_api:8000
|
||||
ENV TEECUP_API_ORIGIN=$TEECUP_API_ORIGIN
|
||||
# NEXT_PUBLIC_-variabler bakes inn i klient-bundlen VED BUILD-TID (ulikt
|
||||
# TEECUP_API_ORIGIN over, som også trengs ved runtime -- se runner-steget).
|
||||
# Offentlig, URL-restriktert Mapbox-token (ADR-048) -- trygt at denne når
|
||||
# nettleseren, IKKE samme som backendens hemmelige MAPBOX_SECRET_TOKEN.
|
||||
ARG NEXT_PUBLIC_MAPBOX_TOKEN=""
|
||||
ENV NEXT_PUBLIC_MAPBOX_TOKEN=$NEXT_PUBLIC_MAPBOX_TOKEN
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:22-slim AS runner
|
||||
|
|
|
|||
|
|
@ -204,3 +204,36 @@
|
|||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* /logg-inn -- parallell V0-utforskning (ADR-047-oppfølging, 2026-08-07) */
|
||||
/* Egen, skopet "clubhouse"-palett KUN brukt av components/teecup/* -- */
|
||||
/* additiv, rører ikke tokens over. Fast lys-modus (leser likt uansett */
|
||||
/* systemtema), siden dette er en isolert sammenligningsside, ikke en */
|
||||
/* endring av appens faktiske tema. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
:root {
|
||||
--tee: #8bbd51;
|
||||
--tee-strong: #2f6b1e;
|
||||
--cup: #ff5427;
|
||||
--cup-strong: #cf3c17;
|
||||
--clubhouse-bg: #f3f6ec;
|
||||
--clubhouse-card: #ffffff;
|
||||
--clubhouse-ink: #17200f;
|
||||
--clubhouse-muted: #515b45;
|
||||
--clubhouse-border: #dbe1cd;
|
||||
--clubhouse-field: #f7f9f3;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-tee: var(--tee);
|
||||
--color-tee-strong: var(--tee-strong);
|
||||
--color-cup: var(--cup);
|
||||
--color-cup-strong: var(--cup-strong);
|
||||
--color-clubhouse-bg: var(--clubhouse-bg);
|
||||
--color-clubhouse-card: var(--clubhouse-card);
|
||||
--color-clubhouse-ink: var(--clubhouse-ink);
|
||||
--color-clubhouse-muted: var(--clubhouse-muted);
|
||||
--color-clubhouse-border: var(--clubhouse-border);
|
||||
--color-clubhouse-field: var(--clubhouse-field);
|
||||
}
|
||||
|
|
|
|||
41
frontend/app/logg-inn/page.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { Metadata } from "next"
|
||||
import { TeeCupWordmark } from "@/components/teecup/wordmark"
|
||||
import { TeeCupAuth } from "@/components/teecup/teecup-auth"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Logg inn – TeeCup",
|
||||
description: "Logg inn på TeeCup for live-scoring og golfturneringer med vennegjengen, klubben eller bedriften.",
|
||||
}
|
||||
|
||||
export default function LoggInnPage() {
|
||||
return (
|
||||
<main className="relative flex min-h-dvh flex-col items-center justify-center overflow-hidden bg-clubhouse-bg px-4 py-8 text-clubhouse-ink">
|
||||
{/* Quiet turf-horizon backdrop — the one bit of atmosphere, kept subtle. */}
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-tee/15 to-transparent" />
|
||||
|
||||
<div className="relative z-10 w-full max-w-md">
|
||||
{/* Header */}
|
||||
<header className="mb-6 flex flex-col items-center text-center">
|
||||
<TeeCupWordmark className="h-11" />
|
||||
<p className="mt-3 text-pretty text-[0.9375rem] font-medium text-clubhouse-muted">
|
||||
Live-scoring og turneringer — fra første tee til pokalen.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Card — the scorecard. Continuation screens (2FA, link confirm) can
|
||||
reuse this exact container. */}
|
||||
<section className="overflow-hidden rounded-3xl border border-clubhouse-border bg-clubhouse-card shadow-[0_1px_0_rgba(23,32,15,0.04),0_18px_40px_-24px_rgba(23,32,15,0.35)]">
|
||||
{/* Signature element: a fairway-green scorecard header stripe. */}
|
||||
<div className="h-2 w-full bg-gradient-to-r from-tee-strong via-tee to-cup" />
|
||||
<div className="p-6 sm:p-8">
|
||||
<TeeCupAuth />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="mt-6 text-center text-[0.875rem] leading-relaxed text-clubhouse-muted">
|
||||
Ved å logge inn godtar du vilkårene våre. Trenger du hjelp? Ta kontakt med klubben din.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import type { MetadataRoute } from "next"
|
||||
|
||||
// PWA-manifest (ADR-028). Ikonene under er en enkel, midlertidig
|
||||
// merkevareriktig placeholder (grønn golfflagg) generert programmatisk --
|
||||
// se FEATURE_BACKLOG.md, skal erstattes med ekte design senere.
|
||||
// PWA-manifest (ADR-028). Ikonene under er beskåret fra den ekte TeeCup-
|
||||
// logoen (2026-08-06, se public/teecup-wordmark.svg for hele ordmerket
|
||||
// ikon+tekst) -- erstatter den tidligere midlertidige grønne
|
||||
// golfflagg-placeholderen.
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "TeeCup",
|
||||
|
|
|
|||
6
frontend/app/watch/[id]/leaderboard/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { WatchLeaderboard } from "@/components/watch-leaderboard"
|
||||
|
||||
export default async function WatchLeaderboardPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
return <WatchLeaderboard roundId={id} />
|
||||
}
|
||||
6
frontend/app/watch/[id]/scorecard/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { WatchScorecard } from "@/components/watch-scorecard"
|
||||
|
||||
export default async function WatchScorecardPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
return <WatchScorecard roundId={id} />
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ import { Label } from "@/components/ui/label"
|
|||
import { Wordmark } from "@/components/wordmark"
|
||||
import { TwoFactorSetupForm } from "@/components/two-factor-flow"
|
||||
import { CourseTemplateEditor, type CourseTemplateValues } from "@/components/course-template-editor"
|
||||
import { BottomTabBar } from "@/components/dashboard"
|
||||
|
||||
// ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de
|
||||
// org-scopede `player`-radene organisatorer administrerer.
|
||||
|
|
@ -263,7 +264,7 @@ export function AccountSettings() {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<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 pb-24 pt-6 sm:pt-8">
|
||||
{settingUp2fa ? (
|
||||
<div className="rounded-3xl border border-border bg-card p-6 shadow-md shadow-black/8 sm:p-8">
|
||||
<TwoFactorSetupForm
|
||||
|
|
@ -337,6 +338,8 @@ export function AccountSettings() {
|
|||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<BottomTabBar active="profile" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -655,7 +658,9 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
|||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
// "image/*" -- mange mobilnettlesere viser bare galleri, ikke
|
||||
// kamera, når accept lister flere eksplisitte MIME-typer.
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAvatarSelected}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,25 @@
|
|||
// respekterer nøyaktig samme regel som all annen rundedeling (ADR-036
|
||||
// fase 2, `_can_view_round`s 'friends'-gren) -- håndhevet server-side, ikke
|
||||
// duplisert her.
|
||||
//
|
||||
// "Clubhouse"-visuell redesign (2026-08-07, V0, egen parallell utforskning
|
||||
// -- IKKE samme retning som "Forest Green" over). Brukeren: "full
|
||||
// overtagelse, også bakgrunnen" -- eksplisitt bekreftet å gå imot
|
||||
// 2026-08-02-fiksen som fjernet en utilsiktet grønn hue fra de nøytrale
|
||||
// tokenene (DESIGN_SYSTEM.md), siden clubhouse-bakgrunnen er en BEVISST
|
||||
// varm/turf-tonet off-white, ikke en utilsiktet lekkasje. Gjenbruker
|
||||
// `--tee`/`--tee-strong`/`--cup`/`--cup-strong`/`--clubhouse-*`-tokenene og
|
||||
// `TeeCupWordmark` som ALLEREDE fantes i `globals.css`/`components/teecup/`
|
||||
// fra en tidligere, ennå ikke integrert `/logg-inn`-utforskning -- samme
|
||||
// palett, ikke funnet opp på nytt. Bevisst IKKE lagt til Bricolage-
|
||||
// visningsfonten (samme utelatelse som `/logg-inn` selv gjorde) -- Nunito
|
||||
// beholdt for å unngå en ekstra font-avhengighet for kun to skjermer.
|
||||
//
|
||||
// Omfangsgrenser (delte komponenter IKKE re-stylet her, se CHANGELOG.md):
|
||||
// `RoundCard`/`TournamentCard` (også brukt av /my-rounds) og `BottomTabBar`
|
||||
// (delt med de fem sidene som nettopp fikk den, venter på egen V0-prompt)
|
||||
// beholder sin eksisterende styling inntil videre -- kun selve
|
||||
// dashbord-skjermens EGNE, ikke-delte JSX er re-stylet i denne runden.
|
||||
|
||||
import type React from "react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
|
|
@ -57,6 +76,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { InstallPrompt } from "@/components/install-prompt"
|
||||
import { TeeCupWordmark } from "@/components/teecup/wordmark"
|
||||
import { RoundCard, type Round } from "@/components/round-card"
|
||||
import { TournamentCard, type Tournament } from "@/components/tournament-card"
|
||||
import { type TournamentStatus } from "@/components/tournament-status-badge"
|
||||
|
|
@ -432,8 +452,8 @@ export function Dashboard() {
|
|||
|
||||
if (loadingMe) {
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
|
||||
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4" style={{ borderColor: `${C.accent}33`, borderTopColor: C.accent }} />
|
||||
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-clubhouse-bg">
|
||||
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-tee-strong/20 border-t-tee-strong" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -469,26 +489,21 @@ export function Dashboard() {
|
|||
const playedCourses = [...coursesMap.values()].sort((a, b) => (a.lastPlayed < b.lastPlayed ? 1 : -1))
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||
<header
|
||||
className="sticky top-0 z-10 border-b bg-background pt-[env(safe-area-inset-top)]"
|
||||
style={{ borderColor: C.border }}
|
||||
>
|
||||
<div className="flex min-h-[100dvh] flex-col bg-clubhouse-bg">
|
||||
<header className="sticky top-0 z-10 border-b border-clubhouse-border bg-clubhouse-card/95 backdrop-blur pt-[env(safe-area-inset-top)] supports-[backdrop-filter]:bg-clubhouse-card/80">
|
||||
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
|
||||
<Link href="/dashboard" aria-label="TeeCup – til forsiden" className="flex items-center gap-2 rounded-xl">
|
||||
<span className="flex size-9 items-center justify-center rounded-xl" style={{ backgroundColor: C.primary }}>
|
||||
<Flag aria-hidden="true" className="size-5" fill={C.successBg} color={C.successBg} />
|
||||
</span>
|
||||
<span className="text-lg font-extrabold tracking-tight" style={{ color: C.ink }}>
|
||||
TeeCup
|
||||
</span>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
aria-label="TeeCup – til forsiden"
|
||||
className="flex items-center rounded-xl py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong"
|
||||
>
|
||||
<TeeCupWordmark className="h-7" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-1">
|
||||
<NotificationBell unreadCount={unreadNotifications} />
|
||||
<Link
|
||||
href="/account"
|
||||
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold transition-all duration-200 ease-in-out active:opacity-70"
|
||||
style={{ color: C.muted }}
|
||||
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold text-clubhouse-muted transition-all duration-200 ease-in-out hover:bg-clubhouse-field hover:text-clubhouse-ink active:opacity-70"
|
||||
>
|
||||
<UserCircle aria-hidden="true" className="size-4" />
|
||||
Konto
|
||||
|
|
@ -496,8 +511,7 @@ export function Dashboard() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold transition-all duration-200 ease-in-out active:opacity-70"
|
||||
style={{ color: C.muted }}
|
||||
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold text-clubhouse-muted transition-all duration-200 ease-in-out hover:bg-clubhouse-field hover:text-clubhouse-ink active:opacity-70"
|
||||
>
|
||||
<LogOut aria-hidden="true" className="size-4" />
|
||||
Logg ut
|
||||
|
|
@ -512,16 +526,16 @@ export function Dashboard() {
|
|||
klasse feil som å glemme at en fixed bar tar plass fra layouten). */}
|
||||
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-24 pt-6 sm:pt-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-extrabold tracking-tight text-balance" style={{ color: C.ink }}>
|
||||
<h1 className="text-2xl font-extrabold tracking-tight text-balance text-clubhouse-ink">
|
||||
God dag, {me.first_name ?? me.display_name}
|
||||
</h1>
|
||||
<p className="text-base leading-relaxed text-pretty" style={{ color: C.muted }}>
|
||||
<p className="text-base leading-relaxed text-pretty text-clubhouse-muted">
|
||||
Klar for en ny runde? Her er golfen din på ett sted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mt-4 text-sm font-medium" style={{ color: "#b3261e" }}>
|
||||
<p role="alert" className="mt-4 text-sm font-medium text-cup-strong">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -615,15 +629,13 @@ function NotificationBell({ unreadCount }: { unreadCount: number }) {
|
|||
<Link
|
||||
href="/my-notifications"
|
||||
aria-label={label}
|
||||
className="relative inline-flex size-11 items-center justify-center rounded-lg transition-all duration-200 ease-in-out active:opacity-70 focus-visible:outline-none focus-visible:ring-2"
|
||||
style={{ color: C.muted, outlineColor: C.accent }}
|
||||
className="relative inline-flex size-11 items-center justify-center rounded-lg text-clubhouse-muted transition-all duration-200 ease-in-out hover:bg-clubhouse-field hover:text-clubhouse-ink active:opacity-70 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong"
|
||||
>
|
||||
<Bell aria-hidden="true" className="size-5" />
|
||||
{hasUnread && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute right-1 top-1 flex min-w-[18px] items-center justify-center rounded-full px-1 text-[11px] font-bold leading-none"
|
||||
style={{ height: 18, backgroundColor: C.warmInk, color: "#ffffff", boxShadow: `0 0 0 2px var(--background)` }}
|
||||
className="absolute right-1 top-1 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-cup px-1 text-[11px] font-bold leading-none text-white ring-2 ring-clubhouse-card"
|
||||
>
|
||||
{badgeText}
|
||||
</span>
|
||||
|
|
@ -648,9 +660,19 @@ function QuickActions({
|
|||
return (
|
||||
<section id="hurtighandlinger" aria-label="Hurtighandlinger" className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-3 gap-2 sm:gap-3">
|
||||
<QuickAction icon={Flag} label="Ny runde" href="/my-rounds/new" />
|
||||
<QuickAction icon={Trophy} label="Ny turnering" onClick={() => onOpenChange(open === "tournament" ? null : "tournament")} />
|
||||
<QuickAction icon={KeyRound} label="Bli med med kode" onClick={() => onOpenChange(open === "code" ? null : "code")} />
|
||||
<QuickAction icon={Flag} label="Ny runde" href="/my-rounds/new" primary />
|
||||
<QuickAction
|
||||
icon={Trophy}
|
||||
label="Ny turnering"
|
||||
onClick={() => onOpenChange(open === "tournament" ? null : "tournament")}
|
||||
expanded={open === "tournament"}
|
||||
/>
|
||||
<QuickAction
|
||||
icon={KeyRound}
|
||||
label="Bli med med kode"
|
||||
onClick={() => onOpenChange(open === "code" ? null : "code")}
|
||||
expanded={open === "code"}
|
||||
/>
|
||||
</div>
|
||||
{open === "tournament" && (
|
||||
<NewTournamentInline organizations={organizations} onCreate={onCreateTournament} onClose={() => onOpenChange(null)} />
|
||||
|
|
@ -665,34 +687,39 @@ function QuickAction({
|
|||
label,
|
||||
href,
|
||||
onClick,
|
||||
primary = false,
|
||||
expanded = false,
|
||||
}: {
|
||||
icon: typeof Flag
|
||||
label: string
|
||||
href?: string
|
||||
onClick?: () => void
|
||||
primary?: boolean
|
||||
expanded?: boolean
|
||||
}) {
|
||||
const style: React.CSSProperties = {
|
||||
backgroundColor: C.primary,
|
||||
color: C.primaryInk,
|
||||
boxShadow: "0 14px 30px -20px rgba(31, 107, 8, 0.55)",
|
||||
}
|
||||
const className =
|
||||
"flex min-h-[84px] flex-1 flex-col items-center justify-center gap-1.5 rounded-2xl px-2 py-3 text-center transition-all duration-200 ease-in-out active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
const className = cn(
|
||||
"flex min-h-[84px] flex-1 flex-col items-center justify-center gap-1.5 rounded-2xl border px-2 py-3 text-center font-semibold transition-all duration-200 ease-in-out active:scale-[0.98] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong",
|
||||
primary
|
||||
? "border-transparent bg-tee-strong text-white shadow-sm hover:brightness-110"
|
||||
: expanded
|
||||
? "border-tee-strong bg-tee/10 text-clubhouse-ink"
|
||||
: "border-clubhouse-border bg-clubhouse-card text-clubhouse-ink shadow-sm hover:border-tee-strong/50 hover:bg-tee/5",
|
||||
)
|
||||
const inner = (
|
||||
<>
|
||||
<Icon aria-hidden="true" className="size-5 sm:size-6" color={C.primaryInk} />
|
||||
<Icon aria-hidden="true" className={cn("size-5 sm:size-6", primary ? "text-white" : "text-tee-strong")} />
|
||||
<span className="text-xs font-bold leading-tight text-balance sm:text-sm">{label}</span>
|
||||
</>
|
||||
)
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className={className} style={style}>
|
||||
<Link href={href} className={className}>
|
||||
{inner}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={className} style={style}>
|
||||
<button type="button" onClick={onClick} aria-expanded={primary ? undefined : expanded} className={className}>
|
||||
{inner}
|
||||
</button>
|
||||
)
|
||||
|
|
@ -868,9 +895,7 @@ function JoinByCodeInline({ onClose }: { onClose: () => void }) {
|
|||
function SectionHeader({ title, action }: { title: string; action?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-extrabold tracking-tight" style={{ color: C.ink }}>
|
||||
{title}
|
||||
</h2>
|
||||
<h2 className="text-lg font-extrabold tracking-tight text-clubhouse-ink">{title}</h2>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -880,8 +905,7 @@ function SeeAllLink({ href, label }: { href: string; label: string }) {
|
|||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="inline-flex min-h-[44px] items-center gap-1 rounded-lg px-2 text-sm font-bold transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2"
|
||||
style={{ color: C.accent, outlineColor: C.accent }}
|
||||
className="inline-flex min-h-[44px] items-center gap-1 rounded-lg px-2 text-sm font-bold text-tee-strong transition-all duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong"
|
||||
>
|
||||
{label}
|
||||
<ChevronRight aria-hidden="true" className="size-4" />
|
||||
|
|
@ -901,20 +925,13 @@ function EmptyState({
|
|||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center gap-3 rounded-2xl border border-dashed px-6 py-8 text-center"
|
||||
style={{ borderColor: C.border, backgroundColor: "rgba(255,255,255,0.5)" }}
|
||||
>
|
||||
<div className="flex size-12 items-center justify-center rounded-xl" style={{ backgroundColor: C.tint }}>
|
||||
<Icon aria-hidden="true" className="size-6" color={C.muted} />
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-clubhouse-border bg-clubhouse-card/60 px-6 py-8 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-xl bg-clubhouse-field">
|
||||
<Icon aria-hidden="true" className="size-6 text-clubhouse-muted" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-base font-bold text-balance" style={{ color: C.ink }}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-pretty" style={{ color: C.muted }}>
|
||||
{description}
|
||||
</p>
|
||||
<h3 className="text-base font-bold text-balance text-clubhouse-ink">{title}</h3>
|
||||
<p className="text-sm leading-relaxed text-pretty text-clubhouse-muted">{description}</p>
|
||||
</div>
|
||||
{children ? <div className="mt-1 flex flex-wrap justify-center gap-2">{children}</div> : null}
|
||||
</div>
|
||||
|
|
@ -934,12 +951,12 @@ function ShortcutButton({
|
|||
onClick?: () => void
|
||||
variant?: "primary" | "outline"
|
||||
}) {
|
||||
const style: React.CSSProperties =
|
||||
const className = cn(
|
||||
"inline-flex min-h-[44px] items-center gap-2 rounded-xl px-4 text-sm font-bold transition-all duration-200 ease-in-out active:scale-[0.98] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong",
|
||||
variant === "primary"
|
||||
? { backgroundColor: C.primary, color: C.primaryInk }
|
||||
: { backgroundColor: C.card, color: C.ink, border: `1px solid ${C.border}` }
|
||||
const className =
|
||||
"inline-flex min-h-[44px] items-center gap-2 rounded-xl px-4 text-sm font-bold transition-all duration-200 ease-in-out active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
? "bg-tee-strong text-white hover:brightness-110"
|
||||
: "border border-clubhouse-border bg-clubhouse-card text-clubhouse-ink hover:bg-clubhouse-field",
|
||||
)
|
||||
const inner = (
|
||||
<>
|
||||
<Icon aria-hidden="true" className="size-4" />
|
||||
|
|
@ -948,13 +965,13 @@ function ShortcutButton({
|
|||
)
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className={className} style={style}>
|
||||
<Link href={href} className={className}>
|
||||
{inner}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={className} style={style}>
|
||||
<button type="button" onClick={onClick} className={className}>
|
||||
{inner}
|
||||
</button>
|
||||
)
|
||||
|
|
@ -986,9 +1003,8 @@ function LiveFriends({ friends }: { friends: ApiFriendOnCourse[] }) {
|
|||
return (
|
||||
<Link
|
||||
key={friend.round_id}
|
||||
href={`/my-rounds/${friend.round_id}`}
|
||||
className="flex min-w-[15rem] max-w-[16rem] shrink-0 snap-start flex-col gap-3 rounded-2xl p-4 transition-colors hover:bg-black/[0.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
style={{ backgroundColor: C.card, border: `1px solid ${C.borderSoft}` }}
|
||||
href={`/watch/${friend.round_id}`}
|
||||
className="flex min-w-[15rem] max-w-[16rem] shrink-0 snap-start flex-col gap-3 rounded-2xl border border-clubhouse-border bg-clubhouse-card p-4 shadow-sm transition hover:border-tee-strong/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative shrink-0">
|
||||
|
|
@ -999,26 +1015,32 @@ function LiveFriends({ friends }: { friends: ApiFriendOnCourse[] }) {
|
|||
{friendOnCourseInitials(friend.friend_name)}
|
||||
</span>
|
||||
{playing && (
|
||||
<span aria-hidden="true" className="absolute -right-0.5 -top-0.5 flex size-3.5 items-center justify-center rounded-full" style={{ backgroundColor: C.card }}>
|
||||
<span className="absolute inline-flex size-2.5 animate-ping rounded-full opacity-75" style={{ backgroundColor: C.successBg }} />
|
||||
<span className="relative inline-flex size-2.5 rounded-full" style={{ backgroundColor: C.successInk }} />
|
||||
<span aria-hidden="true" className="absolute -right-0.5 -top-0.5 flex size-3.5 items-center justify-center rounded-full bg-clubhouse-card">
|
||||
<span className="absolute inline-flex size-2.5 animate-ping rounded-full bg-cup opacity-75" />
|
||||
<span className="relative inline-flex size-2.5 rounded-full bg-cup" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-base font-bold" style={{ color: C.ink }}>
|
||||
{friend.friend_name}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 text-xs font-bold uppercase tracking-wide" style={{ color: playing ? C.successInk : C.muted }}>
|
||||
<span className="truncate text-base font-bold text-clubhouse-ink">{friend.friend_name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-xs font-bold uppercase tracking-wide",
|
||||
playing ? "text-cup-strong" : "text-clubhouse-muted",
|
||||
)}
|
||||
>
|
||||
{playing ? "Spiller nå" : friend.completed_at ? `Fullførte ${timeAgoLabel(friend.completed_at)}` : "Fullførte nylig"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm" style={{ color: C.muted }}>
|
||||
{friend.course_name}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full px-2.5 py-1 text-xs font-bold tabular-nums" style={{ backgroundColor: C.tint, color: C.ink }}>
|
||||
<span className="truncate text-sm text-clubhouse-muted">{friend.course_name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2.5 py-1 text-xs font-bold tabular-nums",
|
||||
playing ? "bg-tee/15 text-tee-strong" : "bg-clubhouse-field text-clubhouse-muted",
|
||||
)}
|
||||
>
|
||||
{playing
|
||||
? `Hull ${friend.holes_played} av ${friend.holes_planned}`
|
||||
: friend.score_to_par !== null
|
||||
|
|
@ -1031,9 +1053,7 @@ function LiveFriends({ friends }: { friends: ApiFriendOnCourse[] }) {
|
|||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm leading-relaxed" style={{ color: C.muted }}>
|
||||
Ingen venner er på banen akkurat nå.
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-clubhouse-muted">Ingen venner er på banen akkurat nå.</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
|
|
@ -1048,7 +1068,7 @@ function UpcomingRounds({ rounds, loading }: { rounds: Round[]; loading: boolean
|
|||
<SectionHeader title="Runder" action={rounds.length > 0 ? <SeeAllLink href="/my-rounds" label="Se alle" /> : null} />
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4" style={{ borderColor: `${C.accent}33`, borderTopColor: C.accent }} />
|
||||
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-tee-strong/20 border-t-tee-strong" />
|
||||
</div>
|
||||
) : visible.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
|
|
@ -1109,8 +1129,8 @@ function Sparkline({ values }: { values: number[] }) {
|
|||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1 rounded-full"
|
||||
style={{ height: `${height}%`, backgroundColor: i === values.length - 1 ? C.successInk : C.border }}
|
||||
className={cn("w-1 rounded-full", i === values.length - 1 ? "bg-tee-strong" : "bg-clubhouse-border")}
|
||||
style={{ height: `${height}%` }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
|
@ -1120,16 +1140,9 @@ function Sparkline({ values }: { values: number[] }) {
|
|||
|
||||
function StatTile({ label, value, children }: { label: string; value: string; children?: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-1.5 rounded-2xl p-4"
|
||||
style={{ backgroundColor: C.card, border: `1px solid ${C.border}`, boxShadow: "0 10px 26px -20px rgba(1, 44, 17, 0.35)" }}
|
||||
>
|
||||
<span className="text-xs font-medium uppercase tracking-wide" style={{ color: C.muted }}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-2xl font-extrabold tabular-nums sm:text-3xl" style={{ color: C.ink }}>
|
||||
{value}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5 rounded-2xl border border-clubhouse-border bg-clubhouse-card p-4 shadow-sm">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-clubhouse-muted">{label}</span>
|
||||
<span className="text-2xl font-extrabold tabular-nums text-clubhouse-ink sm:text-3xl">{value}</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1160,17 +1173,15 @@ function StatsSection({
|
|||
<StatTile label="HCP nå" value={hcpNow}>
|
||||
{hcpTrend !== null ? (
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="inline-flex items-center gap-1 text-xs font-bold" style={{ color: C.ink }}>
|
||||
<TrendIcon aria-hidden="true" className="size-4" color={C.successInk} />
|
||||
<span className="inline-flex items-center gap-1 text-xs font-bold text-tee-strong">
|
||||
<TrendIcon aria-hidden="true" className="size-4" />
|
||||
<span className="tabular-nums">{formatSigned(hcpTrend, 1)}</span>
|
||||
<span className="sr-only">{hcpTrend < 0 ? "handicap gått ned" : "handicap gått opp"}</span>
|
||||
</span>
|
||||
<Sparkline values={hcpHistory} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs font-medium" style={{ color: C.muted }}>
|
||||
Ingen data
|
||||
</span>
|
||||
<span className="text-xs font-medium text-clubhouse-muted">Ingen data</span>
|
||||
)}
|
||||
</StatTile>
|
||||
<StatTile label="Snitt til par" value={avgToPar} />
|
||||
|
|
@ -1186,31 +1197,25 @@ function PlayedCourses({ courses }: { courses: { name: string; visits: number; l
|
|||
<section aria-label="Spilte baner">
|
||||
<SectionHeader title="Spilte baner" />
|
||||
{courses.length > 0 ? (
|
||||
<ul
|
||||
className="flex flex-col overflow-hidden rounded-2xl"
|
||||
style={{ border: `1px solid ${C.border}`, backgroundColor: C.card, boxShadow: "0 10px 26px -20px rgba(1, 44, 17, 0.35)" }}
|
||||
>
|
||||
<ul className="flex flex-col overflow-hidden rounded-2xl border border-clubhouse-border bg-clubhouse-card shadow-sm">
|
||||
{courses.map((course, i) => {
|
||||
const roundLabel = course.visits === 1 ? "runde" : "runder"
|
||||
return (
|
||||
<li key={course.name} style={i > 0 ? { borderTop: `1px solid ${C.border}` } : undefined}>
|
||||
<li key={course.name} className={cn(i > 0 && "border-t border-clubhouse-border")}>
|
||||
<Link
|
||||
href={`/my-rounds/course/${encodeURIComponent(course.name)}`}
|
||||
className="flex min-h-[60px] items-center gap-3 px-4 py-3 transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
|
||||
style={{ outlineColor: C.accent }}
|
||||
className="flex min-h-[60px] items-center gap-3 px-4 py-3 transition-all duration-200 ease-in-out hover:bg-clubhouse-field focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tee-strong"
|
||||
>
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl" style={{ backgroundColor: C.tint }}>
|
||||
<MapPin aria-hidden="true" className="size-5" color={C.ink} />
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-tee/12 text-tee-strong">
|
||||
<MapPin aria-hidden="true" className="size-5" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-base font-bold" style={{ color: C.ink }}>
|
||||
{course.name}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium" style={{ color: C.muted }}>
|
||||
<span className="truncate text-base font-bold text-clubhouse-ink">{course.name}</span>
|
||||
<span className="truncate text-sm font-medium text-clubhouse-muted">
|
||||
{course.visits} {roundLabel} · Sist {formatDate(course.lastPlayed)}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight aria-hidden="true" className="size-5 shrink-0" color={C.muted} />
|
||||
<ChevronRight aria-hidden="true" className="size-5 shrink-0 text-clubhouse-muted" />
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
|
|
@ -1243,46 +1248,38 @@ function FriendsSection({ summary }: { summary: ApiFriendsSummary | null }) {
|
|||
<section aria-label="Venner">
|
||||
<SectionHeader title="Venner" />
|
||||
{friends.length > 0 ? (
|
||||
<div
|
||||
className="flex flex-col gap-4 rounded-2xl p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
style={{ backgroundColor: C.card, border: `1px solid ${C.border}`, boxShadow: "0 10px 26px -20px rgba(1, 44, 17, 0.35)" }}
|
||||
>
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-clubhouse-border bg-clubhouse-card p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center">
|
||||
{visible.map((friend, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="flex size-11 items-center justify-center rounded-full text-sm font-bold"
|
||||
style={{ backgroundColor: C.tint, color: C.ink, border: `2px solid ${C.card}`, marginLeft: i > 0 ? "-0.75rem" : undefined }}
|
||||
className="flex size-11 items-center justify-center rounded-full bg-clubhouse-field text-sm font-bold text-clubhouse-ink ring-2 ring-clubhouse-card"
|
||||
style={{ marginLeft: i > 0 ? "-0.75rem" : undefined }}
|
||||
>
|
||||
{friendInitials(friend.first_name, friend.last_name)}
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<span
|
||||
className="flex size-11 items-center justify-center rounded-full text-xs font-bold tabular-nums"
|
||||
style={{ backgroundColor: C.accent, color: C.inkOn, border: `2px solid ${C.card}`, marginLeft: "-0.75rem" }}
|
||||
className="flex size-11 items-center justify-center rounded-full bg-clubhouse-ink text-xs font-bold tabular-nums text-white ring-2 ring-clubhouse-card"
|
||||
style={{ marginLeft: "-0.75rem" }}
|
||||
>
|
||||
+{overflow}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-base font-bold" style={{ color: C.ink }}>
|
||||
<span className="text-base font-bold text-clubhouse-ink">
|
||||
{friends.length} {friends.length === 1 ? "venn" : "venner"}
|
||||
</span>
|
||||
{pending > 0 ? (
|
||||
<span
|
||||
className="inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-bold"
|
||||
style={{ backgroundColor: C.warmBg, color: C.warmInk }}
|
||||
>
|
||||
<span aria-hidden="true" className="size-1.5 rounded-full" style={{ backgroundColor: C.warmInk }} />
|
||||
<span className="inline-flex w-fit items-center gap-1.5 rounded-full bg-cup/15 px-2.5 py-0.5 text-xs font-bold text-cup-strong">
|
||||
<span aria-hidden="true" className="size-1.5 rounded-full bg-cup-strong" />
|
||||
{pending} ventende {pending === 1 ? "forespørsel" : "forespørsler"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm font-medium" style={{ color: C.muted }}>
|
||||
Ingen nye forespørsler
|
||||
</span>
|
||||
<span className="text-sm font-medium text-clubhouse-muted">Ingen nye forespørsler</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1305,10 +1302,7 @@ function OrganizationsFooter({ organizations }: { organizations: MyOrg[] }) {
|
|||
return (
|
||||
<div className="flex justify-center pt-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-3 text-sm font-medium transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2"
|
||||
style={{ color: C.muted, outlineColor: C.accent }}
|
||||
>
|
||||
<DropdownMenuTrigger className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-3 text-sm font-medium text-clubhouse-muted transition-all duration-200 ease-in-out hover:text-clubhouse-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong">
|
||||
<Building2 aria-hidden="true" className="size-4" />
|
||||
Dine organisasjoner ({organizations.length})
|
||||
</DropdownMenuTrigger>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from "react"
|
|||
import Link from "next/link"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PostEngagement, type ReactionSummary } from "./post-engagement"
|
||||
import { BottomTabBar } from "@/components/dashboard"
|
||||
|
||||
/** Rå feed-oppføring fra backend. */
|
||||
export type ApiFeedEntry = {
|
||||
|
|
@ -18,6 +20,8 @@ export type ApiFeedEntry = {
|
|||
body: string | null
|
||||
image_url: string | null
|
||||
created_at: string // ISO 8601
|
||||
reactions: ReactionSummary[]
|
||||
comment_count: number
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
|
@ -119,7 +123,7 @@ export function Feed() {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-xl px-4 pb-10">
|
||||
<main className="mx-auto w-full max-w-xl px-4 pb-24">
|
||||
{status === "loading" ? (
|
||||
<div className="flex justify-center py-16" aria-live="polite" aria-busy="true">
|
||||
<span
|
||||
|
|
@ -180,6 +184,8 @@ export function Feed() {
|
|||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<BottomTabBar active="more" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -188,44 +194,57 @@ function FeedCard({ entry, currentUserId }: { entry: ApiFeedEntry; currentUserId
|
|||
const isOthersRound = entry.round_owner_user_id !== currentUserId
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/my-rounds/${entry.round_id}`}
|
||||
className="flex flex-col gap-3 py-5 transition-colors hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
>
|
||||
{/* Kontekstlinje */}
|
||||
<p className="text-sm text-muted-foreground text-pretty">
|
||||
<span className="font-semibold text-foreground">{entry.author_display_name}</span>
|
||||
{" på "}
|
||||
{entry.course_name}
|
||||
{" · "}
|
||||
{formatPlayedAt(entry.played_at)}
|
||||
</p>
|
||||
|
||||
{/* Eierkontekst kun for andres runder */}
|
||||
{isOthersRound && (
|
||||
<p className="-mt-1.5 text-sm text-muted-foreground">
|
||||
{entry.round_owner_display_name} sin runde
|
||||
<div className="flex flex-col gap-3 py-5">
|
||||
{/* Selve innholdet er lenken til runden -- reaksjons-/kommentarkontrollene
|
||||
under må IKKE havne inni en <a> (ugyldig nøstet interaktivt innhold,
|
||||
og ville trigget navigering ved hvert tastetrykk/klikk der). */}
|
||||
<Link
|
||||
href={isOthersRound ? `/watch/${entry.round_id}` : `/my-rounds/${entry.round_id}`}
|
||||
className="flex flex-col gap-3 rounded-xl transition-colors hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
>
|
||||
{/* Kontekstlinje */}
|
||||
<p className="text-sm text-muted-foreground text-pretty">
|
||||
<span className="font-semibold text-foreground">{entry.author_display_name}</span>
|
||||
{" på "}
|
||||
{entry.course_name}
|
||||
{" · "}
|
||||
{formatPlayedAt(entry.played_at)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Bilde */}
|
||||
{entry.image_url && (
|
||||
<img
|
||||
src={entry.image_url || "/placeholder.svg"}
|
||||
alt=""
|
||||
className="w-full rounded-xl object-cover"
|
||||
style={{ maxHeight: MAX_IMAGE_HEIGHT }}
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
{/* Eierkontekst kun for andres runder */}
|
||||
{isOthersRound && (
|
||||
<p className="-mt-1.5 text-sm text-muted-foreground">
|
||||
{entry.round_owner_display_name} sin runde
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tekst */}
|
||||
{entry.body && (
|
||||
<p className={cn("text-base leading-relaxed text-foreground text-pretty whitespace-pre-wrap")}>
|
||||
{entry.body}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
{/* Bilde */}
|
||||
{entry.image_url && (
|
||||
<img
|
||||
src={entry.image_url || "/placeholder.svg"}
|
||||
alt=""
|
||||
className="w-full rounded-xl object-cover"
|
||||
style={{ maxHeight: MAX_IMAGE_HEIGHT }}
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tekst */}
|
||||
{entry.body && (
|
||||
<p className={cn("text-base leading-relaxed text-foreground text-pretty whitespace-pre-wrap")}>
|
||||
{entry.body}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<PostEngagement
|
||||
apiBase={`/rounds/${entry.round_id}/messages/${entry.id}`}
|
||||
currentUserId={currentUserId}
|
||||
initialReactions={entry.reactions}
|
||||
initialCommentCount={entry.comment_count}
|
||||
canModerate={entry.round_owner_user_id === currentUserId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
import { Input } from "@/components/ui/input"
|
||||
import { Wordmark } from "@/components/wordmark"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BottomTabBar } from "@/components/dashboard"
|
||||
|
||||
// --- Kategorier --------------------------------------------------------------
|
||||
// Rekkefølge og koder MÅ matche `Category` i app/routers/friends.py nøyaktig.
|
||||
|
|
@ -161,7 +162,7 @@ export function Friends() {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-8 sm:py-10">
|
||||
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-24 pt-8 sm:pt-10">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-foreground text-balance">
|
||||
Mine venner
|
||||
|
|
@ -184,6 +185,8 @@ export function Friends() {
|
|||
<FriendsSection friends={data?.friends ?? []} onChanged={loadFriends} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<BottomTabBar active="more" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,8 +88,8 @@ export function InstallPrompt() {
|
|||
setDismissed(true)
|
||||
}
|
||||
|
||||
// "Forest Green"-visuell redesign (2026-08-01) -- samme palett som
|
||||
// dashboard.tsx/login-form.tsx sin C-konstant, inline-anvendt siden dette
|
||||
// "Clubhouse"-visuell redesign (2026-08-07) -- samme `--tee`/`--tee-strong`-
|
||||
// familie som resten av dashbordets nye reskinn, inline-anvendt siden dette
|
||||
// er den eneste komponenten som trenger den. All logikk over uendret.
|
||||
return (
|
||||
<div
|
||||
|
|
@ -97,16 +97,16 @@ export function InstallPrompt() {
|
|||
aria-label="Installer TeeCup"
|
||||
className="relative overflow-hidden rounded-2xl p-4 sm:p-5"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #1a4325 0%, #123019 100%)",
|
||||
boxShadow: "0 18px 40px -22px rgba(1, 44, 17, 0.5)",
|
||||
background: "linear-gradient(180deg, #2f6b1e 0%, #1c4212 100%)",
|
||||
boxShadow: "0 18px 40px -22px rgba(23, 32, 15, 0.5)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3.5">
|
||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl" style={{ backgroundColor: "rgba(255,255,255,0.12)" }}>
|
||||
{platform === "ios" ? (
|
||||
<Share aria-hidden="true" className="size-5" color="#bfeec4" />
|
||||
<Share aria-hidden="true" className="size-5" color="#d8f0c4" />
|
||||
) : (
|
||||
<Download aria-hidden="true" className="size-5" color="#bfeec4" />
|
||||
<Download aria-hidden="true" className="size-5" color="#d8f0c4" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
|
|
@ -116,13 +116,13 @@ export function InstallPrompt() {
|
|||
Legg TeeCup til på hjemskjermen
|
||||
</h2>
|
||||
{platform === "ios" ? (
|
||||
<p className="text-sm leading-relaxed text-pretty" style={{ color: "#bfeec4" }}>
|
||||
<p className="text-sm leading-relaxed text-pretty" style={{ color: "#d8f0c4" }}>
|
||||
Trykk <Share aria-hidden="true" className="inline size-4 -translate-y-0.5" /> (Del) nederst i
|
||||
Safari, velg deretter <SquarePlus aria-hidden="true" className="inline size-4 -translate-y-0.5" />{" "}
|
||||
«Legg til på Hjem-skjerm».
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm leading-relaxed text-pretty" style={{ color: "#bfeec4" }}>
|
||||
<p className="text-sm leading-relaxed text-pretty" style={{ color: "#d8f0c4" }}>
|
||||
Rask tilgang, fungerer som en egen app, og virker delvis uten nett.
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -134,7 +134,7 @@ export function InstallPrompt() {
|
|||
type="button"
|
||||
onClick={handleInstall}
|
||||
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-xl px-4 text-sm font-bold transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
|
||||
style={{ backgroundColor: "#ffffff", color: "#012c11" }}
|
||||
style={{ backgroundColor: "#ffffff", color: "#17200f" }}
|
||||
>
|
||||
<Download aria-hidden="true" className="size-4" />
|
||||
Installer
|
||||
|
|
@ -144,7 +144,7 @@ export function InstallPrompt() {
|
|||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="inline-flex min-h-[44px] items-center rounded-xl px-3 text-sm font-semibold transition-colors hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
|
||||
style={{ color: "#bfeec4" }}
|
||||
style={{ color: "#d8f0c4" }}
|
||||
>
|
||||
Ikke nå
|
||||
</button>
|
||||
|
|
@ -157,7 +157,7 @@ export function InstallPrompt() {
|
|||
onClick={handleDismiss}
|
||||
aria-label="Lukk installasjonsoppfordringen"
|
||||
className="absolute right-2 top-2 flex size-9 items-center justify-center rounded-lg transition-colors hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
|
||||
style={{ color: "#bfeec4" }}
|
||||
style={{ color: "#d8f0c4" }}
|
||||
>
|
||||
<X aria-hidden="true" className="size-4" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -3331,8 +3331,20 @@ function Step5({
|
|||
{visibility === "venner" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-base leading-relaxed text-muted-foreground text-pretty">
|
||||
Alle er forhåndsvalgt — velg heller bort det du ikke vil skal se den.
|
||||
Alle er forhåndsvalgt — velg heller bort det du ikke vil skal se den. Bruk "Ingen"/"Alle"
|
||||
under for å starte på nytt, og finjuster deretter med enkeltkategoriene.
|
||||
</p>
|
||||
<div className="flex gap-2" role="group" aria-label="Velg alle eller ingen kategorier">
|
||||
<Pill selected={categories.size === 0} onClick={() => setCategories(new Set())}>
|
||||
Ingen
|
||||
</Pill>
|
||||
<Pill
|
||||
selected={categories.size === CATEGORY_OPTIONS.length}
|
||||
onClick={() => setCategories(new Set(CATEGORY_OPTIONS.map((c) => c.code)))}
|
||||
>
|
||||
Alle
|
||||
</Pill>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORY_OPTIONS.map(({ code, label }) => (
|
||||
<Pill key={code} selected={categories.has(code)} onClick={() => toggleCategory(code)}>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from "lucide-react"
|
||||
import { Wordmark } from "@/components/wordmark"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BottomTabBar } from "@/components/dashboard"
|
||||
|
||||
// V0-designet (zip 20, varsler-runden 2026-07-25) -- datalag skrevet om fra
|
||||
// mock til ekte fetch mot GET /notifications + POST .../read + .../read-all
|
||||
|
|
@ -157,6 +158,8 @@ export function Notifications() {
|
|||
</ul>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<BottomTabBar active="more" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button"
|
|||
import { Wordmark } from "@/components/wordmark"
|
||||
import { RoundCard, type Round } from "@/components/round-card"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { BottomTabBar } from "@/components/dashboard"
|
||||
|
||||
type ApiRoundParticipant = {
|
||||
id: string
|
||||
|
|
@ -136,7 +137,7 @@ export function OwnRounds() {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-8 sm:py-10">
|
||||
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-24 pt-8 sm:pt-10">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-3xl font-extrabold tracking-tight text-foreground text-balance">
|
||||
|
|
@ -179,6 +180,8 @@ export function OwnRounds() {
|
|||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<BottomTabBar active="rounds" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
674
frontend/components/post-engagement.tsx
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { MessageSquare, Reply, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Data contracts (match the real API shape) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type ReactionSummary = {
|
||||
emoji: string
|
||||
count: number
|
||||
reacted_by_me: boolean
|
||||
reactors: string[]
|
||||
}
|
||||
|
||||
export type CommentOut = {
|
||||
id: string
|
||||
parent_comment_id: string | null
|
||||
author_user_id: string
|
||||
author_display_name: string
|
||||
body: string
|
||||
created_at: string // ISO 8601
|
||||
}
|
||||
|
||||
export type PostEngagementProps = {
|
||||
/** Fully-resolved base path, e.g. "/rounds/{id}/messages/{id}". This
|
||||
* component appends "/reaction" and "/comments". */
|
||||
apiBase: string
|
||||
currentUserId: string | null
|
||||
initialReactions: ReactionSummary[]
|
||||
initialCommentCount: number
|
||||
canModerate: boolean
|
||||
}
|
||||
|
||||
/** Curated, fixed reaction set — exact order, no free picker. */
|
||||
const REACTION_SET = ["👍", "❤️", "😂", "😮", "😢", "🙏"] as const
|
||||
|
||||
/** Cap visual indent growth so deep threads stay readable on mobile. */
|
||||
const MAX_INDENT_DEPTH = 3
|
||||
|
||||
const GENERIC_ERROR = "Noe gikk galt. Prøv igjen."
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Parse `{ detail: { code, message } }` error envelope, with fallback. */
|
||||
async function readError(res: Response): Promise<string> {
|
||||
try {
|
||||
const data = await res.json()
|
||||
const msg = data?.detail?.message
|
||||
return typeof msg === "string" && msg.trim() !== "" ? msg : GENERIC_ERROR
|
||||
} catch {
|
||||
return GENERIC_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
/** "Kari, Ola og 3 andre reagerte" — 2026-08-07, brukerens eksplisitte krav
|
||||
* om å faktisk kunne se HVEM som reagerte, ikke bare et antall. */
|
||||
function formatReactorSummary(names: string[]): string {
|
||||
if (names.length === 0) return ""
|
||||
if (names.length === 1) return `${names[0]} reagerte`
|
||||
if (names.length === 2) return `${names[0]} og ${names[1]} reagerte`
|
||||
const rest = names.length - 2
|
||||
return `${names[0]}, ${names[1]} og ${rest} ${rest === 1 ? "annen" : "andre"} reagerte`
|
||||
}
|
||||
|
||||
/** Short relative / clock-style timestamp, matching the rest of the app. */
|
||||
function formatTimestamp(iso: string): string {
|
||||
const then = new Date(iso)
|
||||
if (Number.isNaN(then.getTime())) return ""
|
||||
const now = Date.now()
|
||||
const diffMs = now - then.getTime()
|
||||
const min = Math.floor(diffMs / 60000)
|
||||
if (min < 1) return "nå"
|
||||
if (min < 60) return `${min} min`
|
||||
const hours = Math.floor(min / 60)
|
||||
if (hours < 24) return `${hours} t`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days} d`
|
||||
return then.toLocaleDateString("nb-NO", { day: "numeric", month: "short" })
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function PostEngagement({
|
||||
apiBase,
|
||||
currentUserId,
|
||||
initialReactions,
|
||||
initialCommentCount,
|
||||
canModerate,
|
||||
}: PostEngagementProps) {
|
||||
const loggedIn = currentUserId !== null
|
||||
|
||||
/* -------------------------- Reactions -------------------------- */
|
||||
const [reactions, setReactions] = useState<ReactionSummary[]>(initialReactions)
|
||||
const [reactionError, setReactionError] = useState<string | null>(null)
|
||||
const [reactionPending, setReactionPending] = useState(false)
|
||||
|
||||
// Merge the curated set with server data so all six always render in order,
|
||||
// even when a given emoji has no reactions yet.
|
||||
const reactionRow = useMemo(() => {
|
||||
const byEmoji = new Map(reactions.map((r) => [r.emoji, r]))
|
||||
return REACTION_SET.map(
|
||||
(emoji) => byEmoji.get(emoji) ?? { emoji, count: 0, reacted_by_me: false, reactors: [] },
|
||||
)
|
||||
}, [reactions])
|
||||
|
||||
const toggleReaction = useCallback(
|
||||
async (r: ReactionSummary) => {
|
||||
if (!loggedIn || reactionPending) return
|
||||
setReactionPending(true)
|
||||
setReactionError(null)
|
||||
// Tap your own reaction -> DELETE; otherwise PUT (switching is just a PUT,
|
||||
// the backend replaces any prior reaction of yours).
|
||||
const method = r.reacted_by_me ? "DELETE" : "PUT"
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/reaction`, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ emoji: r.emoji }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
setReactionError(await readError(res))
|
||||
return
|
||||
}
|
||||
const fresh = (await res.json()) as ReactionSummary[]
|
||||
setReactions(fresh)
|
||||
} catch {
|
||||
setReactionError(GENERIC_ERROR)
|
||||
} finally {
|
||||
setReactionPending(false)
|
||||
}
|
||||
},
|
||||
[apiBase, loggedIn, reactionPending],
|
||||
)
|
||||
|
||||
/* --------------------------- Comments -------------------------- */
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [comments, setComments] = useState<CommentOut[]>([])
|
||||
const [fetched, setFetched] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [threadError, setThreadError] = useState<string | null>(null)
|
||||
const [commentCount, setCommentCount] = useState(initialCommentCount)
|
||||
|
||||
const loadComments = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setThreadError(null)
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/comments`, { credentials: "include" })
|
||||
if (!res.ok) {
|
||||
setThreadError(await readError(res))
|
||||
return
|
||||
}
|
||||
const data = (await res.json()) as CommentOut[]
|
||||
setComments(data)
|
||||
setFetched(true)
|
||||
} catch {
|
||||
setThreadError(GENERIC_ERROR)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [apiBase])
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setExpanded((prev) => {
|
||||
const next = !prev
|
||||
if (next && !fetched && !loading) void loadComments()
|
||||
return next
|
||||
})
|
||||
}, [fetched, loading, loadComments])
|
||||
|
||||
// Build a nested tree from the flat list, preserving API order (chronological
|
||||
// oldest-first). Children are grouped by parent id.
|
||||
const { roots, childrenOf } = useMemo(() => {
|
||||
const childrenOf = new Map<string, CommentOut[]>()
|
||||
const roots: CommentOut[] = []
|
||||
for (const c of comments) {
|
||||
if (c.parent_comment_id === null) {
|
||||
roots.push(c)
|
||||
} else {
|
||||
const arr = childrenOf.get(c.parent_comment_id) ?? []
|
||||
arr.push(c)
|
||||
childrenOf.set(c.parent_comment_id, arr)
|
||||
}
|
||||
}
|
||||
return { roots, childrenOf }
|
||||
}, [comments])
|
||||
|
||||
const appendComment = useCallback((c: CommentOut) => {
|
||||
setComments((prev) => [...prev, c])
|
||||
setCommentCount((n) => n + 1)
|
||||
}, [])
|
||||
|
||||
const removeSubtree = useCallback((id: string) => {
|
||||
setComments((prev) => {
|
||||
// Collect the target plus all transitive descendants.
|
||||
const toRemove = new Set<string>([id])
|
||||
let grew = true
|
||||
while (grew) {
|
||||
grew = false
|
||||
for (const c of prev) {
|
||||
if (c.parent_comment_id && toRemove.has(c.parent_comment_id) && !toRemove.has(c.id)) {
|
||||
toRemove.add(c.id)
|
||||
grew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
setCommentCount((n) => Math.max(0, n - toRemove.size))
|
||||
return prev.filter((c) => !toRemove.has(c.id))
|
||||
})
|
||||
}, [])
|
||||
|
||||
/* One reply composer open at a time, tracked by target comment id. */
|
||||
const [replyingTo, setReplyingTo] = useState<string | null>(null)
|
||||
|
||||
/* Hvem-reagerte-detalj (2026-08-07) — dataen er allerede i reactionRow
|
||||
* (samme batch-henting som selve tellingen), så dette er ren
|
||||
* visningslogikk, ingen ekstra fetch. */
|
||||
const [showReactors, setShowReactors] = useState(false)
|
||||
const activeReactionGroups = reactionRow.filter((r) => r.count > 0)
|
||||
const allReactorNames = activeReactionGroups.flatMap((r) => r.reactors)
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4 pt-2" aria-label="Reaksjoner og kommentarer">
|
||||
{/* ---------------------------- Reactions ---------------------------- */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-1 rounded-full bg-muted p-1" role="group" aria-label="Reaksjoner">
|
||||
{reactionRow.map((r) => {
|
||||
const active = r.reacted_by_me
|
||||
return (
|
||||
<button
|
||||
key={r.emoji}
|
||||
type="button"
|
||||
onClick={() => toggleReaction(r)}
|
||||
disabled={!loggedIn || reactionPending}
|
||||
aria-pressed={active}
|
||||
aria-label={`Reager med ${r.emoji}${r.count > 0 ? `, ${r.count}` : ""}`}
|
||||
className={cn(
|
||||
"inline-flex min-h-11 items-center justify-center gap-1.5 rounded-full px-3 text-lg leading-none transition-transform focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
||||
"enabled:hover:brightness-95 enabled:active:scale-[0.98] disabled:cursor-default disabled:opacity-70",
|
||||
active
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "border border-border bg-card text-foreground",
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true">{r.emoji}</span>
|
||||
{r.count > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-bold tabular-nums",
|
||||
active ? "text-primary-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{r.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{reactionError && (
|
||||
<p role="alert" className="px-1 text-sm font-semibold text-destructive">
|
||||
{reactionError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Hvem reagerte -- alltid synlig sammendrag (ikke bare hover/tap,
|
||||
tilgjengelighetsregelen), utvidbart til full per-emoji-liste. */}
|
||||
{allReactorNames.length > 0 && (
|
||||
<div className="flex flex-col gap-1 px-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowReactors((v) => !v)}
|
||||
aria-expanded={showReactors}
|
||||
className="flex min-h-11 w-fit items-center text-left text-sm text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{formatReactorSummary(allReactorNames)}
|
||||
</button>
|
||||
{showReactors && (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{activeReactionGroups.map((r) => (
|
||||
<li key={r.emoji} className="flex items-start gap-2 text-sm text-foreground">
|
||||
<span aria-hidden="true">{r.emoji}</span>
|
||||
<span>{r.reactors.join(", ")}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ------------------------ Expand / count ------------------------ */}
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={toggleExpanded}
|
||||
aria-expanded={expanded}
|
||||
className="h-11 gap-2 rounded-xl px-3 text-base font-bold text-foreground hover:bg-accent/50 active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<MessageSquare aria-hidden="true" className="size-5" />
|
||||
Kommentarer ({commentCount})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ---------------------------- Thread ---------------------------- */}
|
||||
{expanded && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{loading && (
|
||||
<div className="flex justify-center py-6">
|
||||
<span
|
||||
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||
role="status"
|
||||
aria-label="Laster kommentarer"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{threadError && !loading && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p role="alert" className="text-sm font-semibold text-destructive">
|
||||
{threadError}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void loadComments()}
|
||||
className="h-11 w-fit rounded-xl text-base font-bold active:scale-[0.98]"
|
||||
>
|
||||
Prøv igjen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !threadError && fetched && (
|
||||
<>
|
||||
{roots.length === 0 ? (
|
||||
<p className="py-2 text-base text-muted-foreground">Ingen kommentarer ennå.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-4">
|
||||
{roots.map((c) => (
|
||||
<CommentNode
|
||||
key={c.id}
|
||||
comment={c}
|
||||
depth={0}
|
||||
childrenOf={childrenOf}
|
||||
apiBase={apiBase}
|
||||
currentUserId={currentUserId}
|
||||
canModerate={canModerate}
|
||||
replyingTo={replyingTo}
|
||||
setReplyingTo={setReplyingTo}
|
||||
onAppend={appendComment}
|
||||
onRemoveSubtree={removeSubtree}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Bottom composer (top-level) */}
|
||||
{loggedIn ? (
|
||||
<CommentComposer
|
||||
apiBase={apiBase}
|
||||
parentCommentId={null}
|
||||
onPosted={appendComment}
|
||||
autoFocus={false}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-base text-muted-foreground">Logg inn for å kommentere.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Single comment (recursive) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function CommentNode({
|
||||
comment,
|
||||
depth,
|
||||
childrenOf,
|
||||
apiBase,
|
||||
currentUserId,
|
||||
canModerate,
|
||||
replyingTo,
|
||||
setReplyingTo,
|
||||
onAppend,
|
||||
onRemoveSubtree,
|
||||
}: {
|
||||
comment: CommentOut
|
||||
depth: number
|
||||
childrenOf: Map<string, CommentOut[]>
|
||||
apiBase: string
|
||||
currentUserId: string | null
|
||||
canModerate: boolean
|
||||
replyingTo: string | null
|
||||
setReplyingTo: (id: string | null) => void
|
||||
onAppend: (c: CommentOut) => void
|
||||
onRemoveSubtree: (id: string) => void
|
||||
}) {
|
||||
const loggedIn = currentUserId !== null
|
||||
const children = childrenOf.get(comment.id) ?? []
|
||||
const hasReplies = children.length > 0
|
||||
const canDelete = comment.author_user_id === currentUserId || canModerate
|
||||
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||
|
||||
const isReplyOpen = replyingTo === comment.id
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
setDeleting(true)
|
||||
setDeleteError(null)
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/comments/${comment.id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) {
|
||||
setDeleteError(await readError(res))
|
||||
return
|
||||
}
|
||||
onRemoveSubtree(comment.id)
|
||||
} catch {
|
||||
setDeleteError(GENERIC_ERROR)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
setConfirming(false)
|
||||
}
|
||||
}, [apiBase, comment.id, onRemoveSubtree])
|
||||
|
||||
// Indent per level via a left border, but stop growing past the cap so long
|
||||
// threads on mobile don't squeeze content into a sliver.
|
||||
const indented = depth > 0
|
||||
const cappedForChildren = Math.min(depth, MAX_INDENT_DEPTH)
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(indented && "border-l-2 border-border pl-4")}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Author + timestamp */}
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="text-base font-bold text-foreground">{comment.author_display_name}</span>
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
{formatTimestamp(comment.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<p className="whitespace-pre-wrap break-words text-base leading-relaxed text-foreground">
|
||||
{comment.body}
|
||||
</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{loggedIn && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setReplyingTo(isReplyOpen ? null : comment.id)}
|
||||
aria-expanded={isReplyOpen}
|
||||
className="h-11 gap-1.5 rounded-xl px-3 text-sm font-bold text-muted-foreground hover:bg-accent/50 hover:text-foreground active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Reply aria-hidden="true" className="size-4" />
|
||||
Svar
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canDelete && !confirming && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirming(true)}
|
||||
className="h-11 gap-1.5 rounded-xl px-3 text-sm font-bold text-destructive hover:bg-destructive/10 hover:text-destructive active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Trash2 aria-hidden="true" className="size-4" />
|
||||
Slett
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canDelete && confirming && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="h-11 gap-1.5 rounded-xl px-3 text-sm font-bold text-destructive hover:bg-destructive/10 hover:text-destructive active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Trash2 aria-hidden="true" className="size-4" />
|
||||
Sikker?
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirming(false)}
|
||||
disabled={deleting}
|
||||
className="h-11 rounded-xl px-3 text-sm font-bold text-foreground hover:bg-accent/50 active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
{hasReplies && (
|
||||
<p className="basis-full text-sm text-muted-foreground text-pretty">
|
||||
Sletter du denne, forsvinner også svarene under.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deleteError && (
|
||||
<p role="alert" className="text-sm font-semibold text-destructive">
|
||||
{deleteError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Inline reply composer */}
|
||||
{isReplyOpen && loggedIn && (
|
||||
<CommentComposer
|
||||
apiBase={apiBase}
|
||||
parentCommentId={comment.id}
|
||||
autoFocus
|
||||
onPosted={(c) => {
|
||||
onAppend(c)
|
||||
setReplyingTo(null)
|
||||
}}
|
||||
onCancel={() => setReplyingTo(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Children */}
|
||||
{hasReplies && (
|
||||
<ul className="mt-4 flex flex-col gap-4">
|
||||
{children.map((child) => (
|
||||
<CommentNode
|
||||
key={child.id}
|
||||
comment={child}
|
||||
depth={cappedForChildren + 1}
|
||||
childrenOf={childrenOf}
|
||||
apiBase={apiBase}
|
||||
currentUserId={currentUserId}
|
||||
canModerate={canModerate}
|
||||
replyingTo={replyingTo}
|
||||
setReplyingTo={setReplyingTo}
|
||||
onAppend={onAppend}
|
||||
onRemoveSubtree={onRemoveSubtree}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Composer */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function CommentComposer({
|
||||
apiBase,
|
||||
parentCommentId,
|
||||
onPosted,
|
||||
onCancel,
|
||||
autoFocus,
|
||||
}: {
|
||||
apiBase: string
|
||||
parentCommentId: string | null
|
||||
onPosted: (c: CommentOut) => void
|
||||
onCancel?: () => void
|
||||
autoFocus?: boolean
|
||||
}) {
|
||||
const [body, setBody] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const empty = body.trim() === ""
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (empty || submitting) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/comments`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
body: body.trim(),
|
||||
...(parentCommentId ? { parent_comment_id: parentCommentId } : {}),
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
setError(await readError(res))
|
||||
return
|
||||
}
|
||||
const created = (await res.json()) as CommentOut
|
||||
onPosted(created)
|
||||
setBody("")
|
||||
} catch {
|
||||
setError(GENERIC_ERROR)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [apiBase, body, empty, onPosted, parentCommentId, submitting])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- intentional: composer opens on explicit user action
|
||||
autoFocus={autoFocus}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Cmd/Ctrl+Enter submits; plain Enter keeps newlines. Respect IME.
|
||||
if (
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
e.key === "Enter" &&
|
||||
!e.nativeEvent.isComposing &&
|
||||
e.keyCode !== 229
|
||||
) {
|
||||
e.preventDefault()
|
||||
void submit()
|
||||
}
|
||||
}}
|
||||
rows={parentCommentId ? 2 : 3}
|
||||
placeholder={parentCommentId ? "Skriv et svar …" : "Skriv en kommentar …"}
|
||||
className="min-h-11 w-full resize-y rounded-2xl border border-border bg-card px-4 py-3 text-base leading-relaxed text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm font-semibold text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={empty || submitting}
|
||||
className="h-11 rounded-xl px-5 text-base font-bold active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{submitting ? "Sender …" : "Send"}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
className="h-11 rounded-xl px-4 text-base font-bold text-muted-foreground hover:bg-accent/50 hover:text-foreground active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PostEngagement
|
||||
|
|
@ -5,6 +5,7 @@ import { useEffect, useState } from "react"
|
|||
import Link from "next/link"
|
||||
import {
|
||||
Calendar,
|
||||
Camera,
|
||||
Users,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
|
|
@ -24,6 +25,7 @@ import { Input } from "@/components/ui/input"
|
|||
import { Label } from "@/components/ui/label"
|
||||
import { Wordmark } from "@/components/wordmark"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PostEngagement, type ReactionSummary } from "@/components/post-engagement"
|
||||
|
||||
// --- Types -----------------------------------------------------------------
|
||||
// Interne visningstyper -- matcher ikke API-et 1:1 (se ApiTournamentInfo/
|
||||
|
|
@ -286,6 +288,8 @@ type ApiFeedMessage = {
|
|||
body: string | null
|
||||
image_url: string | null
|
||||
created_at: string
|
||||
reactions: ReactionSummary[]
|
||||
comment_count: number
|
||||
}
|
||||
|
||||
const FEED_TIME_FMT = new Intl.DateTimeFormat("no-NO", {
|
||||
|
|
@ -308,8 +312,25 @@ function TournamentFeed({ tournamentId, code }: { tournamentId: string; code?: s
|
|||
const [image, setImage] = useState<File | null>(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [postError, setPostError] = useState<string | null>(null)
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null)
|
||||
const codeParam = code ? `?code=${encodeURIComponent(code)}` : ""
|
||||
|
||||
// Kun brukt til å avgjøre om PostEngagement skal vise reaksjons-/
|
||||
// kommentarfeltet som innlogget (samme "beste innsats" som feed.tsx --
|
||||
// selve autorisasjonen håndheves av backend uansett).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch("/auth/me", { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data: { id: string } | null) => {
|
||||
if (!cancelled && data) setCurrentUserId(data.id)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch(`/public/tournaments/${tournamentId}/feed${codeParam}`, { credentials: "include" })
|
||||
|
|
@ -331,7 +352,8 @@ function TournamentFeed({ tournamentId, code }: { tournamentId: string; code?: s
|
|||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const msg: ApiFeedMessage = JSON.parse(event.data)
|
||||
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]))
|
||||
// Nyeste øverst (2026-08-06) -- legg til FØRST, ikke sist.
|
||||
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [msg, ...prev]))
|
||||
} catch {
|
||||
// ignorer ugyldig payload
|
||||
}
|
||||
|
|
@ -362,18 +384,43 @@ function TournamentFeed({ tournamentId, code }: { tournamentId: string; code?: s
|
|||
setPostError("Du må være medlem av arrangøren eller delta i turneringen for å poste.")
|
||||
return
|
||||
}
|
||||
if (!res.ok) throw new Error(`post: ${res.status}`)
|
||||
if (!res.ok) {
|
||||
let message = "Klarte ikke å poste. Prøv igjen."
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (typeof data?.detail?.message === "string") message = data.detail.message
|
||||
} catch {
|
||||
// Ikke JSON -- behold standardmeldingen.
|
||||
}
|
||||
setPostError(message)
|
||||
return
|
||||
}
|
||||
const msg: ApiFeedMessage = await res.json()
|
||||
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]))
|
||||
// Nyeste øverst (2026-08-06) -- legg til FØRST, ikke sist.
|
||||
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [msg, ...prev]))
|
||||
setText("")
|
||||
setImage(null)
|
||||
} catch {
|
||||
setPostError("Klarte ikke å poste. Prøv igjen.")
|
||||
setPostError("Klarte ikke å poste. Sjekk tilkoblingen og prøv igjen.")
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Samme grense som storage.MAX_UPLOAD_BYTES (server-siden) -- sjekket
|
||||
// klient-side for en umiddelbar, presis feilmelding.
|
||||
function handleImageSelected(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
setPostError("Bildet er for stort (maks 20 MB). Velg et mindre bilde.")
|
||||
e.target.value = ""
|
||||
return
|
||||
}
|
||||
setPostError(null)
|
||||
setImage(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="Runde-feed" className="flex flex-col gap-3 border-t border-border pt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -406,6 +453,14 @@ function TournamentFeed({ tournamentId, code }: { tournamentId: string; code?: s
|
|||
<img src={m.image_url} alt="" className="mb-2 max-h-72 w-full rounded-xl object-cover" />
|
||||
)}
|
||||
{m.body && <p className="text-sm leading-relaxed text-foreground text-pretty">{m.body}</p>}
|
||||
|
||||
<PostEngagement
|
||||
apiBase={`/public/tournaments/${tournamentId}/feed/${m.id}`}
|
||||
currentUserId={currentUserId}
|
||||
initialReactions={m.reactions}
|
||||
initialCommentCount={m.comment_count}
|
||||
canModerate={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -426,15 +481,35 @@ function TournamentFeed({ tournamentId, code }: { tournamentId: string; code?: s
|
|||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* To atskilte inputs -- Android sin systembilde-vlger (Photo
|
||||
Picker) viser ALDRI et kamera-alternativ når accept="image/*"
|
||||
mangler capture, uansett nettleser (funnet 2026-08-06, ekte
|
||||
enhet). */}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
id="feed-camera-input"
|
||||
className="hidden"
|
||||
onChange={handleImageSelected}
|
||||
/>
|
||||
<label
|
||||
htmlFor="feed-camera-input"
|
||||
aria-label="Ta bilde"
|
||||
className="flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
<Camera aria-hidden="true" className="size-5" />
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
id="feed-image-input"
|
||||
className="hidden"
|
||||
onChange={(e) => setImage(e.target.files?.[0] ?? null)}
|
||||
onChange={handleImageSelected}
|
||||
/>
|
||||
<label
|
||||
htmlFor="feed-image-input"
|
||||
aria-label="Velg bilde fra galleri"
|
||||
className="flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
<ImagePlus aria-hidden="true" className="size-5" />
|
||||
|
|
|
|||
|
|
@ -551,6 +551,16 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
void loadRound()
|
||||
}, [loadRound])
|
||||
|
||||
// Anker-scroll til kommentarseksjonen (ADR-047) -- leaderboardets nye
|
||||
// "Kommentarer"-lenke peker hit, men elementet finnes ikke i DOM-en før
|
||||
// `round` er lastet, så nettleserens EGEN anker-scroll (som skjer FØR
|
||||
// dette) rekker ikke frem. Trigges på nytt manuelt her når dataen er der.
|
||||
useEffect(() => {
|
||||
if (!round) return
|
||||
if (window.location.hash !== "#kommentarer") return
|
||||
document.getElementById("kommentarer")?.scrollIntoView({ block: "start" })
|
||||
}, [round])
|
||||
|
||||
// Løpende matchstatus/skins-tavle (ADR-039) -- avledet ved LESING (aldri
|
||||
// lagret), delt av FormatResultPanel OG ScorecardGrid/SideScorecardGrid
|
||||
// (per-hull-fargelegging + Stilling-rad, 2026-07-29). Slagspill/Stableford
|
||||
|
|
@ -1592,7 +1602,9 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
)}
|
||||
|
||||
{round && viewerId && (
|
||||
<div className="mt-6">
|
||||
// Anker-mål for "Kommentarer"-lenken fra leaderboardets utvidede
|
||||
// rader (round-leaderboard.tsx) -- samme seksjon, ikke en duplikat.
|
||||
<div id="kommentarer" className="mt-6 scroll-mt-20">
|
||||
<RoundMessages
|
||||
roundId={roundId}
|
||||
currentUserId={viewerId}
|
||||
|
|
@ -1916,41 +1928,13 @@ function ScoringWizard({
|
|||
|
||||
{step === "details" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base font-semibold">Kølle brukt ved utslaget</Label>
|
||||
{player.isSelf && ownBagClubs.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ownBagClubs.map((club) => {
|
||||
const selected = stat.club === club
|
||||
return (
|
||||
<button
|
||||
key={club}
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
onClick={() => onUpdate({ club })}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"flex min-h-11 items-center justify-center rounded-xl border px-3 text-sm font-bold transition-colors disabled:opacity-100",
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{club}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={stat.club}
|
||||
onChange={(e) => onUpdate({ club: e.target.value })}
|
||||
placeholder="F.eks. Driver, 3-jern"
|
||||
disabled={readOnly}
|
||||
className="h-12 rounded-2xl text-base"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ClubPicker
|
||||
label="Kølle brukt ved utslaget"
|
||||
value={stat.club}
|
||||
onChange={(club) => onUpdate({ club })}
|
||||
ownBagClubs={player.isSelf ? ownBagClubs : []}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
{hole.par !== 3 && (
|
||||
<DirectionCross
|
||||
|
|
@ -4190,6 +4174,34 @@ function EditRoundPanel({
|
|||
<p className="text-xs font-semibold text-muted-foreground">
|
||||
Hvilke av dine venne-kategorier skal kunne se runden?
|
||||
</p>
|
||||
<div className="flex gap-2" role="group" aria-label="Velg alle eller ingen kategorier">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisibleCategories([])}
|
||||
aria-pressed={visibleCategories.length === 0}
|
||||
className={cn(
|
||||
"min-h-[44px] rounded-full border px-3 text-sm font-semibold transition-colors",
|
||||
visibleCategories.length === 0
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-card text-muted-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
Ingen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisibleCategories(VISIBILITY_CATEGORY_OPTIONS.map((c) => c.code))}
|
||||
aria-pressed={visibleCategories.length === VISIBILITY_CATEGORY_OPTIONS.length}
|
||||
className={cn(
|
||||
"min-h-[44px] rounded-full border px-3 text-sm font-semibold transition-colors",
|
||||
visibleCategories.length === VISIBILITY_CATEGORY_OPTIONS.length
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-card text-muted-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{VISIBILITY_CATEGORY_OPTIONS.map(({ code, label }) => {
|
||||
const active = visibleCategories.includes(code)
|
||||
|
|
@ -4215,6 +4227,11 @@ function EditRoundPanel({
|
|||
)
|
||||
})}
|
||||
</div>
|
||||
{visibleCategories.length === 0 && (
|
||||
<p className="rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm font-semibold text-destructive">
|
||||
Ingen kategori valgt ennå -- ingen venner kan se runden før du velger minst én.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -6238,6 +6255,63 @@ function DirectionCross({
|
|||
)
|
||||
}
|
||||
|
||||
// --- Kølle-plukker -----------------------------------------------------
|
||||
// Delt mellom ScoringWizard sitt "details"-steg (utslags-kølle) og
|
||||
// ShotMeasurementSheet (ADR-048, slag-for-slag måling) -- ren utrekking,
|
||||
// ingen atferdsendring for veiviserens eksisterende bruk.
|
||||
|
||||
function ClubPicker({
|
||||
value,
|
||||
onChange,
|
||||
ownBagClubs,
|
||||
readOnly = false,
|
||||
label = "Kølle",
|
||||
}: {
|
||||
value: string
|
||||
onChange: (club: string) => void
|
||||
ownBagClubs: string[]
|
||||
readOnly?: boolean
|
||||
label?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base font-semibold">{label}</Label>
|
||||
{ownBagClubs.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ownBagClubs.map((club) => {
|
||||
const selected = value === club
|
||||
return (
|
||||
<button
|
||||
key={club}
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
onClick={() => onChange(club)}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"flex min-h-11 items-center justify-center rounded-xl border px-3 text-sm font-bold transition-colors disabled:opacity-100",
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{club}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="F.eks. Driver, 3-jern"
|
||||
disabled={readOnly}
|
||||
className="h-12 rounded-2xl text-base"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Choice row (segmented buttons) ----------------------------------------
|
||||
|
||||
function ChoiceRow({
|
||||
|
|
|
|||
|
|
@ -22,9 +22,10 @@
|
|||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, Crown, Flag as FlagIcon, Trophy } from "lucide-react"
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, Crown, Flag as FlagIcon, MessageSquare, Trophy } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result"
|
||||
import { MatchScorecardGrid } from "@/components/round-scorecard"
|
||||
|
||||
// --- API-typer ---------------------------------------------------------------
|
||||
|
||||
|
|
@ -61,6 +62,18 @@ type ApiRoundParticipant = {
|
|||
user_id: string | null
|
||||
round_side_id: string | null
|
||||
handicap_index_snapshot: number | null
|
||||
// Felt under er allerede returnert av samme `${apiPrefix}/{roundId}`-
|
||||
// endepunkt (samme JSON som round-scorecard.tsx sin `ApiParticipant`
|
||||
// fetcher, se der) -- bare ikke tidligere typet her, siden de ikke
|
||||
// trengtes før MatchScorecardGrid-gjenbruken (ny utvid-seksjon i
|
||||
// TwoSidedBoard/HighLowBoard). `guest_name` er valgfritt -- fraværende i
|
||||
// publicMode (PublicRoundParticipantOut har ikke feltet), defaultes til
|
||||
// null der (se toGridRound under) -- ubrukt av MatchScorecardGrid uansett
|
||||
// (den leser kun display_name).
|
||||
guest_name?: string | null
|
||||
display_name: string
|
||||
is_owner: boolean
|
||||
playing_handicap: number | null
|
||||
}
|
||||
|
||||
type ApiSide = {
|
||||
|
|
@ -75,6 +88,9 @@ type ApiRoundHeader = {
|
|||
id: string
|
||||
name: string | null
|
||||
course_name_snapshot: string
|
||||
// Samme "allerede i JSON-en, bare ikke tidligere typet"-begrunnelse som
|
||||
// ApiRoundParticipant sine nye felt over.
|
||||
tee_name_snapshot: string
|
||||
played_at: string
|
||||
start_hole: number
|
||||
holes_planned: number
|
||||
|
|
@ -427,12 +443,14 @@ function IndividualBoard({
|
|||
defaultMetric,
|
||||
pointsUnit = "p",
|
||||
holes,
|
||||
commentsHref,
|
||||
}: {
|
||||
players: IndividualPlayer[]
|
||||
metrics: MetricKey[]
|
||||
defaultMetric: MetricKey
|
||||
pointsUnit?: string
|
||||
holes: Hole[]
|
||||
commentsHref: string
|
||||
}) {
|
||||
const [metric, setMetric] = useState<MetricKey>(defaultMetric)
|
||||
const [openId, setOpenId] = useState<string | null>(null)
|
||||
|
|
@ -468,6 +486,7 @@ function IndividualBoard({
|
|||
holes={holes}
|
||||
open={openId === e.player.id}
|
||||
onToggle={() => setOpenId((cur) => (cur === e.player.id ? null : e.player.id))}
|
||||
commentsHref={commentsHref}
|
||||
/>
|
||||
))}
|
||||
{unranked.map((e) => (
|
||||
|
|
@ -483,6 +502,7 @@ function IndividualBoard({
|
|||
holes={holes}
|
||||
open={openId === e.player.id}
|
||||
onToggle={() => setOpenId((cur) => (cur === e.player.id ? null : e.player.id))}
|
||||
commentsHref={commentsHref}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
|
|
@ -539,6 +559,7 @@ function IndividualRow({
|
|||
holes,
|
||||
open,
|
||||
onToggle,
|
||||
commentsHref,
|
||||
}: {
|
||||
player: IndividualPlayer
|
||||
rank: number | null
|
||||
|
|
@ -550,6 +571,7 @@ function IndividualRow({
|
|||
holes: Hole[]
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
commentsHref: string
|
||||
}) {
|
||||
const played = playedHoles(player)
|
||||
const totalHoles = holes.length
|
||||
|
|
@ -595,16 +617,33 @@ function IndividualRow({
|
|||
|
||||
{open && (
|
||||
<div id={panelId} className="px-3 pb-3">
|
||||
<HoleByHole player={player} metric={metric} holes={holes} />
|
||||
<HoleByHole player={player} metric={metric} holes={holes} rank={rank} tied={tied} commentsHref={commentsHref} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function HoleByHole({ player, metric, holes }: { player: IndividualPlayer; metric: MetricKey; holes: Hole[] }) {
|
||||
function HoleByHole({
|
||||
player,
|
||||
metric,
|
||||
holes,
|
||||
rank,
|
||||
tied,
|
||||
commentsHref,
|
||||
}: {
|
||||
player: IndividualPlayer
|
||||
metric: MetricKey
|
||||
holes: Hole[]
|
||||
rank: number | null
|
||||
tied: boolean
|
||||
commentsHref: string
|
||||
}) {
|
||||
const front = holes.slice(0, 9)
|
||||
const back = holes.slice(9)
|
||||
const totalPar = holes.reduce((s, h) => s + h.par, 0)
|
||||
const totalGross = player.gross.reduce<number>((s, g) => s + (g ?? 0), 0)
|
||||
const holesWithScore = player.gross.filter((g) => g !== null).length
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-border bg-background p-3">
|
||||
<p className="text-xs font-semibold text-muted-foreground">
|
||||
|
|
@ -675,6 +714,20 @@ function HoleByHole({ player, metric, holes }: { player: IndividualPlayer; metri
|
|||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 border-t border-border pt-2">
|
||||
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
||||
Par {totalPar} · Score {holesWithScore > 0 ? totalGross : "–"}
|
||||
{rank !== null ? ` · ${tied ? "Delt " : ""}${rank}. plass` : ""}
|
||||
</span>
|
||||
<Link
|
||||
href={commentsHref}
|
||||
className="inline-flex h-11 items-center gap-1.5 rounded-lg px-1 text-sm font-bold text-primary hover:underline active:scale-[0.98]"
|
||||
>
|
||||
<MessageSquare aria-hidden="true" className="size-4" />
|
||||
Kommentarer
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -722,6 +775,12 @@ function TwoSidedBoard({
|
|||
leader,
|
||||
isDormie,
|
||||
isClosed,
|
||||
round,
|
||||
roundId,
|
||||
viewerId,
|
||||
refreshKey,
|
||||
apiPrefix,
|
||||
commentsHref,
|
||||
}: {
|
||||
sideA: SideTeamInfo
|
||||
sideB: SideTeamInfo
|
||||
|
|
@ -733,7 +792,14 @@ function TwoSidedBoard({
|
|||
leader: "a" | "b" | null
|
||||
isDormie: boolean
|
||||
isClosed: boolean
|
||||
round: ApiRoundHeader
|
||||
roundId: string
|
||||
viewerId: string | null
|
||||
refreshKey: number
|
||||
apiPrefix: string
|
||||
commentsHref: string
|
||||
}) {
|
||||
const [showDetail, setShowDetail] = useState(false)
|
||||
const holesPlayed = results.filter((r) => r !== null).length
|
||||
let aWins = 0
|
||||
let bWins = 0
|
||||
|
|
@ -778,6 +844,17 @@ function TwoSidedBoard({
|
|||
</div>
|
||||
|
||||
<HoleWinners sideA={sideA} sideB={sideB} results={results} totalHoles={totalHoles} />
|
||||
|
||||
<MatchDetailSection
|
||||
showDetail={showDetail}
|
||||
onToggle={() => setShowDetail((v) => !v)}
|
||||
round={round}
|
||||
roundId={roundId}
|
||||
viewerId={viewerId}
|
||||
refreshKey={refreshKey}
|
||||
apiPrefix={apiPrefix}
|
||||
commentsHref={commentsHref}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -834,6 +911,72 @@ function HoleWinners({ sideA, sideB, results, totalHoles }: { sideA: SideTeamInf
|
|||
)
|
||||
}
|
||||
|
||||
// Delt av TwoSidedBoard/HighLowBoard (ADR-047) -- ÉN utvidbar detaljseksjon
|
||||
// for HELE kampen (ikke et per-rad-mønster, siden formatet kun har 2 sider,
|
||||
// ikke en spillerliste). Viser faktiske hull-for-hull-SCORER (ikke bare
|
||||
// hvem som vant hullet, som HoleWinners/DuelRow allerede gjør statisk over)
|
||||
// via den allerede eksisterende, nå eksporterte MatchScorecardGrid --
|
||||
// samme scorekort som `/my-rounds/{id}/scorecard` viser, ingen ny visuell
|
||||
// design. `MatchScorecardGrid` gjør sin egen `useFormatResult`-henting
|
||||
// internt, så dette henter kun ved faktisk utvidelse, ikke ved mount.
|
||||
// `MatchScorecardGrid` (round-scorecard.tsx) sin `ApiParticipant.guest_name`
|
||||
// er required (string|null), men `publicMode` sin `PublicRoundParticipantOut`
|
||||
// har ikke feltet i det hele tatt (undefined ved kjøretid) -- defaultes til
|
||||
// null her. Trygt: MatchScorecardGrid leser aldri guest_name (kun
|
||||
// display_name via participantLabel), så dette endrer ingen visning.
|
||||
function toGridRound(round: ApiRoundHeader) {
|
||||
return {
|
||||
...round,
|
||||
participants: round.participants.map((p) => ({ ...p, guest_name: p.guest_name ?? null })),
|
||||
}
|
||||
}
|
||||
|
||||
function MatchDetailSection({
|
||||
showDetail,
|
||||
onToggle,
|
||||
round,
|
||||
roundId,
|
||||
viewerId,
|
||||
refreshKey,
|
||||
apiPrefix,
|
||||
commentsHref,
|
||||
}: {
|
||||
showDetail: boolean
|
||||
onToggle: () => void
|
||||
round: ApiRoundHeader
|
||||
roundId: string
|
||||
viewerId: string | null
|
||||
refreshKey: number
|
||||
apiPrefix: string
|
||||
commentsHref: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border bg-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={showDetail}
|
||||
className="flex min-h-14 w-full items-center justify-between gap-2 px-4 py-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
>
|
||||
<span className="text-sm font-bold text-foreground">Vis hull-for-hull med score</span>
|
||||
<ChevronDown aria-hidden="true" className={cn("size-5 text-muted-foreground transition-transform", showDetail && "rotate-180")} />
|
||||
</button>
|
||||
{showDetail && (
|
||||
<div className="flex flex-col gap-3 border-t border-border p-3">
|
||||
<MatchScorecardGrid round={toGridRound(round)} roundId={roundId} viewerId={viewerId} refreshKey={refreshKey} apiPrefix={apiPrefix} />
|
||||
<Link
|
||||
href={commentsHref}
|
||||
className="inline-flex h-11 w-fit items-center gap-1.5 rounded-lg px-1 text-sm font-bold text-primary hover:underline active:scale-[0.98]"
|
||||
>
|
||||
<MessageSquare aria-hidden="true" className="size-4" />
|
||||
Kommentarer
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 3. HIGH-LOW-HIGH -- to duller per hull, poeng per side (IKKE hull-opp)
|
||||
// ===========================================================================
|
||||
|
|
@ -848,6 +991,12 @@ function HighLowBoard({
|
|||
totalA,
|
||||
totalB,
|
||||
formatName,
|
||||
round,
|
||||
roundId,
|
||||
viewerId,
|
||||
refreshKey,
|
||||
apiPrefix,
|
||||
commentsHref,
|
||||
}: {
|
||||
sideA: SideTeamInfo
|
||||
sideB: SideTeamInfo
|
||||
|
|
@ -855,7 +1004,14 @@ function HighLowBoard({
|
|||
totalA: number
|
||||
totalB: number
|
||||
formatName: string
|
||||
round: ApiRoundHeader
|
||||
roundId: string
|
||||
viewerId: string | null
|
||||
refreshKey: number
|
||||
apiPrefix: string
|
||||
commentsHref: string
|
||||
}) {
|
||||
const [showDetail, setShowDetail] = useState(false)
|
||||
const leader = totalA > totalB ? "a" : totalB > totalA ? "b" : null
|
||||
const fmt = (n: number) => NF1.format(n)
|
||||
return (
|
||||
|
|
@ -905,6 +1061,17 @@ function HighLowBoard({
|
|||
<span className="flex items-center gap-1.5"><span className="flex size-5 items-center justify-center rounded border border-border bg-muted text-[10px] font-bold">=</span>Delt (½–½)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MatchDetailSection
|
||||
showDetail={showDetail}
|
||||
onToggle={() => setShowDetail((v) => !v)}
|
||||
round={round}
|
||||
roundId={roundId}
|
||||
viewerId={viewerId}
|
||||
refreshKey={refreshKey}
|
||||
apiPrefix={apiPrefix}
|
||||
commentsHref={commentsHref}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -970,6 +1137,7 @@ function TeamFlightBoard({
|
|||
members,
|
||||
holes,
|
||||
formatName,
|
||||
commentsHref,
|
||||
}: {
|
||||
teamName: string
|
||||
toPar: number | null
|
||||
|
|
@ -979,8 +1147,10 @@ function TeamFlightBoard({
|
|||
members: IndividualPlayer[]
|
||||
holes: Hole[]
|
||||
formatName: string
|
||||
commentsHref: string
|
||||
}) {
|
||||
const [showRaw, setShowRaw] = useState(false)
|
||||
const [openMemberId, setOpenMemberId] = useState<string | null>(null)
|
||||
const ranked = [...flight].sort((a, b) => a.toPar - b.toPar)
|
||||
const myRank = ranked.findIndex((t) => t.isThisTeam) + 1
|
||||
|
||||
|
|
@ -1041,18 +1211,15 @@ function TeamFlightBoard({
|
|||
{showRaw && (
|
||||
<ol className="flex flex-col border-t border-border">
|
||||
{members.map((m) => (
|
||||
<li key={m.id} className="flex min-h-14 items-center gap-3 border-b border-border px-4 py-2 last:border-b-0">
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex items-center gap-2 truncate text-base font-bold text-foreground">
|
||||
{m.name}
|
||||
{m.isSelf && <Badge tone="self">Deg</Badge>}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{m.hcpIndex !== null ? `Hcp ${NF1.format(m.hcpIndex)}` : "Uten HCP"} · {playedHoles(m)}/{totalHoles} hull
|
||||
</span>
|
||||
</span>
|
||||
<MetricPill kind="topar" value={grossToPar(m, holes)} />
|
||||
</li>
|
||||
<TeamMemberRow
|
||||
key={m.id}
|
||||
member={m}
|
||||
holes={holes}
|
||||
totalHoles={totalHoles}
|
||||
open={openMemberId === m.id}
|
||||
onToggle={() => setOpenMemberId((cur) => (cur === m.id ? null : m.id))}
|
||||
commentsHref={commentsHref}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
|
@ -1061,6 +1228,56 @@ function TeamFlightBoard({
|
|||
)
|
||||
}
|
||||
|
||||
// Utvidbar rad for ETT medlem av flighten (ADR-047) -- samme mønster
|
||||
// (klikk → hull-for-hull via HoleByHole) som IndividualRow, men uten
|
||||
// rangeringskolonnen (et lags medlemmer rangeres ikke seg imellom her,
|
||||
// kun laget som helhet gjør, vist i flight-gruppen over).
|
||||
function TeamMemberRow({
|
||||
member,
|
||||
holes,
|
||||
totalHoles,
|
||||
open,
|
||||
onToggle,
|
||||
commentsHref,
|
||||
}: {
|
||||
member: IndividualPlayer
|
||||
holes: Hole[]
|
||||
totalHoles: number
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
commentsHref: string
|
||||
}) {
|
||||
const panelId = `tfm-${member.id}`
|
||||
return (
|
||||
<li className="border-b border-border last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
className="flex min-h-14 w-full items-center gap-3 px-4 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex items-center gap-2 truncate text-base font-bold text-foreground">
|
||||
{member.name}
|
||||
{member.isSelf && <Badge tone="self">Deg</Badge>}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{member.hcpIndex !== null ? `Hcp ${NF1.format(member.hcpIndex)}` : "Uten HCP"} · {playedHoles(member)}/{totalHoles} hull
|
||||
</span>
|
||||
</span>
|
||||
<MetricPill kind="topar" value={grossToPar(member, holes)} />
|
||||
<ChevronDown aria-hidden="true" className={cn("size-5 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
{open && (
|
||||
<div id={panelId} className="px-4 pb-3">
|
||||
<HoleByHole player={member} metric="brutto" holes={holes} rank={null} tied={false} commentsHref={commentsHref} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Datalag: fetch-hooks (uendret fra forrige runde)
|
||||
// ===========================================================================
|
||||
|
|
@ -1109,11 +1326,11 @@ type ApiFormatResult = {
|
|||
holes: ApiFormatHole[] | null
|
||||
}
|
||||
|
||||
function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult | null {
|
||||
function useFormatResult(roundId: string, refreshKey: number, apiPrefix: string = "/rounds"): ApiFormatResult | null {
|
||||
const [result, setResult] = useState<ApiFormatResult | null>(null)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch(`/rounds/${roundId}/format-result`, { credentials: "include" })
|
||||
fetch(`${apiPrefix}/${roundId}/format-result`, { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data: ApiFormatResult | null) => {
|
||||
if (!cancelled) setResult(data)
|
||||
|
|
@ -1122,7 +1339,7 @@ function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult |
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [roundId, refreshKey])
|
||||
}, [roundId, refreshKey, apiPrefix])
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
@ -1176,12 +1393,17 @@ function buildScrambleSoloResult(round: ApiRoundHeader, formatResult: ApiFormatR
|
|||
|
||||
type FlightSibling = { round_id: string; name: string | null; isAnchor: boolean; teamScore: number | null; par: number | null; through: number }
|
||||
|
||||
function useFlightTeamStandings(roundId: string, playFormat: string, refreshKey: number) {
|
||||
function useFlightTeamStandings(
|
||||
roundId: string,
|
||||
playFormat: string,
|
||||
refreshKey: number,
|
||||
apiPrefix: string = "/rounds",
|
||||
) {
|
||||
const [standings, setStandings] = useState<FlightSibling[] | null>(null)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
const flightRes = await fetch(`/rounds/${roundId}/flight-group`, { credentials: "include" })
|
||||
const flightRes = await fetch(`${apiPrefix}/${roundId}/flight-group`, { credentials: "include" })
|
||||
if (!flightRes.ok) return
|
||||
const flight: { flight_group_id: string | null; flights: { round_id: string; name: string | null; is_anchor: boolean }[] } =
|
||||
await flightRes.json()
|
||||
|
|
@ -1191,7 +1413,7 @@ function useFlightTeamStandings(roundId: string, playFormat: string, refreshKey:
|
|||
}
|
||||
const results = await Promise.all(
|
||||
flight.flights.map(async (f) => {
|
||||
const r = await fetch(`/rounds/${f.round_id}/format-result`, { credentials: "include" })
|
||||
const r = await fetch(`${apiPrefix}/${f.round_id}/format-result`, { credentials: "include" })
|
||||
const data: ApiFormatResult | null = r.ok ? await r.json() : null
|
||||
const teamScore = playFormat === "shamble" ? data?.shamble_team_score ?? null : data?.money_ball_team_score ?? null
|
||||
const holes = data?.holes ?? []
|
||||
|
|
@ -1205,16 +1427,17 @@ function useFlightTeamStandings(roundId: string, playFormat: string, refreshKey:
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [roundId, playFormat, refreshKey])
|
||||
}, [roundId, playFormat, refreshKey, apiPrefix])
|
||||
return standings
|
||||
}
|
||||
|
||||
function useLeaderboardData(roundId: string) {
|
||||
function useLeaderboardData(roundId: string, apiPrefix: string = "/rounds") {
|
||||
const [round, setRound] = useState<ApiRoundHeader | null>(null)
|
||||
const [leaderboard, setLeaderboard] = useState<ApiLeaderboard | null>(null)
|
||||
const [viewerId, setViewerId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const publicMode = apiPrefix !== "/rounds"
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -1231,17 +1454,18 @@ function useLeaderboardData(roundId: string) {
|
|||
|
||||
useEffect(() => {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`)
|
||||
const wsPath = publicMode ? "ws/public/rounds" : "ws/rounds"
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}/${wsPath}/${roundId}/live`)
|
||||
socket.onmessage = () => setRefreshKey((k) => k + 1)
|
||||
return () => socket.close()
|
||||
}, [roundId])
|
||||
}, [roundId, publicMode])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
const [roundRes, leaderboardRes] = await Promise.all([
|
||||
fetch(`/rounds/${roundId}`, { credentials: "include" }),
|
||||
fetch(`/rounds/${roundId}/leaderboard`, { credentials: "include" }),
|
||||
fetch(`${apiPrefix}/${roundId}`, { credentials: "include" }),
|
||||
fetch(`${apiPrefix}/${roundId}/leaderboard`, { credentials: "include" }),
|
||||
])
|
||||
if (roundRes.status === 403 || roundRes.status === 404) {
|
||||
if (!cancelled) setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
|
||||
|
|
@ -1262,7 +1486,7 @@ function useLeaderboardData(roundId: string) {
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [roundId, refreshKey])
|
||||
}, [roundId, refreshKey, apiPrefix])
|
||||
|
||||
const viewerParticipantId = round?.participants.find((p) => p.user_id === viewerId)?.id ?? null
|
||||
|
||||
|
|
@ -1372,20 +1596,30 @@ const METRICS_FOR_FORMAT: Record<string, { metrics: MetricKey[]; defaultMetric:
|
|||
export function RoundLeaderboard({
|
||||
roundId,
|
||||
embedded = false,
|
||||
publicMode = false,
|
||||
}: {
|
||||
roundId: string
|
||||
embedded?: boolean
|
||||
// ADR-044 (tilskuer-visning, 2026-08-06) -- samme mønster som
|
||||
// `RoundScorecard` sin `publicMode`: bytter alle `/rounds/*`-kall til
|
||||
// `/public/rounds/*` og WS-kanalen til den offentlige. INGEN endring
|
||||
// i eiersidens oppførsel når denne er `false` (standard).
|
||||
publicMode?: boolean
|
||||
}) {
|
||||
const { round, leaderboard, viewerParticipantId, error, refreshKey } = useLeaderboardData(roundId)
|
||||
const formatResult = useFormatResult(roundId, refreshKey)
|
||||
const standings = useFlightTeamStandings(roundId, round?.play_format ?? "", refreshKey)
|
||||
const apiPrefix = publicMode ? "/public/rounds" : "/rounds"
|
||||
const { round, leaderboard, viewerParticipantId, error, refreshKey } = useLeaderboardData(roundId, apiPrefix)
|
||||
const formatResult = useFormatResult(roundId, refreshKey, apiPrefix)
|
||||
const standings = useFlightTeamStandings(roundId, round?.play_format ?? "", refreshKey, apiPrefix)
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-5 text-center">
|
||||
<p className="text-base font-medium text-destructive">{error}</p>
|
||||
<Link href="/my-rounds" className="text-base font-semibold text-primary underline underline-offset-2">
|
||||
Tilbake til egne runder
|
||||
<Link
|
||||
href={publicMode ? "/dashboard" : "/my-rounds"}
|
||||
className="text-base font-semibold text-primary underline underline-offset-2"
|
||||
>
|
||||
{publicMode ? "Tilbake til dashbordet" : "Tilbake til egne runder"}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1399,6 +1633,14 @@ export function RoundLeaderboard({
|
|||
)
|
||||
}
|
||||
|
||||
// Delt kommentartråd (ADR-047) -- round_message-reaksjoner/-kommentarer
|
||||
// fester seg til ETT spesifikt innlegg, ikke runden som helhet, så
|
||||
// hver utvidet rad/kamp-seksjon lenker til den SAMME, allerede
|
||||
// eksisterende kommentarseksjonen på selve rundesiden (anker-id
|
||||
// "kommentarer" i round-detail.tsx/watch-round.tsx) i stedet for å late
|
||||
// som om det finnes en egen tråd per spiller/kamp.
|
||||
const commentsHref = publicMode ? `/watch/${roundId}#kommentarer` : `/my-rounds/${roundId}#kommentarer`
|
||||
|
||||
const holeOrder = buildHoleOrder(round.start_hole, round.holes_planned)
|
||||
const holes = buildHoles(leaderboard.entries, holeOrder)
|
||||
const isHighLow = HIGH_LOW_FORMATS.has(round.play_format)
|
||||
|
|
@ -1413,7 +1655,7 @@ export function RoundLeaderboard({
|
|||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}`}
|
||||
href={publicMode ? `/watch/${roundId}` : `/my-rounds/${roundId}`}
|
||||
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-6" />
|
||||
|
|
@ -1458,6 +1700,12 @@ export function RoundLeaderboard({
|
|||
totalA={formatResult.hlh_points_a ?? 0}
|
||||
totalB={formatResult.hlh_points_b ?? 0}
|
||||
formatName="High-low-high"
|
||||
round={round}
|
||||
roundId={roundId}
|
||||
viewerId={viewerParticipantId}
|
||||
refreshKey={refreshKey}
|
||||
apiPrefix={apiPrefix}
|
||||
commentsHref={commentsHref}
|
||||
/>
|
||||
)
|
||||
})()
|
||||
|
|
@ -1505,6 +1753,12 @@ export function RoundLeaderboard({
|
|||
leader={leader}
|
||||
isDormie={Boolean(formatResult.match_is_dormie)}
|
||||
isClosed={Boolean(formatResult.match_is_closed)}
|
||||
round={round}
|
||||
roundId={roundId}
|
||||
viewerId={viewerParticipantId}
|
||||
refreshKey={refreshKey}
|
||||
apiPrefix={apiPrefix}
|
||||
commentsHref={commentsHref}
|
||||
/>
|
||||
)
|
||||
})()
|
||||
|
|
@ -1535,6 +1789,7 @@ export function RoundLeaderboard({
|
|||
members={members}
|
||||
holes={holes}
|
||||
formatName={round.play_format === "shamble" ? "Shamble" : "Money Ball"}
|
||||
commentsHref={commentsHref}
|
||||
/>
|
||||
)
|
||||
})()
|
||||
|
|
@ -1553,6 +1808,7 @@ export function RoundLeaderboard({
|
|||
defaultMetric={config.defaultMetric}
|
||||
pointsUnit={config.pointsUnit}
|
||||
holes={holes}
|
||||
commentsHref={commentsHref}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { ImagePlus, Send, Trash2, X } from "lucide-react"
|
||||
import { Camera, ImagePlus, Send, Trash2, X } from "lucide-react"
|
||||
import { PostEngagement, type ReactionSummary } from "./post-engagement"
|
||||
|
||||
/** Rå meldingsform fra backend. */
|
||||
export type ApiRoundMessage = {
|
||||
|
|
@ -12,6 +13,8 @@ export type ApiRoundMessage = {
|
|||
body: string | null
|
||||
image_url: string | null
|
||||
created_at: string // ISO 8601
|
||||
reactions: ReactionSummary[]
|
||||
comment_count: number
|
||||
}
|
||||
|
||||
export type RoundMessagesProps = {
|
||||
|
|
@ -25,8 +28,38 @@ export type RoundMessagesProps = {
|
|||
refreshKey?: number
|
||||
}
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = "image/jpeg,image/png,image/webp,image/gif"
|
||||
// "image/*" (ikke en eksplisitt liste av MIME-typer) -- mange mobilnett-
|
||||
// lesere viser bare galleri, ikke kamera, i filvelgeren når accept lister
|
||||
// flere eksplisitte typer. Server-siden validerer uansett strengt mot
|
||||
// storage.ALLOWED_INPUT_CONTENT_TYPES, så dette svekker ingen kontroll.
|
||||
const ACCEPTED_IMAGE_TYPES = "image/*"
|
||||
// Funnet 2026-08-06 (ekte enhet, Android/Chrome, installert som PWA): siden
|
||||
// Android 13 bruker Chrome systemets eget bilde-vlger ("Photo Picker") for
|
||||
// <input type=file accept="image/*"> UTEN capture-attributt -- den viser
|
||||
// KUN galleri, aldri et kamera-alternativ. Løsning: to atskilte knapper/
|
||||
// inputs -- én med capture="environment" (rett til kamera), én uten (rett
|
||||
// til galleri) -- i stedet for å stole på at nettleseren tilbyr begge i
|
||||
// samme velger.
|
||||
const MAX_IMAGE_HEIGHT = 300
|
||||
// Samme grense som storage.MAX_UPLOAD_BYTES (server-siden) -- sjekket
|
||||
// klient-side også for en umiddelbar, presis feilmelding uten en unødvendig
|
||||
// tur-retur til serveren for noe som uansett vil bli avvist.
|
||||
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||
|
||||
/** Leser {detail:{code,message}} fra et feil-svar, samme kontrakt som
|
||||
* resten av API-et (app/errors.py). Faller tilbake til en generisk
|
||||
* norsk melding hvis svaret ikke har denne formen (f.eks. et rått 502
|
||||
* fra en proxy, ikke fra selve API-et). */
|
||||
async function readErrorMessage(res: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const data = await res.json()
|
||||
const message = data?.detail?.message
|
||||
if (typeof message === "string" && message.length > 0) return message
|
||||
} catch {
|
||||
// Ikke JSON -- bruk fallback.
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** Kort, norsk relativt tidsstempel (f.eks. "nå", "5 min", "2 t", "i går"). */
|
||||
function formatRelativeTime(iso: string): { label: string; title: string } {
|
||||
|
|
@ -72,6 +105,7 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
|||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const cameraInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
// Hent meldinger ved mount, og på nytt hver gang refreshKey endres
|
||||
// (WS-signal fra round-detail.tsx).
|
||||
|
|
@ -113,11 +147,17 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
|||
return null
|
||||
})
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
if (cameraInputRef.current) cameraInputRef.current.value = ""
|
||||
}, [])
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
setPostError("Bildet er for stort (maks 20 MB). Velg et mindre bilde.")
|
||||
e.target.value = ""
|
||||
return
|
||||
}
|
||||
setPostError(null)
|
||||
setImageFile(file)
|
||||
setImagePreview((prev) => {
|
||||
|
|
@ -145,15 +185,19 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
|||
credentials: "include",
|
||||
body: formData,
|
||||
})
|
||||
if (res.status !== 201) throw new Error(`Status ${res.status}`)
|
||||
if (res.status !== 201) {
|
||||
setPostError(await readErrorMessage(res, "Kunne ikke poste kommentaren. Prøv igjen."))
|
||||
return
|
||||
}
|
||||
|
||||
const created: ApiRoundMessage = await res.json()
|
||||
// Legg nederst — listen er kronologisk, eldst til nyest.
|
||||
setMessages((prev) => [...prev, created])
|
||||
// Legg øverst — nyeste først (2026-08-06, samme rekkefølge som
|
||||
// backend nå returnerer listen i).
|
||||
setMessages((prev) => [created, ...prev])
|
||||
setBody("")
|
||||
clearSelectedImage()
|
||||
} catch {
|
||||
setPostError("Kunne ikke poste kommentaren. Prøv igjen.")
|
||||
setPostError("Kunne ikke poste kommentaren. Sjekk tilkoblingen og prøv igjen.")
|
||||
} finally {
|
||||
setPosting(false)
|
||||
}
|
||||
|
|
@ -222,23 +266,50 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
|||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_IMAGE_TYPES}
|
||||
onChange={handleFileChange}
|
||||
className="sr-only"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg border border-border bg-background px-4 text-base font-medium text-foreground outline-none transition-all duration-200 ease-in-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]"
|
||||
>
|
||||
<ImagePlus aria-hidden="true" className="size-5" />
|
||||
Legg til bilde
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* To atskilte inputs -- se MAX_IMAGE_HEIGHT-kommentaren over:
|
||||
Android sin systembilde-vlger (Photo Picker) viser ALDRI
|
||||
et kamera-alternativ når accept="image/*" mangler
|
||||
capture, uansett hvilken nettleser. capture="environment"
|
||||
her sender rett til kamera i stedet for å stole på at
|
||||
velgeren tilbyr det. */}
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_IMAGE_TYPES}
|
||||
capture="environment"
|
||||
onChange={handleFileChange}
|
||||
className="sr-only"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cameraInputRef.current?.click()}
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg border border-border bg-background px-4 text-base font-medium text-foreground outline-none transition-all duration-200 ease-in-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]"
|
||||
>
|
||||
<Camera aria-hidden="true" className="size-5" />
|
||||
Ta bilde
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_IMAGE_TYPES}
|
||||
onChange={handleFileChange}
|
||||
className="sr-only"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg border border-border bg-background px-4 text-base font-medium text-foreground outline-none transition-all duration-200 ease-in-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]"
|
||||
>
|
||||
<ImagePlus aria-hidden="true" className="size-5" />
|
||||
Velg fra galleri
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
|
|
@ -338,6 +409,14 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
|||
{m.body}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<PostEngagement
|
||||
apiBase={`/rounds/${roundId}/messages/${m.id}`}
|
||||
currentUserId={currentUserId || null}
|
||||
initialReactions={m.reactions}
|
||||
initialCommentCount={m.comment_count}
|
||||
canModerate={roundOwnerUserId === currentUserId}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -597,11 +597,11 @@ type ApiFormatResult = {
|
|||
holes: ApiFormatHoleOut[] | null
|
||||
}
|
||||
|
||||
function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult | null {
|
||||
function useFormatResult(roundId: string, refreshKey: number, apiPrefix: string = "/rounds"): ApiFormatResult | null {
|
||||
const [result, setResult] = useState<ApiFormatResult | null>(null)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch(`/rounds/${roundId}/format-result`, { credentials: "include" })
|
||||
fetch(`${apiPrefix}/${roundId}/format-result`, { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data: ApiFormatResult | null) => {
|
||||
if (!cancelled) setResult(data)
|
||||
|
|
@ -610,7 +610,7 @@ function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult |
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [roundId, refreshKey])
|
||||
}, [roundId, refreshKey, apiPrefix])
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
@ -778,18 +778,20 @@ function LeadBar({
|
|||
|
||||
type MatchRow = { id: string; side: "a" | "b"; label: string; hcp: number | null }
|
||||
|
||||
function MatchScorecardGrid({
|
||||
export function MatchScorecardGrid({
|
||||
round,
|
||||
roundId,
|
||||
viewerId,
|
||||
refreshKey,
|
||||
apiPrefix = "/rounds",
|
||||
}: {
|
||||
round: ApiRound
|
||||
roundId: string
|
||||
viewerId: string | null
|
||||
refreshKey: number
|
||||
apiPrefix?: string
|
||||
}) {
|
||||
const result = useFormatResult(roundId, refreshKey)
|
||||
const result = useFormatResult(roundId, refreshKey, apiPrefix)
|
||||
if (!result) return null
|
||||
|
||||
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
|
||||
|
|
@ -1005,8 +1007,16 @@ function MatchScorecardGrid({
|
|||
)
|
||||
}
|
||||
|
||||
function SkinsProgressTable({ roundId, refreshKey }: { roundId: string; refreshKey: number }) {
|
||||
const result = useFormatResult(roundId, refreshKey)
|
||||
function SkinsProgressTable({
|
||||
roundId,
|
||||
refreshKey,
|
||||
apiPrefix = "/rounds",
|
||||
}: {
|
||||
roundId: string
|
||||
refreshKey: number
|
||||
apiPrefix?: string
|
||||
}) {
|
||||
const result = useFormatResult(roundId, refreshKey, apiPrefix)
|
||||
if (!result || !result.holes || result.holes.length === 0) return null
|
||||
|
||||
const hasNet = result.holes.some((h) => h.entries.some((e) => e.net !== null))
|
||||
|
|
@ -1069,6 +1079,7 @@ function SkinsProgressTable({ roundId, refreshKey }: { roundId: string; refreshK
|
|||
export function RoundScorecard({
|
||||
roundId,
|
||||
embedded = false,
|
||||
publicMode = false,
|
||||
}: {
|
||||
roundId: string
|
||||
// ADR-040 Beslutning D ("det store grepet") -- Scorekort+Statistikk
|
||||
|
|
@ -1077,7 +1088,16 @@ export function RoundScorecard({
|
|||
// `RoundPageShell`, dekker det nå) når den rendres som øverste halvdel
|
||||
// av den slåtte sammen siden, i stedet for standalone.
|
||||
embedded?: boolean
|
||||
// ADR-044 (tilskuer-visning, 2026-08-06) -- gjenbruker DENNE komponenten
|
||||
// uendret for `/watch/{id}/scorecard`, kun med annen datakilde: alle
|
||||
// `/rounds/*`-kall byttes til `/public/rounds/*` (samme respons-form,
|
||||
// `_can_view_round`-autorisert i stedet for eier/medspiller-only), og
|
||||
// WS-kanalen byttes til den offentlige. INGEN visuell/atferdsmessig
|
||||
// endring når `publicMode` er `false` (standard) -- eiersiden er
|
||||
// uberørt.
|
||||
publicMode?: boolean
|
||||
}) {
|
||||
const apiPrefix = publicMode ? "/public/rounds" : "/rounds"
|
||||
const [round, setRound] = useState<ApiRound | null>(null)
|
||||
const [unitId, setUnitId] = useState<string | null>(null)
|
||||
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
||||
|
|
@ -1104,15 +1124,16 @@ export function RoundScorecard({
|
|||
|
||||
useEffect(() => {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`)
|
||||
const wsPath = publicMode ? "ws/public/rounds" : "ws/rounds"
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}/${wsPath}/${roundId}/live`)
|
||||
socket.onmessage = () => setRefreshKey((k) => k + 1)
|
||||
return () => socket.close()
|
||||
}, [roundId])
|
||||
}, [roundId, publicMode])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
const res = await fetch(`/rounds/${roundId}`, { credentials: "include" })
|
||||
const res = await fetch(`${apiPrefix}/${roundId}`, { credentials: "include" })
|
||||
if (res.status === 403 || res.status === 404) {
|
||||
if (!cancelled) setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
|
||||
return
|
||||
|
|
@ -1158,7 +1179,7 @@ export function RoundScorecard({
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [roundId, viewerId, refreshKey])
|
||||
}, [roundId, viewerId, refreshKey, apiPrefix])
|
||||
|
||||
useEffect(() => {
|
||||
if (!unitId || !round) return
|
||||
|
|
@ -1171,8 +1192,8 @@ export function RoundScorecard({
|
|||
// den ene eller den andre typen).
|
||||
const isSideUnit = round.sides.some((s) => s.id === unitId)
|
||||
const endpoint = isSideUnit
|
||||
? `/rounds/${roundId}/sides/${unitId}/holes`
|
||||
: `/rounds/${roundId}/participants/${unitId}/holes`
|
||||
? `${apiPrefix}/${roundId}/sides/${unitId}/holes`
|
||||
: `${apiPrefix}/${roundId}/participants/${unitId}/holes`
|
||||
let cancelled = false
|
||||
async function loadHoles() {
|
||||
const res = await fetch(endpoint, { credentials: "include" })
|
||||
|
|
@ -1184,14 +1205,17 @@ export function RoundScorecard({
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [roundId, unitId, round?.play_format, refreshKey])
|
||||
}, [roundId, unitId, round?.play_format, refreshKey, apiPrefix])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-5 text-center">
|
||||
<p className="text-base font-medium text-destructive">{error}</p>
|
||||
<Link href="/my-rounds" className="text-base font-semibold text-primary underline underline-offset-2">
|
||||
Tilbake til egne runder
|
||||
<Link
|
||||
href={publicMode ? "/dashboard" : "/my-rounds"}
|
||||
className="text-base font-semibold text-primary underline underline-offset-2"
|
||||
>
|
||||
{publicMode ? "Tilbake til dashbordet" : "Tilbake til egne runder"}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1222,7 +1246,7 @@ export function RoundScorecard({
|
|||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}`}
|
||||
href={publicMode ? `/watch/${roundId}` : `/my-rounds/${roundId}`}
|
||||
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-6" />
|
||||
|
|
@ -1318,7 +1342,7 @@ export function RoundScorecard({
|
|||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}`}
|
||||
href={publicMode ? `/watch/${roundId}` : `/my-rounds/${roundId}`}
|
||||
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-6" />
|
||||
|
|
@ -1436,9 +1460,9 @@ export function RoundScorecard({
|
|||
{/* Matchforløp / skins-forløp -- HVORFOR ble resultatet som det ble
|
||||
(brutto/netto/slag mottatt per hull), ikke bare et sluttall. */}
|
||||
{isTwoSided && round.sides.length === 2 && (
|
||||
<MatchScorecardGrid round={round} roundId={roundId} viewerId={viewerId} refreshKey={refreshKey} />
|
||||
<MatchScorecardGrid round={round} roundId={roundId} viewerId={viewerId} refreshKey={refreshKey} apiPrefix={apiPrefix} />
|
||||
)}
|
||||
{isSkins && <SkinsProgressTable roundId={roundId} refreshKey={refreshKey} />}
|
||||
{isSkins && <SkinsProgressTable roundId={roundId} refreshKey={refreshKey} apiPrefix={apiPrefix} />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -610,17 +610,17 @@ export function RoundsStatsSummary() {
|
|||
/>
|
||||
<CompareToPar
|
||||
title="Bunker"
|
||||
aLabel="I bunker"
|
||||
aValue={s.avg_to_par_with_bunker}
|
||||
bLabel="Ikke i bunker"
|
||||
bValue={s.avg_to_par_without_bunker}
|
||||
aLabel="Ikke i bunker"
|
||||
aValue={s.avg_to_par_without_bunker}
|
||||
bLabel="I bunker"
|
||||
bValue={s.avg_to_par_with_bunker}
|
||||
/>
|
||||
<CompareToPar
|
||||
title="Anywayslag"
|
||||
aLabel="Med anywayslag"
|
||||
aValue={s.avg_to_par_with_anyway}
|
||||
bLabel="Uten anywayslag"
|
||||
bValue={s.avg_to_par_without_anyway}
|
||||
aLabel="Uten anywayslag"
|
||||
aValue={s.avg_to_par_without_anyway}
|
||||
bLabel="Med anywayslag"
|
||||
bValue={s.avg_to_par_with_anyway}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@
|
|||
import type React from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ImagePlus, Lock, Send, Trash2, X } from "lucide-react"
|
||||
import { ArrowLeft, Camera, ImagePlus, Lock, Send, Trash2, X } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PostEngagement, type ReactionSummary } from "./post-engagement"
|
||||
|
||||
// --- Types (matcher API-kontrakten i app/routers/messaging.py) -------------
|
||||
|
||||
|
|
@ -16,6 +17,8 @@ type ApiMessage = {
|
|||
body: string | null
|
||||
image_url: string | null
|
||||
created_at: string
|
||||
reactions: ReactionSummary[]
|
||||
comment_count: number
|
||||
}
|
||||
|
||||
const TIME_FMT = new Intl.DateTimeFormat("no-NO", { hour: "2-digit", minute: "2-digit" })
|
||||
|
|
@ -46,8 +49,8 @@ export function TeamChat({
|
|||
const [text, setText] = useState("")
|
||||
const [image, setImage] = useState<File | null>(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -90,7 +93,8 @@ export function TeamChat({
|
|||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const msg: ApiMessage = JSON.parse(event.data)
|
||||
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]))
|
||||
// Nyeste øverst (2026-08-06) -- legg til FØRST, ikke sist.
|
||||
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [msg, ...prev]))
|
||||
} catch {
|
||||
// ignorer ugyldig payload
|
||||
}
|
||||
|
|
@ -98,9 +102,19 @@ export function TeamChat({
|
|||
return () => socket.close()
|
||||
}, [organizationId, teamId, accessError])
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ block: "end" })
|
||||
}, [messages.length])
|
||||
// Samme grense som storage.MAX_UPLOAD_BYTES (server-siden) -- sjekket
|
||||
// klient-side for en umiddelbar, presis feilmelding.
|
||||
function handleImageSelected(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
setError("Bildet er for stort (maks 20 MB). Velg et mindre bilde.")
|
||||
e.target.value = ""
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setImage(file)
|
||||
}
|
||||
|
||||
async function send(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
|
|
@ -116,14 +130,26 @@ export function TeamChat({
|
|||
credentials: "include",
|
||||
body: form,
|
||||
})
|
||||
if (!res.ok) throw new Error(`send: ${res.status}`)
|
||||
if (!res.ok) {
|
||||
let message = "Klarte ikke å sende meldingen. Prøv igjen."
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (typeof data?.detail?.message === "string") message = data.detail.message
|
||||
} catch {
|
||||
// Ikke JSON -- behold standardmeldingen.
|
||||
}
|
||||
setError(message)
|
||||
return
|
||||
}
|
||||
const msg: ApiMessage = await res.json()
|
||||
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]))
|
||||
// Nyeste øverst (2026-08-06) -- legg til FØRST, ikke sist.
|
||||
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [msg, ...prev]))
|
||||
setText("")
|
||||
setImage(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
if (cameraInputRef.current) cameraInputRef.current.value = ""
|
||||
} catch {
|
||||
setError("Klarte ikke å sende meldingen. Prøv igjen.")
|
||||
setError("Klarte ikke å sende meldingen. Sjekk tilkoblingen og prøv igjen.")
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
|
|
@ -224,10 +250,18 @@ export function TeamChat({
|
|||
<span className="px-1 text-[11px] text-muted-foreground">
|
||||
{TIME_FMT.format(new Date(m.created_at))}
|
||||
</span>
|
||||
<div className="w-full px-1">
|
||||
<PostEngagement
|
||||
apiBase={`/orgs/${organizationId}/teams/${teamId}/messages/${m.id}`}
|
||||
currentUserId={currentUserId}
|
||||
initialReactions={m.reactions}
|
||||
initialCommentCount={m.comment_count}
|
||||
canModerate={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</main>
|
||||
|
||||
<div className="sticky bottom-0 border-t border-border bg-background/95 backdrop-blur">
|
||||
|
|
@ -245,6 +279,7 @@ export function TeamChat({
|
|||
onClick={() => {
|
||||
setImage(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
if (cameraInputRef.current) cameraInputRef.current.value = ""
|
||||
}}
|
||||
aria-label="Fjern bilde"
|
||||
>
|
||||
|
|
@ -253,19 +288,41 @@ export function TeamChat({
|
|||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* To atskilte inputs -- Android sin systembilde-vlger
|
||||
(Photo Picker) viser ALDRI et kamera-alternativ når
|
||||
accept="image/*" mangler capture, uansett nettleser
|
||||
(funnet 2026-08-06, ekte enhet). */}
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={handleImageSelected}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => cameraInputRef.current?.click()}
|
||||
aria-label="Ta bilde"
|
||||
className="size-11 shrink-0 rounded-2xl text-muted-foreground"
|
||||
>
|
||||
<Camera aria-hidden="true" className="size-5" />
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => setImage(e.target.files?.[0] ?? null)}
|
||||
onChange={handleImageSelected}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label="Legg ved bilde"
|
||||
aria-label="Velg bilde fra galleri"
|
||||
className="size-11 shrink-0 rounded-2xl text-muted-foreground"
|
||||
>
|
||||
<ImagePlus aria-hidden="true" className="size-5" />
|
||||
|
|
|
|||
520
frontend/components/teecup/teecup-auth.tsx
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useEffect, useId, useRef, useState } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Icons — inline SVG (currentColor), so every action can pair an */
|
||||
/* icon with a real text label and we control size/stroke precisely. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
type IconProps = { className?: string }
|
||||
const stroke = {
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.75,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
}
|
||||
function MailIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2.5" />
|
||||
<path d="m4 7 8 6 8-6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function KeyIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
|
||||
<circle cx="8" cy="15" r="4.5" />
|
||||
<path d="M11 12 21 2m-4 1 3 3m-6 0 3 3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function TicketIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
|
||||
<path d="M3 8.5A1.5 1.5 0 0 1 4.5 7h15A1.5 1.5 0 0 1 21 8.5V10a2 2 0 0 0 0 4v1.5a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 15.5V14a2 2 0 0 0 0-4Z" />
|
||||
<path d="M14 7v10" strokeDasharray="1.5 2.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function EyeIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
|
||||
<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function EyeOffIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
|
||||
<path d="M3 3l18 18" />
|
||||
<path d="M10.6 6.1A9.7 9.7 0 0 1 12 6c6 0 9.5 6 9.5 6a16 16 0 0 1-3.3 3.9M6.3 7.9A16 16 0 0 0 2.5 12S6 18 12 18a9.5 9.5 0 0 0 3.4-.6" />
|
||||
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function ArrowLeftIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
|
||||
<path d="M11 5 4 12l7 7M4 12h16" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function AlertIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
|
||||
<path d="M12 3.5 1.8 20.5h20.4L12 3.5Z" />
|
||||
<path d="M12 10v4.5" />
|
||||
<circle cx="12" cy="17.6" r="0.4" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function CheckIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
|
||||
<path d="m4 12.5 5 5 11-11" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function FlagIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
|
||||
<path d="M6 21V4" />
|
||||
<path d="M6 4.5c3-2 6 1.5 9 0V13c-3 1.5-6-2-9 0" fill="var(--cup)" stroke="none" />
|
||||
<path d="M6 4.5c3-2 6 1.5 9 0V13c-3 1.5-6-2-9 0" />
|
||||
<circle cx="6" cy="21" r="1.6" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
function Spinner({ className }: IconProps) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={cn("animate-spin", className)} aria-hidden="true" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2.5" />
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared styles */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const focusRing =
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-tee-strong focus-visible:ring-offset-2 focus-visible:ring-offset-clubhouse-card"
|
||||
|
||||
const inputBase =
|
||||
"w-full h-12 rounded-xl border bg-clubhouse-field px-4 text-base text-clubhouse-ink placeholder:text-clubhouse-muted/70 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-tee-strong/70"
|
||||
|
||||
const primaryBtn = cn(
|
||||
"inline-flex w-full min-h-[3.25rem] items-center justify-center gap-2 rounded-xl bg-tee-strong px-5 text-base font-semibold text-white shadow-sm transition-colors hover:bg-[#265a18] active:translate-y-px disabled:cursor-not-allowed disabled:opacity-55",
|
||||
focusRing,
|
||||
)
|
||||
|
||||
const secondaryBtn = cn(
|
||||
"inline-flex w-full min-h-[2.75rem] items-center justify-center gap-2 rounded-xl border border-clubhouse-border bg-clubhouse-card px-4 py-2.5 text-base font-medium text-clubhouse-ink transition-colors hover:border-tee-strong/40 hover:bg-clubhouse-field",
|
||||
focusRing,
|
||||
)
|
||||
|
||||
const backBtn = cn(
|
||||
"-mx-2 inline-flex min-h-[2.75rem] items-center gap-1.5 rounded-lg px-2 text-base font-medium text-tee-strong transition-colors hover:underline",
|
||||
focusRing,
|
||||
)
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mocked backend — stands in for the real API so the flow actually */
|
||||
/* runs (loading, success, and error responses are all real here). */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
async function apiSendMagicLink(_email: string) {
|
||||
await sleep(1100)
|
||||
return { ok: true as const }
|
||||
}
|
||||
async function apiPasswordLogin(_email: string, password: string) {
|
||||
await sleep(1100)
|
||||
// Demo credential so the success path is reachable; anything else fails.
|
||||
if (password === "teecup123") return { ok: true as const }
|
||||
return { ok: false as const, error: "Feil e-post eller passord. Prøv igjen." }
|
||||
}
|
||||
async function apiJoinByCode(code: string) {
|
||||
await sleep(1000)
|
||||
const known: Record<string, string> = {
|
||||
TEECUP: "Lørdagsrunden",
|
||||
"RYDER-25": "Ryder Cup 2025",
|
||||
HOST2026: "Høstcupen 2026",
|
||||
}
|
||||
const name = known[code.trim().toUpperCase()]
|
||||
if (name) return { ok: true as const, name }
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `Fant ingen turnering med koden «${code.trim()}». Sjekk at den er riktig skrevet.`,
|
||||
}
|
||||
}
|
||||
|
||||
type Mode = "email" | "sent" | "password" | "code"
|
||||
const RESEND_SECONDS = 30
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Small building blocks */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function FieldError({ id, children }: { id: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<p id={id} role="alert" className="mt-1.5 flex items-start gap-1.5 text-[0.9375rem] font-medium text-cup-strong">
|
||||
<AlertIcon className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{children}</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function FormAlert({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2.5 rounded-xl border border-cup-strong/45 bg-cup/10 px-4 py-3 text-[0.9375rem] leading-snug text-clubhouse-ink"
|
||||
>
|
||||
<AlertIcon className="mt-0.5 size-5 shrink-0 text-cup-strong" />
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
export function TeeCupAuth() {
|
||||
const [mode, setMode] = useState<Mode>("email")
|
||||
|
||||
// shared field state (email persists across email <-> password)
|
||||
const [email, setEmail] = useState("")
|
||||
const [emailTouched, setEmailTouched] = useState(false)
|
||||
|
||||
const emailInvalid = emailTouched && email.length > 0 && !EMAIL_RE.test(email)
|
||||
const emailIds = useId()
|
||||
const emailErrId = `${emailIds}-email-err`
|
||||
|
||||
/* form-level (server) error, shared across states */
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
/* ---- State 1: magic link ---- */
|
||||
async function handleSendLink(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setEmailTouched(true)
|
||||
if (!EMAIL_RE.test(email)) return
|
||||
setFormError(null)
|
||||
setLoading(true)
|
||||
const res = await apiSendMagicLink(email)
|
||||
setLoading(false)
|
||||
if (res.ok) setMode("sent")
|
||||
}
|
||||
|
||||
/* ---- State 2: cooldown ---- */
|
||||
const [cooldown, setCooldown] = useState(0)
|
||||
const [resentNote, setResentNote] = useState(false)
|
||||
useEffect(() => {
|
||||
if (mode !== "sent") return
|
||||
setCooldown(RESEND_SECONDS)
|
||||
}, [mode])
|
||||
useEffect(() => {
|
||||
if (cooldown <= 0) return
|
||||
const t = setInterval(() => setCooldown((c) => (c <= 1 ? 0 : c - 1)), 1000)
|
||||
return () => clearInterval(t)
|
||||
}, [cooldown])
|
||||
|
||||
async function handleResend() {
|
||||
if (cooldown > 0 || loading) return
|
||||
setResentNote(false)
|
||||
setLoading(true)
|
||||
await apiSendMagicLink(email)
|
||||
setLoading(false)
|
||||
setCooldown(RESEND_SECONDS)
|
||||
setResentNote(true)
|
||||
}
|
||||
|
||||
/* ---- State 3: password ---- */
|
||||
const [password, setPassword] = useState("")
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const pwIds = useId()
|
||||
|
||||
async function handlePasswordLogin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!email || !password) return
|
||||
setFormError(null)
|
||||
setLoading(true)
|
||||
const res = await apiPasswordLogin(email, password)
|
||||
setLoading(false)
|
||||
if (res.ok) {
|
||||
setSuccess("Logget inn! Neste steg er en engangskode på e-post (2FA).")
|
||||
} else {
|
||||
setFormError(res.error)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- State 4: join by code ---- */
|
||||
const [code, setCode] = useState("")
|
||||
async function handleJoin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!code.trim()) return
|
||||
setFormError(null)
|
||||
setLoading(true)
|
||||
const res = await apiJoinByCode(code)
|
||||
setLoading(false)
|
||||
if (res.ok) {
|
||||
setSuccess(`Fant turneringen «${res.name}». Sender deg dit …`)
|
||||
} else {
|
||||
setFormError(res.error)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- shared success confirmation ---- */
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
// Reset transient per-mode state when switching modes.
|
||||
function switchMode(next: Mode, opts?: { clearEmail?: boolean }) {
|
||||
setFormError(null)
|
||||
setSuccess(null)
|
||||
setPassword("")
|
||||
setShowPassword(false)
|
||||
setResentNote(false)
|
||||
if (opts?.clearEmail) {
|
||||
setEmail("")
|
||||
setEmailTouched(false)
|
||||
}
|
||||
if (next === "code") setCode("")
|
||||
setMode(next)
|
||||
}
|
||||
|
||||
/* ---------------- success view (shared continuation) ------------- */
|
||||
if (success) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-5 flex size-16 items-center justify-center rounded-full bg-tee/20 text-tee-strong">
|
||||
<CheckIcon className="size-8" />
|
||||
</div>
|
||||
<h2 className="text-pretty text-xl font-bold text-clubhouse-ink">Alt klart</h2>
|
||||
<p className="mx-auto mt-2 max-w-xs text-pretty text-base leading-relaxed text-clubhouse-muted">{success}</p>
|
||||
<button type="button" className={cn(backBtn, "mt-6 justify-center")} onClick={() => switchMode("email", { clearEmail: true })}>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
Tilbake til innlogging
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ---------- State 1: Magic link (primary path) ---------- */}
|
||||
{mode === "email" && (
|
||||
<form onSubmit={handleSendLink} noValidate>
|
||||
<h2 className="text-balance text-xl font-bold text-clubhouse-ink">Logg inn</h2>
|
||||
<p className="mt-1.5 text-pretty text-base leading-relaxed text-clubhouse-muted">
|
||||
Klart for en runde? Skriv inn e-posten din, så sender vi deg en lenke.
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<label htmlFor="email" className="mb-1.5 block text-[0.9375rem] font-semibold text-clubhouse-ink">
|
||||
E-post
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
inputMode="email"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
enterKeyHint="go"
|
||||
placeholder="deg@eksempel.no"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onBlur={() => setEmailTouched(true)}
|
||||
aria-invalid={emailInvalid || undefined}
|
||||
aria-describedby={emailInvalid ? emailErrId : undefined}
|
||||
className={cn(inputBase, emailInvalid ? "border-cup-strong ring-2 ring-cup-strong/30" : "border-clubhouse-border")}
|
||||
/>
|
||||
{emailInvalid && <FieldError id={emailErrId}>Skriv inn en gyldig e-postadresse, f.eks. navn@klubb.no.</FieldError>}
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={loading} className={cn(primaryBtn, "mt-5")}>
|
||||
{loading ? <Spinner className="size-5" /> : <MailIcon className="size-5" />}
|
||||
{loading ? "Sender …" : "Send innloggingslenke"}
|
||||
</button>
|
||||
<p className="mt-3 text-pretty text-[0.9375rem] leading-relaxed text-clubhouse-muted">
|
||||
Ingen passord nødvendig. Vi sender deg en sikker lenke på e-post.
|
||||
</p>
|
||||
|
||||
<div className="mt-7 border-t border-clubhouse-border pt-5">
|
||||
<div className="grid gap-2.5">
|
||||
<button type="button" className={secondaryBtn} onClick={() => switchMode("password")}>
|
||||
<KeyIcon className="size-5 text-tee-strong" />
|
||||
Logg inn med e-post og passord i stedet
|
||||
</button>
|
||||
<button type="button" className={secondaryBtn} onClick={() => switchMode("code")}>
|
||||
<TicketIcon className="size-5 text-cup-strong" />
|
||||
Har du en invitasjonskode? Gå rett til turneringen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* ---------- State 2: Link sent ---------- */}
|
||||
{mode === "sent" && (
|
||||
<div>
|
||||
<div className="mb-5 flex size-14 items-center justify-center rounded-2xl bg-tee/20 text-tee-strong">
|
||||
<MailIcon className="size-7" />
|
||||
</div>
|
||||
<h2 className="text-balance text-xl font-bold text-clubhouse-ink">Lenke sendt</h2>
|
||||
<p className="mt-2 text-pretty text-base leading-relaxed text-clubhouse-ink">
|
||||
Vi sendte en innloggingslenke til <span className="font-semibold break-all">{email}</span>.
|
||||
</p>
|
||||
<p className="mt-2 text-pretty text-[0.9375rem] leading-relaxed text-clubhouse-muted">
|
||||
Åpne lenken på denne enheten for å logge inn. Det kan ta et minutt før den kommer frem.
|
||||
</p>
|
||||
|
||||
{resentNote && (
|
||||
<p role="status" className="mt-4 flex items-center gap-2 text-[0.9375rem] font-medium text-tee-strong">
|
||||
<CheckIcon className="size-5 shrink-0" />
|
||||
Lenken er sendt på nytt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button type="button" onClick={handleResend} disabled={cooldown > 0 || loading} className={cn(primaryBtn, "mt-6")}>
|
||||
{loading ? <Spinner className="size-5" /> : <MailIcon className="size-5" />}
|
||||
{loading ? "Sender …" : cooldown > 0 ? `Send igjen om ${cooldown}s` : "Send lenken igjen"}
|
||||
</button>
|
||||
|
||||
<div className="mt-6">
|
||||
<button type="button" className={backBtn} onClick={() => switchMode("email", { clearEmail: true })}>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
Bruk en annen e-postadresse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---------- State 3: Password fallback ---------- */}
|
||||
{mode === "password" && (
|
||||
<form onSubmit={handlePasswordLogin} noValidate>
|
||||
<h2 className="text-balance text-xl font-bold text-clubhouse-ink">Logg inn med passord</h2>
|
||||
<p className="mt-1.5 text-pretty text-base leading-relaxed text-clubhouse-muted">
|
||||
For deg som har satt et passord på kontoen din.
|
||||
</p>
|
||||
|
||||
{formError && <div className="mt-5">{<FormAlert>{formError}</FormAlert>}</div>}
|
||||
|
||||
<div className="mt-5">
|
||||
<label htmlFor="email-pw" className="mb-1.5 block text-[0.9375rem] font-semibold text-clubhouse-ink">
|
||||
E-post
|
||||
</label>
|
||||
<input
|
||||
id="email-pw"
|
||||
name="email"
|
||||
type="email"
|
||||
inputMode="email"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
placeholder="deg@eksempel.no"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={cn(inputBase, "border-clubhouse-border")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label htmlFor={pwIds} className="mb-1.5 block text-[0.9375rem] font-semibold text-clubhouse-ink">
|
||||
Passord
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id={pwIds}
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
placeholder="Passordet ditt"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={cn(inputBase, "border-clubhouse-border pr-14")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
aria-label={showPassword ? "Skjul passord" : "Vis passord"}
|
||||
aria-pressed={showPassword}
|
||||
className={cn(
|
||||
"absolute right-1 top-1/2 flex size-11 -translate-y-1/2 items-center justify-center rounded-lg text-clubhouse-muted transition-colors hover:text-clubhouse-ink",
|
||||
focusRing,
|
||||
)}
|
||||
>
|
||||
{showPassword ? <EyeOffIcon className="size-5" /> : <EyeIcon className="size-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={loading || !email || !password} className={cn(primaryBtn, "mt-6")}>
|
||||
{loading ? <Spinner className="size-5" /> : <KeyIcon className="size-5" />}
|
||||
{loading ? "Logger inn …" : "Logg inn"}
|
||||
</button>
|
||||
|
||||
<div className="mt-6">
|
||||
<button type="button" className={backBtn} onClick={() => switchMode("email")}>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
Bruk innloggingslenke på e-post i stedet
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* ---------- State 4: Join by code ---------- */}
|
||||
{mode === "code" && (
|
||||
<form onSubmit={handleJoin} noValidate>
|
||||
<h2 className="text-balance text-xl font-bold text-clubhouse-ink">Bli med via kode</h2>
|
||||
<p className="mt-1.5 text-pretty text-base leading-relaxed text-clubhouse-muted">
|
||||
Fått en kode muntlig eller på en lapp? Skriv den inn her — du trenger ikke logge inn for å se turneringen eller melde deg på.
|
||||
</p>
|
||||
|
||||
{formError && <div className="mt-5">{<FormAlert>{formError}</FormAlert>}</div>}
|
||||
|
||||
<div className="mt-5">
|
||||
<label htmlFor="invite" className="mb-1.5 block text-[0.9375rem] font-semibold text-clubhouse-ink">
|
||||
Invitasjonskode
|
||||
</label>
|
||||
<input
|
||||
id="invite"
|
||||
name="invite"
|
||||
type="text"
|
||||
autoFocus
|
||||
autoCapitalize="characters"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
enterKeyHint="go"
|
||||
placeholder="F.EKS. RYDER-25"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
aria-describedby="invite-hint"
|
||||
className={cn(inputBase, "border-clubhouse-border font-mono tracking-[0.15em] uppercase")}
|
||||
/>
|
||||
<p id="invite-hint" className="mt-1.5 text-[0.875rem] text-clubhouse-muted">
|
||||
Prøv <span className="font-mono font-semibold text-clubhouse-ink">TEECUP</span> for en demo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={loading || !code.trim()} className={cn(primaryBtn, "mt-6")}>
|
||||
{loading ? <Spinner className="size-5" /> : <FlagIcon className="size-5" />}
|
||||
{loading ? "Sjekker …" : "Gå til turnering"}
|
||||
</button>
|
||||
|
||||
<div className="mt-6">
|
||||
<button type="button" className={backBtn} onClick={() => switchMode("email")}>
|
||||
<ArrowLeftIcon className="size-5" />
|
||||
Tilbake til innlogging
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
frontend/components/teecup/wordmark.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* TeeCup wordmark, rebuilt from the supplied logo as pure vector paths so it
|
||||
* stays razor-sharp at small sizes (e.g. the page header). "Tee" is drawn in
|
||||
* fairway green, "Cup" in the brand orange-red.
|
||||
*/
|
||||
export function TeeCupWordmark({
|
||||
className,
|
||||
title = "TeeCup",
|
||||
}: {
|
||||
className?: string
|
||||
title?: string
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="198 136 214 62"
|
||||
role="img"
|
||||
aria-label={title}
|
||||
className={cn("h-auto w-auto", className)}
|
||||
>
|
||||
{/* Tee */}
|
||||
<g fill="var(--tee-strong)">
|
||||
<path
|
||||
transform="translate(201.67657,181.31221)"
|
||||
d="m 33.5625,-41.859375 v 6.953125 H 21.46875 V 0 H 13.234375 V -34.90625 H 1.140625 v -6.953125 z m 0,0"
|
||||
/>
|
||||
<path
|
||||
transform="translate(227.37493,181.31221)"
|
||||
d="M 31.203125,-14 H 10.90625 c 0.226562,2.75 1.007812,4.75 2.34375,6 1.34375,1.25 3.164062,1.875 5.46875,1.875 1.820312,0 3.296875,-0.335938 4.421875,-1.015625 1.125,-0.675781 2.03125,-1.703125 2.71875,-3.078125 h 0.59375 l 5.265625,3.375 c -1.03125,2.21875 -2.589844,4.015625 -4.671875,5.390625 -2.074219,1.3671875 -4.851563,2.046875 -8.328125,2.046875 -3.261719,0 -6.078125,-0.6171875 -8.453125,-1.859375 -2.375,-1.238281 -4.210937,-3.082031 -5.5,-5.53125 -1.292969,-2.457031 -1.9375,-5.453125 -1.9375,-8.984375 0,-3.582031 0.675781,-6.601562 2.03125,-9.0625 1.363281,-2.46875 3.195313,-4.304688 5.5,-5.515625 2.3125,-1.21875 4.910156,-1.828125 7.796875,-1.828125 4.46875,0 7.890625,1.1875 10.265625,3.5625 2.375,2.367188 3.5625,5.839844 3.5625,10.421875 0,1.65625 -0.05859,2.945313 -0.171875,3.859375 z m -6.8125,-5.125 c -0.07422,-2.40625 -0.632813,-4.15625 -1.671875,-5.25 -1.042969,-1.101562 -2.5625,-1.65625 -4.5625,-1.65625 -4.011719,0 -6.371094,2.304688 -7.078125,6.90625 z m 0,0"
|
||||
/>
|
||||
<path
|
||||
transform="translate(261.70112,181.31221)"
|
||||
d="M 31.203125,-14 H 10.90625 c 0.226562,2.75 1.007812,4.75 2.34375,6 1.34375,1.25 3.164062,1.875 5.46875,1.875 1.820312,0 3.296875,-0.335938 4.421875,-1.015625 1.125,-0.675781 2.03125,-1.703125 2.71875,-3.078125 h 0.59375 l 5.265625,3.375 c -1.03125,2.21875 -2.589844,4.015625 -4.671875,5.390625 -2.074219,1.3671875 -4.851563,2.046875 -8.328125,2.046875 -3.261719,0 -6.078125,-0.6171875 -8.453125,-1.859375 -2.375,-1.238281 -4.210937,-3.082031 -5.5,-5.53125 -1.292969,-2.457031 -1.9375,-5.453125 -1.9375,-8.984375 0,-3.582031 0.675781,-6.601562 2.03125,-9.0625 1.363281,-2.46875 3.195313,-4.304688 5.5,-5.515625 2.3125,-1.21875 4.910156,-1.828125 7.796875,-1.828125 4.46875,0 7.890625,1.1875 10.265625,3.5625 2.375,2.367188 3.5625,5.839844 3.5625,10.421875 0,1.65625 -0.05859,2.945313 -0.171875,3.859375 z m -6.8125,-5.125 c -0.07422,-2.40625 -0.632813,-4.15625 -1.671875,-5.25 -1.042969,-1.101562 -2.5625,-1.65625 -4.5625,-1.65625 -4.011719,0 -6.371094,2.304688 -7.078125,6.90625 z m 0,0"
|
||||
/>
|
||||
</g>
|
||||
{/* Cup */}
|
||||
<g fill="var(--cup)">
|
||||
<path d="M 22.875 0.59375 C 19.019531 0.59375 15.625 -0.203125 12.6875 -1.796875 C 9.75 -3.398438 7.445312 -5.8125 5.78125 -9.03125 C 4.125 -12.25 3.296875 -16.21875 3.296875 -20.9375 C 3.296875 -25.644531 4.125 -29.609375 5.78125 -32.828125 C 7.445312 -36.046875 9.75 -38.457031 12.6875 -40.0625 C 15.625 -41.664062 19.019531 -42.46875 22.875 -42.46875 C 26.90625 -42.46875 30.328125 -41.675781 33.140625 -40.09375 C 35.953125 -38.519531 37.960938 -36.125 39.171875 -32.90625 L 33.125 -29.203125 L 32.40625 -29.234375 C 31.507812 -31.410156 30.289062 -32.96875 28.75 -33.90625 C 27.21875 -34.851562 25.257812 -35.328125 22.875 -35.328125 C 19.40625 -35.328125 16.695312 -34.175781 14.75 -31.875 C 12.800781 -29.582031 11.828125 -25.9375 11.828125 -20.9375 C 11.828125 -15.945312 12.800781 -12.300781 14.75 -10 C 16.695312 -7.707031 19.40625 -6.5625 22.875 -6.5625 C 28.3125 -6.5625 31.644531 -9.203125 32.875 -14.484375 L 33.59375 -14.484375 L 39.6875 -11.3125 C 38.539062 -7.34375 36.582031 -4.363281 33.8125 -2.375 C 31.039062 -0.394531 27.394531 0.59375 22.875 0.59375 Z M 22.875 0.59375 " transform="translate(294.859051,181.312207)" />
|
||||
<path d="M 14.453125 0.59375 C 10.910156 0.59375 8.289062 -0.375 6.59375 -2.3125 C 4.90625 -4.25 4.0625 -7.03125 4.0625 -10.65625 L 4.0625 -31.578125 L 12 -31.578125 L 12 -11.65625 C 12 -9.726562 12.410156 -8.316406 13.234375 -7.421875 C 14.066406 -6.535156 15.351562 -6.09375 17.09375 -6.09375 C 19.289062 -6.09375 21 -6.8125 22.21875 -8.25 C 23.4375 -9.695312 24.046875 -11.96875 24.046875 -15.0625 L 24.046875 -31.578125 L 31.984375 -31.578125 L 31.984375 0 L 24.359375 0 L 24.359375 -4.640625 L 23.765625 -4.78125 C 22.753906 -2.96875 21.515625 -1.617188 20.046875 -0.734375 C 18.585938 0.148438 16.722656 0.59375 14.453125 0.59375 Z M 14.453125 0.59375 " transform="translate(335.913048,181.312207)" />
|
||||
<path d="M 4.546875 12.796875 L 4.546875 -31.578125 L 12.484375 -31.578125 L 12.484375 -27.453125 L 13.078125 -27.34375 C 14.015625 -28.945312 15.265625 -30.15625 16.828125 -30.96875 C 18.398438 -31.78125 20.234375 -32.1875 22.328125 -32.1875 C 24.867188 -32.1875 27.085938 -31.582031 28.984375 -30.375 C 30.890625 -29.175781 32.367188 -27.347656 33.421875 -24.890625 C 34.484375 -22.441406 35.015625 -19.40625 35.015625 -15.78125 C 35.015625 -12.164062 34.457031 -9.132812 33.34375 -6.6875 C 32.226562 -4.25 30.675781 -2.425781 28.6875 -1.21875 C 26.707031 -0.0078125 24.414062 0.59375 21.8125 0.59375 C 19.851562 0.59375 18.15625 0.261719 16.71875 -0.390625 C 15.289062 -1.046875 14.078125 -2.070312 13.078125 -3.46875 L 12.484375 -3.34375 L 12.484375 12.796875 Z M 19.609375 -6.125 C 21.984375 -6.125 23.78125 -6.894531 25 -8.4375 C 26.21875 -9.988281 26.828125 -12.4375 26.828125 -15.78125 C 26.828125 -19.113281 26.21875 -21.554688 25 -23.109375 C 23.78125 -24.671875 21.984375 -25.453125 19.609375 -25.453125 C 17.398438 -25.453125 15.644531 -24.648438 14.34375 -23.046875 C 13.039062 -21.441406 12.390625 -19.019531 12.390625 -15.78125 C 12.390625 -12.550781 13.039062 -10.132812 14.34375 -8.53125 C 15.644531 -6.925781 17.398438 -6.125 19.609375 -6.125 Z " transform="translate(372.443662,181.312207)" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -329,7 +329,9 @@ export function TournamentPresentationPanel({
|
|||
<input
|
||||
ref={heroInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
// "image/*" -- mange mobilnettlesere viser bare
|
||||
// galleri, ikke kamera, med en eksplisitt MIME-liste.
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleHeroSelected}
|
||||
/>
|
||||
|
|
|
|||
28
frontend/components/watch-leaderboard.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client"
|
||||
|
||||
// Leaderboard-fanen for tilskuer-visningen (ADR-044, 2026-08-06). Gjenbruker
|
||||
// round-leaderboard.tsx sin RoundLeaderboard UENDRET (samme formatspesifikke
|
||||
// tavler som eiersiden -- IndividualBoard/TwoSidedBoard/HighLowBoard/
|
||||
// TeamFlightBoard, alle ni+ formater -- se komponentens `publicMode`-prop),
|
||||
// kun matet fra /public/rounds/* i stedet for /rounds/*.
|
||||
|
||||
import { Trophy } from "lucide-react"
|
||||
import { WatchTabs } from "@/components/watch-tabs"
|
||||
import { RoundLeaderboard } from "@/components/round-leaderboard"
|
||||
|
||||
export function WatchLeaderboard({ roundId }: { roundId: string }) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-background">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||
<span className="flex min-h-11 items-center gap-1.5 text-base font-bold text-foreground">
|
||||
<Trophy aria-hidden="true" className="size-5 text-primary" />
|
||||
Følger live
|
||||
</span>
|
||||
</div>
|
||||
<WatchTabs roundId={roundId} active="leaderboard" />
|
||||
</header>
|
||||
<RoundLeaderboard roundId={roundId} embedded publicMode />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
143
frontend/components/watch-players.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"use client"
|
||||
|
||||
// "Spillere og runde", read-only tilskuer-variant (ADR-044, 2026-08-06).
|
||||
// Bevisst IKKE et forsøk på å gjenbruke round-detail.tsx sin PlayerList
|
||||
// via en canManage=false-prop -- den er en lokal, ikke-eksportert funksjon
|
||||
// tett koblet til eier-autentiserte handlere (onPatchParticipant m.fl.),
|
||||
// og selv canManage dekker i dag ikke alt konsekvent der (Fullfør runde-
|
||||
// knappen er kun gated på !completed). Denne komponenten inneholder rett
|
||||
// og slett INGEN handlings-kode (ingen +Medspiller, ingen Rediger/Fullfør/
|
||||
// Slett) -- ingen risiko for at en eier-handling glipper gjennom.
|
||||
|
||||
import Link from "next/link"
|
||||
import { Users } from "lucide-react"
|
||||
|
||||
export type ApiPublicParticipant = {
|
||||
id: string
|
||||
display_name: string
|
||||
is_owner: boolean
|
||||
tee_name_snapshot: string
|
||||
handicap_index_snapshot: number | null
|
||||
course_handicap_snapshot: number | null
|
||||
round_side_id: string | null
|
||||
playing_handicap: number | null
|
||||
}
|
||||
|
||||
export type ApiLeaderboardEntry = {
|
||||
participant_id: string
|
||||
display_name: string
|
||||
holes_played: number
|
||||
total_score: number | null
|
||||
score_to_par: number | null
|
||||
}
|
||||
|
||||
export type ApiFlightSummary = {
|
||||
round_id: string
|
||||
name: string | null
|
||||
course_name_snapshot: string
|
||||
played_at: string
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
participant_count: number
|
||||
is_anchor: boolean
|
||||
}
|
||||
|
||||
function formatToPar(value: number): string {
|
||||
if (value === 0) return "E"
|
||||
return value > 0 ? `+${value}` : `−${Math.abs(value)}`
|
||||
}
|
||||
|
||||
function ParticipantCard({ participant, progress }: { participant: ApiPublicParticipant; progress: ApiLeaderboardEntry | null }) {
|
||||
// Tildelte slag: allowance-justert spillehandicap der satt, ellers
|
||||
// course handicap -- samme prioritet som eiersidens PlayerList bruker
|
||||
// for "Tildelte slag".
|
||||
const strokesGiven = participant.playing_handicap ?? participant.course_handicap_snapshot
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-base font-bold text-foreground">{participant.display_name}</span>
|
||||
{participant.is_owner && (
|
||||
<span className="rounded-full border border-border px-2 py-0.5 text-xs font-semibold text-muted-foreground">
|
||||
Eier
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Utslag: <span className="font-semibold text-foreground">{participant.tee_name_snapshot}</span>
|
||||
{participant.handicap_index_snapshot !== null && (
|
||||
<>
|
||||
{" · "}HCP: <span className="font-semibold text-foreground">{participant.handicap_index_snapshot.toFixed(1)}</span>
|
||||
</>
|
||||
)}
|
||||
{strokesGiven !== null && (
|
||||
<>
|
||||
{" · "}Tildelte slag: <span className="font-semibold text-foreground">{strokesGiven}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{progress && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Så langt: <span className="font-semibold text-foreground">{progress.holes_played} hull</span>
|
||||
{progress.total_score !== null && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="font-semibold text-foreground">{progress.total_score} slag</span>
|
||||
</>
|
||||
)}
|
||||
{progress.score_to_par !== null && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="font-semibold text-foreground">{formatToPar(progress.score_to_par)} til par</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WatchPlayers({
|
||||
participants,
|
||||
leaderboardEntries,
|
||||
flightGroup,
|
||||
}: {
|
||||
participants: ApiPublicParticipant[]
|
||||
leaderboardEntries: ApiLeaderboardEntry[]
|
||||
flightGroup: { flight_group_id: string | null; flights: ApiFlightSummary[] } | null
|
||||
}) {
|
||||
const progressByParticipant = new Map(leaderboardEntries.map((e) => [e.participant_id, e]))
|
||||
const otherFlights = (flightGroup?.flights ?? []).filter((f) => !f.is_anchor)
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4" aria-label="Spillere og runde">
|
||||
{otherFlights.length > 0 && (
|
||||
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-4">
|
||||
<span className="flex items-center gap-1.5 text-sm font-bold text-foreground">
|
||||
<Users aria-hidden="true" className="size-4 text-primary" />
|
||||
Flere flighter deler dette leaderboardet
|
||||
</span>
|
||||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||||
{otherFlights.map((f) => (
|
||||
<li key={f.round_id}>
|
||||
<Link
|
||||
href={`/watch/${f.round_id}`}
|
||||
className="flex min-h-11 items-center justify-between gap-2 px-3 py-2 text-sm font-semibold text-foreground transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<span>{f.name || "Flight"}</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">{f.participant_count} spillere</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{participants.map((p) => (
|
||||
<ParticipantCard key={p.id} participant={p} progress={progressByParticipant.get(p.id) ?? null} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,33 +1,27 @@
|
|||
"use client"
|
||||
|
||||
// Tredjeparts/offentlig live-visning av en frittstående runde (ADR-036
|
||||
// fase 2, 2026-07-28) -- der en venn (i en synlig kategori) eller alle
|
||||
// (visibility_mode='public') kan følge runden UTEN å være eier/deltaker.
|
||||
// Ferskbygget, IKKE en gjenbruk av round-detail.tsx/round-leaderboard.tsx
|
||||
// fase 2, 2026-07-28). Ferskbygget, IKKE en gjenbruk av round-detail.tsx
|
||||
// (som antar eier-/deltaker-tilgang og full skrive-UI) -- denne siden er
|
||||
// rendyrket lesevisning mot de nye /public/rounds/*-endepunktene, som
|
||||
// fungerer like fint anonymt som innlogget (get_current_user_optional).
|
||||
//
|
||||
// Tre distinkte presentasjoner avhengig av play_format, samme inndeling
|
||||
// som round-leaderboard.tsx sin TWO_SIDED_FORMATS-logikk:
|
||||
// - Slagspill: individuell rangering (brutto til par).
|
||||
// - Skins: skins-tavle (hvem har vunnet flest skins).
|
||||
// - Match/fourball/foursome/greensome/scramble: matchstatus + hull-merker.
|
||||
// Score-fanen (denne filen): kompakt status + "Spillere og runde"
|
||||
// (deltakerliste, HCP/utslag/tildelte slag, "så langt", flight-
|
||||
// gruppering) -- se watch-players.tsx. Selve matchstatus/skins-tavle/
|
||||
// leaderboard flyttet til /watch/[id]/leaderboard (ADR-044, 2026-08-06),
|
||||
// som gjenbruker den ALLEREDE eksisterende round-leaderboard.tsx uendret
|
||||
// (kun `publicMode`-bryteren), for full formatparitet med eiersiden.
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Radio, Trophy } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { WatchTabs } from "@/components/watch-tabs"
|
||||
import { WatchPlayers, type ApiPublicParticipant, type ApiLeaderboardEntry, type ApiFlightSummary } from "@/components/watch-players"
|
||||
import { RoundMessages } from "@/components/round-messages"
|
||||
|
||||
type ApiSide = { id: string; label: string | null }
|
||||
|
||||
type ApiParticipant = {
|
||||
id: string
|
||||
display_name: string
|
||||
is_owner: boolean
|
||||
}
|
||||
|
||||
type ApiRound = {
|
||||
id: string
|
||||
name: string | null
|
||||
|
|
@ -37,47 +31,13 @@ type ApiRound = {
|
|||
play_format: string
|
||||
completed_at: string | null
|
||||
owner_display_name: string
|
||||
participants: ApiParticipant[]
|
||||
owner_user_id: string
|
||||
participants: ApiPublicParticipant[]
|
||||
sides: ApiSide[]
|
||||
}
|
||||
|
||||
type ApiLeaderboardEntry = {
|
||||
participant_id: string
|
||||
display_name: string
|
||||
holes_played: number
|
||||
total_score: number | null
|
||||
score_to_par: number | null
|
||||
}
|
||||
|
||||
type ApiLeaderboard = { holes_planned: number; completed: boolean; entries: ApiLeaderboardEntry[] }
|
||||
|
||||
type ApiFormatResult = {
|
||||
play_format: string
|
||||
ready: boolean
|
||||
match_lead: number | null
|
||||
match_holes_played: number | null
|
||||
match_holes_remaining: number | null
|
||||
match_is_closed: boolean | null
|
||||
match_is_dormie: boolean | null
|
||||
match_status_text: string | null
|
||||
hole_results: string[] | null
|
||||
skins_won: Record<string, number> | null
|
||||
// High-low-high (2026-07-30) -- literal poengsum, IKKE match-play
|
||||
// "X UP" (match_status_text settes aldri for dette formatet).
|
||||
hlh_points_a: number | null
|
||||
hlh_points_b: number | null
|
||||
}
|
||||
|
||||
// Territorium-bar (2026-07-29, samme visuelle språk som identitetsbanneret i
|
||||
// round-detail.tsx/round-scorecard.tsx/session-scorecard.tsx/round-
|
||||
// leaderboard.tsx) -- ingen navn her (vises andre steder på siden), kun
|
||||
// fargesonene + den sentrerte statuspillen, for full visuell konsistens.
|
||||
function leadZoneFraction(lead: number, totalHoles: number): number {
|
||||
if (totalHoles <= 0) return 0.5
|
||||
const fraction = 0.5 + (lead / totalHoles) * 0.5
|
||||
return Math.min(0.82, Math.max(0.18, fraction))
|
||||
}
|
||||
|
||||
const FORMAT_LABELS: Record<string, string> = {
|
||||
stroke: "Slagspill",
|
||||
stableford: "Stableford",
|
||||
|
|
@ -98,176 +58,32 @@ const FORMAT_LABELS: Record<string, string> = {
|
|||
scramble_solo: "Scramble mot enkeltspiller",
|
||||
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
|
||||
}
|
||||
const TWO_SIDED_FORMATS = new Set([
|
||||
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
|
||||
])
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
||||
|
||||
function formatToPar(value: number): string {
|
||||
if (value === 0) return "E"
|
||||
return value > 0 ? `+${value}` : `−${Math.abs(value)}`
|
||||
}
|
||||
|
||||
function substituteSideLabels(text: string, sides: ApiSide[]): string {
|
||||
const labelFor = (idx: number) => sides[idx]?.label?.trim() || `Side ${idx === 0 ? "A" : "B"}`
|
||||
return text.replace(/\(A\)/, `(${labelFor(0)})`).replace(/\(B\)/, `(${labelFor(1)})`)
|
||||
}
|
||||
|
||||
// --- Slagspill: individuell rangering ---------------------------------------
|
||||
|
||||
function StrokeLeaderboard({ leaderboard }: { leaderboard: ApiLeaderboard }) {
|
||||
const ranked = [...leaderboard.entries].sort((a, b) => {
|
||||
if (a.score_to_par === null) return 1
|
||||
if (b.score_to_par === null) return -1
|
||||
return a.score_to_par - b.score_to_par
|
||||
})
|
||||
return (
|
||||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
|
||||
{ranked.map((entry, i) => (
|
||||
<li key={entry.participant_id} className="flex items-center gap-3 p-3 sm:p-4">
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-full text-sm font-extrabold tabular-nums",
|
||||
i === 0 && entry.score_to_par !== null ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{entry.score_to_par !== null ? `#${i + 1}` : "–"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-base font-bold text-foreground">{entry.display_name}</span>
|
||||
<span className="shrink-0 text-sm text-muted-foreground">
|
||||
{leaderboard.completed ? "Ferdig" : entry.holes_played > 0 ? `${entry.holes_played} hull` : "Ikke startet"}
|
||||
</span>
|
||||
{entry.score_to_par !== null && (
|
||||
<span className="shrink-0 text-base font-extrabold tabular-nums text-foreground">
|
||||
{formatToPar(entry.score_to_par)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Skins-tavle -------------------------------------------------------------
|
||||
|
||||
function SkinsBoard({ participants, skinsWon }: { participants: ApiParticipant[]; skinsWon: Record<string, number> }) {
|
||||
const rows = participants
|
||||
.map((p) => ({ participant: p, skins: skinsWon[p.id] ?? 0 }))
|
||||
.sort((a, b) => b.skins - a.skins)
|
||||
const totalAwarded = Object.values(skinsWon).reduce((sum, v) => sum + v, 0)
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-bold text-foreground">Skins-tavle</span>
|
||||
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
||||
{totalAwarded} skin{totalAwarded !== 1 ? "s" : ""} avgjort
|
||||
</span>
|
||||
</div>
|
||||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||||
{rows.map(({ participant, skins }) => (
|
||||
<li key={participant.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
<span className="text-sm font-semibold text-foreground">{participant.display_name}</span>
|
||||
<span className="text-base font-extrabold tabular-nums text-foreground">{skins}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Matchstatus (to-sidede formater) ----------------------------------------
|
||||
|
||||
function MatchStatus({ result, sides }: { result: ApiFormatResult; sides: ApiSide[] }) {
|
||||
if (!result.ready) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
|
||||
Venter på at begge sider er komplette og har beregnet handicap før matchstatus kan vises.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const statusText = result.match_status_text
|
||||
? substituteSideLabels(result.match_status_text, sides)
|
||||
: result.hlh_points_a !== null && result.hlh_points_b !== null
|
||||
? `${result.hlh_points_a}-${result.hlh_points_b}`
|
||||
: null
|
||||
const lead = result.match_lead ?? 0
|
||||
const holesPlayed = result.match_holes_played ?? 0
|
||||
const holesRemaining = result.match_holes_remaining ?? 0
|
||||
const totalHoles = holesPlayed + holesRemaining
|
||||
const tone: "a" | "b" | "neutral" = lead > 0 ? "a" : lead < 0 ? "b" : "neutral"
|
||||
const widthA = holesPlayed > 0 ? leadZoneFraction(lead, totalHoles) * 100 : 50
|
||||
const widthB = 100 - widthA
|
||||
const zoneAClass = tone === "a" ? "bg-primary" : tone === "b" ? "bg-primary/12" : "bg-muted"
|
||||
const zoneBClass = tone === "b" ? "bg-brand-orange" : tone === "a" ? "bg-brand-orange/12" : "bg-muted"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm font-bold text-muted-foreground">Matchstatus</span>
|
||||
{result.match_is_closed && <Badge variant="default">Avgjort</Badge>}
|
||||
{result.match_is_dormie && !result.match_is_closed && <Badge variant="outline">Dormie</Badge>}
|
||||
</div>
|
||||
<div
|
||||
className="grid h-20 w-full overflow-hidden rounded-2xl"
|
||||
style={{ gridTemplateColumns: `minmax(0, ${widthA}fr) auto minmax(0, ${widthB}fr)` }}
|
||||
>
|
||||
<div className={cn("h-full transition-[grid-template-columns] duration-300", zoneAClass)} />
|
||||
<div className="flex h-full items-center justify-center px-3">
|
||||
<div className="flex flex-col items-center rounded-xl border border-border/60 bg-card/95 px-3 py-1.5 shadow-sm shadow-black/10 backdrop-blur-sm">
|
||||
{statusText ? (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xl font-extrabold leading-none tabular-nums sm:text-2xl",
|
||||
tone === "a" ? "text-primary" : tone === "b" ? "text-brand-orange" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
<span className="mt-1 text-[11px] font-semibold text-muted-foreground">
|
||||
{holesPlayed} hull spilt{holesRemaining > 0 && `, ${holesRemaining} igjen`}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs font-bold text-muted-foreground text-pretty">Ingen hull spilt ennå</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn("h-full transition-[grid-template-columns] duration-300", zoneBClass)} />
|
||||
</div>
|
||||
{result.hole_results && result.hole_results.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-1">
|
||||
{result.hole_results.map((r, i) => (
|
||||
<li
|
||||
key={i}
|
||||
aria-label={
|
||||
r === "halved" ? `Hull ${i + 1}: delt` : `Hull ${i + 1}: ${r === "a" ? sides[0]?.label?.trim() || "Side A" : sides[1]?.label?.trim() || "Side B"} vant`
|
||||
}
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-full text-xs font-extrabold",
|
||||
r === "a" && "bg-primary text-primary-foreground",
|
||||
r === "b" && "bg-brand-orange text-brand-orange-foreground",
|
||||
r === "halved" && "border border-border bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{r === "halved" ? "½" : r.toUpperCase()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Screen ------------------------------------------------------------------
|
||||
|
||||
export function WatchRound({ roundId }: { roundId: string }) {
|
||||
const [round, setRound] = useState<ApiRound | null>(null)
|
||||
const [leaderboard, setLeaderboard] = useState<ApiLeaderboard | null>(null)
|
||||
const [formatResult, setFormatResult] = useState<ApiFormatResult | null>(null)
|
||||
const [flightGroup, setFlightGroup] = useState<{ flight_group_id: string | null; flights: ApiFlightSummary[] } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
// Innlogget bruker (kan være anonym -- se moduldoc). Kun brukt til
|
||||
// RoundMessages sin slett-knapp-synlighet; "" er trygt som fallback
|
||||
// siden ingen ekte author_user_id/eier-id noensinne er en tom streng.
|
||||
const [viewerId, setViewerId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch("/auth/me", { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data: { id: string } | null) => {
|
||||
if (!cancelled && data) setViewerId(data.id)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||
|
|
@ -296,14 +112,12 @@ export function WatchRound({ roundId }: { roundId: string }) {
|
|||
if (cancelled) return
|
||||
setRound(roundData)
|
||||
|
||||
if (roundData.play_format === "stroke" || roundData.play_format === "stableford" || roundData.play_format === "skins") {
|
||||
const lbRes = await fetch(`/public/rounds/${roundId}/leaderboard`, { credentials: "include" })
|
||||
if (lbRes.ok && !cancelled) setLeaderboard(await lbRes.json())
|
||||
}
|
||||
if (TWO_SIDED_FORMATS.has(roundData.play_format) || roundData.play_format === "skins") {
|
||||
const frRes = await fetch(`/public/rounds/${roundId}/format-result`, { credentials: "include" })
|
||||
if (frRes.ok && !cancelled) setFormatResult(await frRes.json())
|
||||
}
|
||||
const [lbRes, flightRes] = await Promise.all([
|
||||
fetch(`/public/rounds/${roundId}/leaderboard`, { credentials: "include" }),
|
||||
fetch(`/public/rounds/${roundId}/flight-group`, { credentials: "include" }),
|
||||
])
|
||||
if (lbRes.ok && !cancelled) setLeaderboard(await lbRes.json())
|
||||
if (flightRes.ok && !cancelled) setFlightGroup(await flightRes.json())
|
||||
}
|
||||
void load()
|
||||
return () => {
|
||||
|
|
@ -311,6 +125,14 @@ export function WatchRound({ roundId }: { roundId: string }) {
|
|||
}
|
||||
}, [roundId, refreshKey])
|
||||
|
||||
// Anker-scroll til kommentarseksjonen (ADR-047) -- se samme mønster/
|
||||
// begrunnelse i round-detail.tsx.
|
||||
useEffect(() => {
|
||||
if (!round) return
|
||||
if (window.location.hash !== "#kommentarer") return
|
||||
document.getElementById("kommentarer")?.scrollIntoView({ block: "start" })
|
||||
}, [round])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-5 text-center">
|
||||
|
|
@ -341,6 +163,7 @@ export function WatchRound({ roundId }: { roundId: string }) {
|
|||
Følger live
|
||||
</span>
|
||||
</div>
|
||||
<WatchTabs roundId={roundId} active="score" />
|
||||
</header>
|
||||
|
||||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
||||
|
|
@ -363,26 +186,28 @@ export function WatchRound({ roundId }: { roundId: string }) {
|
|||
<span className="text-sm text-muted-foreground">Spilt av {round.owner_display_name}</span>
|
||||
</div>
|
||||
|
||||
{TWO_SIDED_FORMATS.has(round.play_format) && formatResult && (
|
||||
<MatchStatus result={formatResult} sides={round.sides} />
|
||||
)}
|
||||
<WatchPlayers
|
||||
participants={round.participants}
|
||||
leaderboardEntries={leaderboard?.entries ?? []}
|
||||
flightGroup={flightGroup}
|
||||
/>
|
||||
|
||||
{round.play_format === "skins" && formatResult?.skins_won && (
|
||||
<SkinsBoard participants={round.participants} skinsWon={formatResult.skins_won} />
|
||||
)}
|
||||
|
||||
{(round.play_format === "stroke" || round.play_format === "stableford") && leaderboard && (
|
||||
<StrokeLeaderboard leaderboard={leaderboard} />
|
||||
)}
|
||||
|
||||
{!TWO_SIDED_FORMATS.has(round.play_format) &&
|
||||
round.play_format !== "skins" &&
|
||||
round.play_format !== "stroke" &&
|
||||
round.play_format !== "stableford" && (
|
||||
<p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
||||
Ingen live-visning tilgjengelig for dette formatet ennå.
|
||||
</p>
|
||||
)}
|
||||
{/* Kommentarer/bilder (ADR-044) -- BUG funnet 2026-08-06: fantes
|
||||
aldri her, kun på round-detail.tsx sin egen "Score"-fane. En
|
||||
person runden er delt med hadde dermed ingen vei til å se eller
|
||||
delta i samtalen. `GET/POST /rounds/{id}/messages` er allerede
|
||||
synlighets-gatet (_can_view_round, ikke eier/deltaker-only), så
|
||||
samme endepunkt gjenbrukes uendret -- ingen ny backend-kode. */}
|
||||
{/* Anker-mål for "Kommentarer"-lenken fra leaderboardets utvidede
|
||||
rader (round-leaderboard.tsx) -- samme seksjon, ikke en duplikat. */}
|
||||
<div id="kommentarer" className="scroll-mt-20">
|
||||
<RoundMessages
|
||||
roundId={roundId}
|
||||
currentUserId={viewerId ?? ""}
|
||||
roundOwnerUserId={round.owner_user_id}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
29
frontend/components/watch-scorecard.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client"
|
||||
|
||||
// Scorekort-fanen for tilskuer-visningen (ADR-044, 2026-08-06). Gjenbruker
|
||||
// round-scorecard.tsx sin RoundScorecard UENDRET (samme formatspesifikke
|
||||
// rutenett som eiersiden -- se komponentens `publicMode`-prop), kun matet
|
||||
// fra /public/rounds/* i stedet for /rounds/*. Egen sticky header + fane-
|
||||
// stripe her, siden RoundScorecard sin egen header slås av med
|
||||
// `embedded`.
|
||||
|
||||
import { Trophy } from "lucide-react"
|
||||
import { WatchTabs } from "@/components/watch-tabs"
|
||||
import { RoundScorecard } from "@/components/round-scorecard"
|
||||
|
||||
export function WatchScorecard({ roundId }: { roundId: string }) {
|
||||
return (
|
||||
<div className="min-h-dvh bg-background">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||
<span className="flex min-h-11 items-center gap-1.5 text-base font-bold text-foreground">
|
||||
<Trophy aria-hidden="true" className="size-5 text-primary" />
|
||||
Følger live
|
||||
</span>
|
||||
</div>
|
||||
<WatchTabs roundId={roundId} active="scorecard" />
|
||||
</header>
|
||||
<RoundScorecard roundId={roundId} embedded publicMode />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
frontend/components/watch-tabs.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client"
|
||||
|
||||
// Fane-stripe for tilskuer-visningen (/watch/[id], ADR-044, 2026-08-06).
|
||||
// Bevisst IKKE en gjenbruk av RoundHeader (eiersidens fane-mekanisme) --
|
||||
// RoundHeader render alltid et "Administrer"-tannhjul som åpner
|
||||
// ManageRoundDialog (Rediger/Fullfør/Slett-runde), uansett hvilke handlere
|
||||
// som gis inn. En tilskuer skal aldri kunne nå de handlingene i det hele
|
||||
// tatt -- denne komponenten inneholder rett og slett ingen slik kode, så
|
||||
// det finnes ingen vei til at de vises ved et uhell.
|
||||
|
||||
import Link from "next/link"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type WatchTab = "score" | "scorecard" | "leaderboard"
|
||||
|
||||
const TABS: { key: WatchTab; label: string }[] = [
|
||||
{ key: "score", label: "Score" },
|
||||
{ key: "scorecard", label: "Scorekort" },
|
||||
{ key: "leaderboard", label: "Leaderboard" },
|
||||
]
|
||||
|
||||
export function WatchTabs({ roundId, active }: { roundId: string; active: WatchTab }) {
|
||||
const hrefFor = (tab: WatchTab) =>
|
||||
tab === "score" ? `/watch/${roundId}` : `/watch/${roundId}/${tab}`
|
||||
|
||||
return (
|
||||
<nav aria-label="Rundevisning" className="mx-auto w-full max-w-xl px-4 pt-3">
|
||||
<div className="inline-flex w-full gap-1 rounded-full bg-muted p-1">
|
||||
{TABS.map((tab) => (
|
||||
<Link
|
||||
key={tab.key}
|
||||
href={hrefFor(tab.key)}
|
||||
aria-current={active === tab.key ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex min-h-11 flex-1 items-center justify-center rounded-full px-3 text-sm font-bold transition-all duration-200 ease-in-out",
|
||||
active === tab.key
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
16
frontend/lib/geo.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export type LatLng = { lat: number; lng: number }
|
||||
|
||||
/**
|
||||
* Storsirkel-avstand mellom to koordinater, i meter. Ren klient-side
|
||||
* beregning -- ingen Mapbox-kall (ADR-048 kostnadskontroll-invariant B).
|
||||
*/
|
||||
export function haversineMeters(a: LatLng, b: LatLng): number {
|
||||
const R = 6371000
|
||||
const toRad = (d: number) => (d * Math.PI) / 180
|
||||
const dLat = toRad(b.lat - a.lat)
|
||||
const dLng = toRad(b.lng - a.lng)
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(h))
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"mapbox-gl": "3.27.0",
|
||||
"next": "16.2.6",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 939 B After Width: | Height: | Size: 909 B |
|
Before Width: | Height: | Size: 939 B After Width: | Height: | Size: 909 B |
|
Before Width: | Height: | Size: 366 B After Width: | Height: | Size: 406 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 31 KiB |
394
frontend/public/teecup-wordmark.svg
Normal file
|
After Width: | Height: | Size: 406 KiB |