e reelle bugger (500 på /feeds paginering, en rute-/rewrite-kollisjon på /feed, og manglende WS-refetch-kobling for kommentarer) — se ADR-044/CHANGELOG for detaljer. Før jeg ruller dette ut mot ekte teecup_db, her er planen:

Kommandoer jeg vil kjøre mot ekte teecup_db/teecup_api/teecup_frontend:

psql migrasjon 058_round_messages.sql mot ekte teecup_db (ny round_message-tabell, ingen endring i eksisterende tabeller).
docker compose up -d --build teecup_api teecup_frontend (begge containere, siden dette er backend+frontend sammen).
Etterpå: bekreft round_message-tabellen finnes, /health//my-rounds/new//my-feed → 200 over https, teeoff.no upåvirket.
This commit is contained in:
Erol Haagenrud 2026-08-06 07:01:22 +02:00
parent 038516d236
commit f55a51e9e2
19 changed files with 1746 additions and 77 deletions

View file

@ -401,7 +401,10 @@
"Bash(python3 test_oom_backend.py)", "Bash(python3 test_oom_backend.py)",
"Bash(grep -n \"Dine organisasjoner\\\\|organizations/\\\\[id\\\\]\\\\|/organizations/\\\\${\" /opt/teecup/frontend/components/dashboard.tsx)", "Bash(grep -n \"Dine organisasjoner\\\\|organizations/\\\\[id\\\\]\\\\|/organizations/\\\\${\" /opt/teecup/frontend/components/dashboard.tsx)",
"Bash(python3 -c \"import fastapi\")", "Bash(python3 -c \"import fastapi\")",
"Bash(openssl rand *)" "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)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/opt/teeoff/deploy",

43
058_round_messages.sql Normal file
View file

@ -0,0 +1,43 @@
-- =====================================================================
-- TeeCup — Kommentarer/bilder på frittstående runder, migrasjon 058
-- (ADR-044).
--
-- Samme idé som org-turneringers "Banter Board" (message-tabellen,
-- migrasjon 013, ADR-025) -- forfatter-snapshot, valgfri tekst, valgfritt
-- bilde -- men EGEN tabell, ikke en utvidelse av `message`. `round` er
-- IKKE org-scopet og har INGEN RLS (ADR-033 Beslutning A), så den delte
-- message-tabellens organization_id NOT NULL + RLS-policy passer ikke
-- uendret her.
--
-- Autorisasjon (ADR-044 Beslutning A): ALLE som kan SE runden
-- (_can_view_round i app/routers/rounds.py -- eier, lenket medspiller,
-- eller en venn/offentlighet med visibility_mode-tilgang) kan også
-- POSTE. Samme "kan se = kan bidra"-modell som org-feeden, håndhevet i
-- app-laget (app/routers/round_messages.py), ikke her i skjemaet.
-- =====================================================================
\set ON_ERROR_STOP on
CREATE TABLE round_message (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
round_id uuid NOT NULL REFERENCES round(id) ON DELETE CASCADE,
author_user_id uuid NOT NULL REFERENCES app_user(id),
-- Frosset ved skrivetidspunkt, samme prinsipp som message.author_display_name
-- -- her FULLT NAVN (ikke fornavn alene, dette er en forfatter-liste,
-- ikke direkte adressering av mottakeren -- CLAUDE.md navneformat-regel).
author_display_name text NOT NULL,
body text,
image_key text,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (body IS NOT NULL OR image_key IS NOT NULL)
);
CREATE INDEX round_message_round_idx ON round_message (round_id, created_at);
CREATE INDEX round_message_author_idx ON round_message (author_user_id);
-- INGEN RLS -- samme plain_connection()-mønster som round/round_participant/
-- round_hole (ADR-033 Beslutning A). Autorisasjon håndheves eksplisitt i
-- app-laget via round_id-oppslag + _can_view_round, ikke database-policy.
GRANT SELECT, INSERT, DELETE ON round_message TO teecup_app;
-- Ingen UPDATE-grant -- meldinger redigeres ikke, kun slettes, samme som
-- den org-scopede message-tabellen.

View file

@ -4069,6 +4069,118 @@ seksjon). Full detalj i CHANGELOG.md 2026-08-04.
--- ---
## ADR-044: Kommentarer/bilder på frittstående runder + samlet feed
Reist av brukeren 2026-08-06: samme "Banter Board"-mulighet (bilde+tekst)
som org-turneringer allerede har (ADR-025), men for en enkelt, frittstående
runde — pluss en sentral feed-side som samler dette på tvers av runder.
Underveis avdekket research at rundevisibilitet (ADR-036 fase 2 —
`round.visibility_mode`/`round_visible_category`) allerede var bygget og
live siden 2026-07-28 (en eldre, aldri oppdatert CHANGELOG-seksjon sa
fortsatt "ikke bygget") — denne ADR-en gjenbruker den infrastrukturen
uendret, den handler kun om selve kommentarene og feed-aggregeringen.
**Beslutning A — Skriverett: kan-se-kan-poste.** Alle som kan SE en runde
(eier, lenket medspiller, `public`-synlig, eller riktig-kategorisert
`friends`-venn — samme `_can_view_round` som ADR-036) kan også POSTE et
bilde/kommentar til den. Samme "kan se = kan bidra"-modell som org-feeden,
IKKE begrenset til eier+medspillere. Avklart eksplisitt med bruker
(AskUserQuestion) — overstyrer et tidligere, snevrere "eier+medspillere"-
utkast notert i FEATURE_BACKLOG.md 2026-08-06.
**Beslutning B — Moderering: forfatter ELLER rundeeier.** Samme
"forfatter-eller-admin"-modell som org-feedens `delete_feed_message`
(ADR-025 Beslutning C) — rundens eier er her analogen til org-admin. Ulikt
lag-chattens strengere "kun forfatter, ingen unntak" (den er ekte privat,
en runde-kommentarseksjon åpen for tredjeparter trenger en reell
moderasjonsvei).
**Beslutning C — Egen `round_message`-tabell (migrasjon 058), ikke
gjenbruk av org sin `message`-tabell.** `round` har verken
`organization_id` eller RLS (ADR-033 Beslutning A) — en delt tabell ville
krevd enten en kunstig organisasjon rundt hver frittstående runde eller et
RLS-unntak. Samme feltform (forfatter-snapshot, valgfri tekst, valgfri
bilde-nøkkel) og samme MinIO+AVIF-pipeline (`app/storage.py`, ADR-018)
gjenbrukt uendret, egen `prefix="round_messages"`. Autorisasjon håndheves
utelukkende i app-laget (`app/routers/round_messages.py`,
`plain_connection()`), ikke database-policy — samme mønster som
`round`/`round_participant`.
**Beslutning D — Sanntid: ingen ny WebSocket-kanal.** Gjenbruker den
eksisterende `broadcast_round_update`/`/ws/rounds/{id}/live`-kringkastingen
(allerede trigget ved hver hull-endring) fremfor et nytt, payload-bærende
kanal-mønster (slik org-feeden bruker). `round-detail.tsx` sin eksisterende
WS-lytter fikk ett nytt tick-signal (`messagesRefreshTick`) som
`RoundMessages` reagerer på med en vanlig REST-refetch — ingen egen socket
for kommentarer. Bekreftet fungerende i ekte nettleser (se
CHANGELOG.md 2026-08-06) — MERK at dette KUN virker gjennom Caddy sin
dedikerte `handle /ws/* { reverse_proxy teecup_api:8000 }`-rute
(`/opt/teeoff/deploy/Caddyfile`), IKKE gjennom Next.js sitt eget
rewrite-lag (som ikke pålitelig proxyer WebSocket-oppgraderinger i
standalone-modus) — samme forutsetning som all annen runde-/turnering-
sanntid i appen.
**Beslutning E — Ny, dedikert `/my-feed`-side, IKKE en dashbord-seksjon.**
Avklart eksplisitt med bruker. Aggregerer på tvers av egne runder (uansett
`visibility_mode`) + venners synlige runder, modellert på
`list_friends_on_course` (`GET /friends/on-course`) sin reverserte retning
(viewer → venners synlige runder), men uten "spiller nå/siste 24t"-
filteret — en all-time, paginert (keyset, `?before=`) liste av
`round_message`-rader. Nås fra dashbordets "Venner på banen"-seksjon
(`SeeAllLink`) og en ny lenke i `/more`.
**KRITISK, funnet under full-stack-verifisering: rute-/rewrite-kollisjon,
samme klasse som `/rounds`→`/my-rounds` og `/friends`→`/my-friends`
(dokumentert i `frontend/next.config.mjs`).** Frontend-siden ble først lagt
`/feed` — nøyaktig samme streng som det flate API-endepunktet `GET
/feed`. Uten en eksplisitt rewrite-regel vant Next.js sin egen side-rute
over ethvert forsøk på server-side proxy, så `fetch("/feed")` fra klienten
fikk appens egen HTML tilbake i stedet for JSON (stille feilet
JSON-parsing). Rettet ved (1) å flytte frontend-siden til `/my-feed`
(samme navnemønster som de tre presedensene over) OG (2) legge til en
manglende, eksplisitt `{ source: "/feed", destination: ... }`-regel i
`next.config.mjs` (det flate mønsteret, uten `:path*`, siden `/feed` ikke
har understier) — retting nummer to var nødvendig uavhengig av
sideflyttingen, siden regelen rett og slett manglet helt fra start.
**Scratch-verifisert grundig** (isolert scratch-DB/rolle/MinIO/API-/
frontend-container, PLUSS en minimal scratch-Caddy satt opp spesifikt for
å faktisk bevise WS-refetchen — ikke bare anta at den ville virke i
produksjon): 30/30 håndregnede API-sjekker (hele autorisasjonsmatrisen —
eier poster/sletter egen, medspiller poster, en annen ikke-eier kan IKKE
slette andres, eier KAN moderere andres, fremmed uten innsyn avvist på
GET+POST, `friends`-synlighet riktig inkludert/ekskludert per kategori,
feed-aggregering korrekt per bruker, paginering uten hopp/duplikat, anonym
`/feed`-tilgang korrekt 401). Ekte bildeopplasting bekreftet konvertert til
AVIF og lagret riktig. Ekte nettleser: kommentar+bilde postet og vist,
sletting fungerte, `/my-feed` viste riktig aggregert sett (egne + andres
offentlige runder), WS-refetch bekreftet fungerende GJENNOM Caddy (en
kommentar postet i én "økt" dukket opp automatisk i en åpen fane uten
interaksjon). Ekte typesjekket produksjonsbuild av frontend kjørt og
bekreftet (fanget rute-kollisjonen over ved kjøretid, ikke ved bygging —
typesjekk alene fanger ikke denne klassen feil). `test_isolation.sql`
uendret 12/12. Scratch-miljøet (DB/rolle/MinIO/API-/frontend-/Caddy-
containere/images, V0-eksport-zip-er) ryddet opp fullstendig etter bruk.
**Frontend bygget via V0** (brukerens eksplisitte, stående arbeidsfordeling
— Claude skriver V0-prompten, bruker limer inn selv, Claude integrerer
eksporten): to prompter (kommentarseksjon + feed-side), begge levert med
eksakte data-kontrakter/design-tokens fra `DESIGN_SYSTEM.md` forhåndsutfylt
i prompten. Begge eksportene (zip 32/33) diffet ordrett mot hverandre og
mot ingenting-fra-før (nye filer) før integrering — ingen utilsiktet drift.
To små, bevisste integrasjonsjusteringer utover ren copy-inn: fjernet en
ubrukt `cn`-import, og lot `Feed`-komponenten selv hente `/auth/me` (samme
mønster som `round-detail.tsx`) i stedet for å kreve `currentUserId` som
ekstern prop, siden alle andre sider i appen er tynne wrappere som selv
henter egen brukeridentitet.
**Bevisst utenfor omfang:** egen payload-bærende WS-kanal for kommentarer,
redigering av innlegg (kun slett, samme som org-feeden), liker/reaksjoner,
varsler ved ny kommentar, sanntid på selve `/my-feed`-siden, inline
slett-knapp på `/my-feed` (kun fra runde-siden).
---
## Åpne spørsmål (ikke besluttet ennå) ## Åpne spørsmål (ikke besluttet ennå)
Disse må avklares før eller under de relevante fasene: Disse må avklares før eller under de relevante fasene:

View file

@ -7850,3 +7850,161 @@ Neste steg:
**Rullet ut live 2026-08-05** (kun `teecup_frontend` bygget/ **Rullet ut live 2026-08-05** (kun `teecup_frontend` bygget/
restartet -- ren frontend-endring, ingen skjema-/API-endring). restartet -- ren frontend-endring, ingen skjema-/API-endring).
`/my-rounds/new` → 200 over https etterpå. `/my-rounds/new` → 200 over https etterpå.
**Oppfølging samme kveld, 2026-08-06:** brukeren sendte skjermdump og
rapporterte at knappen FORTSATT dukket opp -- men i en ANNEN gren enn
den akkurat rettet: `CourseList` (offisielle baner, "Tjøme Golfklubb"
→ "Hovedbanen") viste den for enhver offisiell bane, uansett om målet
var å spille eller å opprette. Punktet over rettet kun `OwnSearch`
(egne baner) -- samme feilmønster fantes uavhengig i den offisielle
grenen, siden begge alltid hadde vist knappen unconditionalt bortsett
fra en `templateHoles.length > 0`-sjekk.
**Utvidet fiks:** erstattet den forrige, EGEN-bane-spesifikke
`"own-search-template"`-S1Sub-verdien med en generell boolsk
`templateMode`-state (løftet til toppnivå, ved siden av `s1Sub`).
`CourseList`s `onUseAsTemplate` gjort valgfri på samme måte som
`OwnSearch`s allerede var -- begge rendrer nå kun
`{onUseAsTemplate && ...}`, styrt av `templateMode`. Ny mellomskjerm
`"template-source"` (samme to `SourceCard`-valg som det aller første
Bane&tid-steget, men med egen tittel "Basér på en eksisterende bane")
lar brukeren velge OFFISIELL eller EGEN bane som malkilde -- nådd fra
samme "Basér på en eksisterende bane i stedet"-lenke i det blanke
opprett-skjemaet. `templateMode` settes eksplisitt `false` i det
aller første Bane&tid-steget (fresh start) og ved "Avbryt" fra
opprett-skjemaet, `true` kun fra `"template-source"`.
**Scratch-verifisert på nytt** (sjette scratch-miljø): gjenskapte
brukerens eksakte scenario (offisiell-bane-søk → "Tjøme Golfklubb" →
"Hovedbanen") mot ekte teeoff-data, bekreftet at "Bruk som mal for
egen bane" IKKE lenger vises der; bekreftet at "template-source"-
mellomskjermen vises korrekt fra opprett-skjemaets lenke; valgte
"Offisiell bane" derfra, søkte samme klubb, bekreftet at knappen NÅ
vises (med justert beskrivelsestekst: "Velg hvilken bane du vil
bruke som mal..."); trykket den, bekreftet at opprett-skjemaet
forhåndsfylte navn/18 ekte hull/4 ekte utslag korrekt fra Hovedbanen.
Ingen konsoll-feil. Scratch-miljøet ryddet opp fullstendig.
**Rullet ut live 2026-08-06** (kun `teecup_frontend`).
`/my-rounds/new` → 200 over https etterpå.
25. **Kommentarer/bilder på frittstående runder + samlet `/my-feed`-side
(ADR-044, migrasjon 058) — BYGGET OG SCRATCH-VERIFISERT 2026-08-06,
venter på utrulling mot ekte `teecup_db`.** Brukeren ba om samme
"Banter Board"-mulighet (bilde+tekst) som org-turneringer allerede har
(ADR-025), for en enkelt frittstående runde, pluss en sentral feed-side
som samler dette på tvers av runder. Underveis avdekket research at
rundevisibilitet (ADR-036 fase 2 -- `round.visibility_mode`/
`round_visible_category`) allerede var bygget og live siden 2026-07-28
-- en gammel "Neste steg"-seksjon i denne loggen sa fortsatt "ikke
bygget", aldri rettet opp. Denne runden gjenbrukte den infrastrukturen
uendret; omfanget ble dermed smalere enn først antatt.
**To beslutninger avklart eksplisitt med bruker (AskUserQuestion) FØR
planlegging:** (1) skriverett = alle som kan SE runden kan også POSTE
(ikke kun eier/medspillere -- overstyrer et snevrere utkast notert
tidligere samme dag i FEATURE_BACKLOG.md), (2) feed på en ny, dedikert
side (`/my-feed`), ikke en dashbord-seksjon. Fullstendig plan skrevet
og godkjent i Plan Mode før noe ble bygget (to Explore-runder + én
Plan-agent-syntese, grundig kodegrunnet mot eksisterende `_can_view_
round`/`list_friends_on_course`/`messaging.py`-mønstre).
**Datamodell:** ny, EGEN `round_message`-tabell (migrasjon 058), IKKE
en utvidelse av org sin `message`-tabell -- `round` har verken
`organization_id` eller RLS (ADR-033 Beslutning A). Samme feltform
(forfatter-snapshot, valgfri tekst, valgfri `image_key`) og samme
MinIO+AVIF-pipeline (`app/storage.py`) gjenbrukt uendret, egen
`prefix="round_messages"`. Scratch-verifisert alene først: skjema,
indekser, grants bekreftet, `test_isolation.sql` 12/12.
**Backend:** ny fil `app/routers/round_messages.py` (`rounds.py` er
allerede 5500+ linjer) -- `GET`/`POST /rounds/{id}/messages`, `DELETE
/rounds/{id}/messages/{message_id}` (forfatter ELLER rundeeier kan
slette, samme "forfatter-eller-admin"-modell som org-feedens
`delete_feed_message`), og en ny `GET /feed`-aggregering modellert på
`list_friends_on_course` sin reverserte retning (viewer → synlige
runder), justert til å inkludere egne runder uansett `visibility_mode`
og uten "spiller nå"-filteret (all-time, keyset-paginert). Gjenbruker
`_can_view_round`/`_get_viewable_round_or_404` fra `rounds.py` uendret
som BÅDE lese- og post-sjekk. Ingen ny WebSocket-kanal -- gjenbruker
eksisterende `broadcast_round_update`, `RoundMessages`-komponenten
henter på nytt via et nytt `messagesRefreshTick`-signal i
`round-detail.tsx` sin allerede eksisterende WS-lytter. Liten
refaktor i samme runde: `messaging.py` sin lokale `_read_optional_
image` flyttet til `app/storage.py` som delt `read_optional_image`,
brukt av begge routerne nå.
**En reell bug funnet OG rettet FØR noen verifisering:** `GET /feed`
sin `before`-parameter krasjet med 500 (`asyncpg.exceptions.DataError`)
-- asyncpg godtar ikke en ren tekststreng mot en `timestamptz`-cast i
SQL-en, krever et faktisk `datetime`-objekt. Rettet ved å la Pydantic
parse query-parameteren som `datetime` direkte i stedet for `str`.
**Håndregnet auth-testskript, 30/30 sjekker bestått:** eier poster/
sletter egen (private) runde; lenket medspiller (ikke eier) poster,
en ANNEN ikke-eier-medspiller kan IKKE slette den (403); eieren KAN
slette andres innlegg på egen runde (moderasjon); en fremmed uten
innsyn avvist på både GET og POST (403/404) for en privat runde; en
`friends`-synlig runde med RIKTIG venne-kategori gir GET+POST; med
FEIL/manglende kategori avvises selv GET; `GET /feed` bekreftet riktig
sett per bruker (eier ser alle egne uansett visibility, en venn ser
kun offentlige + riktig-kategoriserte `friends`-runder, en fremmed ser
kun offentlige), paginering (`before`-cursor) uten hopp/duplikat.
Ekte bildeopplasting testet direkte mot scratch-MinIO (utenfor HTTP,
`storage._client.stat_object`) -- bekreftet 488 byte AVIF,
`content_type=image/avif`.
**Frontend bygget via V0** (brukerens eksplisitte, stående
arbeidsfordeling -- se CLAUDE.md-notat): to prompter skrevet og
levert i chat (kommentarseksjon, feed-side), begge forankret i
`DESIGN_SYSTEM.md` sine fargetokens/tilgjengelighetsregler og eksakte
data-kontrakter fra det allerede bygde API-et. Zip 32 (`round-
messages.tsx`) og zip 33 (samme + ny `feed.tsx`) diffet ordrett mot
hverandre FØR integrering -- ingen utilsiktet drift utover den ene
nye filen. To små, bevisste integrasjonsjusteringer: fjernet en
ubrukt `cn`-import, og lot `Feed`-komponenten selv hente `/auth/me`
(samme mønster som `round-detail.tsx`) i stedet for å kreve
`currentUserId` som ekstern prop -- konsekvent med at alle andre sider
i appen er tynne wrappere som selv henter egen brukeridentitet.
`RoundMessages` montert som en ny seksjon NEDERST på `round-detail.
tsx` sin `pageTab === "score"` (ikke en ny rundefane, ikke inni
admin-"manage"-fanen -- se ADR-044 for begrunnelsen). Ny `/my-feed`-
side + inngangspunkter: `SeeAllLink` lagt til dashbordets "Venner på
banen"-seksjon, ny lenke i `/more`-menyen.
**KRITISK rute-/rewrite-kollisjon funnet under full-stack-
verifisering, samme klasse som `/rounds`→`/my-rounds` og
`/friends`→`/my-friends`:** frontend-siden ble først lagt på `/feed`
-- nøyaktig samme streng som det flate API-endepunktet. Uten en
eksplisitt rewrite-regel vant Next.js sin egen side-rute, så
`fetch("/feed")` fra klienten fikk appens egen HTML tilbake (stille
feilet JSON-parsing, "Kunne ikke laste feeden"-feilmelding i UI-et).
Rettet ved å (1) flytte siden til `/my-feed` OG (2) legge til en
manglende, eksplisitt `/feed`-rewrite-regel i `next.config.mjs` (som
rett og slett ikke fantes fra før -- retting nummer to var nødvendig
uavhengig av sideflyttingen). Ekte typesjekket produksjonsbuild fanget
IKKE denne feilen (kun kjøretid avslørte den) -- funnet ved faktisk
nettleser-testing, ikke ved bygging.
**En andre reell bug funnet under samme verifiseringsrunde:**
`RoundMessages` hentet meldinger KUN ved mount -- `round-detail.tsx`
sin eksisterende `/ws/rounds/{id}/live`-lytter (allerede der fra
tidligere, brukt av scorekort/format-resultat) var aldri koblet til
den nye kommentarseksjonen. Rettet med et nytt `messagesRefreshTick`-
signal, samme mønster som det eksisterende `formatResultRefreshTick`.
Verifisert med en EGEN, minimal scratch-Caddy (`handle /ws/* {
reverse_proxy ... }`, samme regel som den ekte `/opt/teeoff/deploy/
Caddyfile`) satt opp spesifikt for denne testen -- uten den ville
WebSocket-oppgraderingen aldri nådd API-et i det hele tatt (Next.js
sitt eget rewrite-lag proxyer ikke WS pålitelig i standalone-modus,
dokumentert allerede i den ekte Caddyfilen). Bekreftet: en kommentar
postet via et separat API-kall dukket opp automatisk i en åpen
nettleserfane, ingen interaksjon eller reload nødvendig.
**Scratch-miljøet ryddet opp fullstendig** etter bruk (DB/rolle/MinIO/
API-/frontend-/Caddy-containere og -images, V0-eksport-zip-ene fjernet
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.

View file

@ -1947,12 +1947,48 @@ forskjell mellom scramble og greensome i noen av domenene.
| Video | 💤 | Fortsatt utsatt, egen ADR om/når etterspurt. | | Video | 💤 | Fortsatt utsatt, egen ADR om/når etterspurt. |
| 1-til-1 direktemeldinger | 💤 | Fortsatt utsatt, ikke 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). | | 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. |
Se ARCHITECTURE_DECISIONS.md ADR-025 for alle fire hovedbeslutningene og Se ARCHITECTURE_DECISIONS.md ADR-025 for alle fire hovedbeslutningene og
CHANGELOG.md for full byggerunde (datamodell, autorisasjon, scratch- CHANGELOG.md for full byggerunde (datamodell, autorisasjon, scratch-
verifisering med 20 automatiserte sjekker inkl. reell WebSocket-sanntid, verifisering med 20 automatiserte sjekker inkl. reell WebSocket-sanntid,
og utrulling). 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`
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
også være en sentral feed-side?" — samme idé som `message`-tabellens
`tournament_feed`-scope (bilde + tekst), nå bygget for en vanlig
frittstående/personlig runde (`round`-tabellen, ADR-033), PLUSS en ny
`/my-feed`-side som aggregerer dette på tvers av runder.
**Ny, egen `round_message`-tabell** (ikke en utvidelse av `message`) --
`round` har verken `organization_id` eller RLS (ADR-033 Beslutning A), så
den delte org-tabellen passet ikke uendret. Se ADR-044 for full
begrunnelse og alle seks beslutningene (skriverett, moderering, datamodell,
sanntid, feed-plassering, rutenavngiving).
**Rettelse av en tidligere antagelse i denne seksjonen:** utkastet notert
2026-08-06 tidligere samme dag foreslo "runde-eier + faktiske
medspillere" som autorisasjonskrets — brukeren avklarte eksplisitt (via
AskUserQuestion) en BREDERE modell: alle som kan SE runden (inkl.
`public`/riktig-kategorisert `friends`-synlighet, ADR-036 fase 2, som
viste seg å allerede være bygget og live siden 2026-07-28) kan også
POSTE, samme "kan se = kan bidra"-modell som org-feeden.
**Verifisert:** 30/30 håndregnede API-sjekker (hele autorisasjonsmatrisen),
ekte bildeopplasting→AVIF bekreftet, ekte nettleser (kommentar+bilde
postet/vist/slettet, `/my-feed` viste riktig aggregert sett, WS-refetch
bekreftet fungerende GJENNOM en scratch-Caddy satt opp spesifikt for å
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
2026-08-06 for full byggelogg.
--- ---
## Landingssider (turnering + organisasjon) — ADR-018 ✅ HELT FERDIG 2026-07-18 ## Landingssider (turnering + organisasjon) — ADR-018 ✅ HELT FERDIG 2026-07-18

394
TeeCup logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 406 KiB

View file

@ -27,6 +27,7 @@ from .routers import (
organizations, organizations,
players, players,
registration, registration,
round_messages,
rounds, rounds,
scoring, scoring,
tournaments, tournaments,
@ -60,6 +61,7 @@ app.include_router(registration.org_router)
app.include_router(messaging.router) app.include_router(messaging.router)
app.include_router(messaging.public_router) app.include_router(messaging.public_router)
app.include_router(rounds.router) app.include_router(rounds.router)
app.include_router(round_messages.router)
app.include_router(friends.router) app.include_router(friends.router)
app.include_router(notifications.router) app.include_router(notifications.router)

View file

@ -81,17 +81,6 @@ async def _resolve_author_display_name(conn, organization_id: str, user_id: str)
return email.split("@")[0] if email else "Ukjent" return email.split("@")[0] if email else "Ukjent"
async def _read_optional_image(image: UploadFile | None) -> bytes | None:
if image is None:
return None
if image.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES:
raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")
raw = await image.read(storage.MAX_UPLOAD_BYTES + 1)
if len(raw) > storage.MAX_UPLOAD_BYTES:
raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).")
return raw
# --- Sanntid: in-memory tilkoblingsregister (se moduldocstring) ------------ # --- Sanntid: in-memory tilkoblingsregister (se moduldocstring) ------------
_team_sockets: dict[str, set[WebSocket]] = defaultdict(set) _team_sockets: dict[str, set[WebSocket]] = defaultdict(set)
@ -163,7 +152,7 @@ async def send_team_message(
) -> MessageOut: ) -> MessageOut:
if not body and image is None: if not body and image is None:
raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.") raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.")
raw = await _read_optional_image(image) raw = await storage.read_optional_image(image)
async with org_connection(organization_id) as conn, translate_db_errors(): async with org_connection(organization_id) as conn, translate_db_errors():
team = await conn.fetchrow( team = await conn.fetchrow(
@ -316,7 +305,7 @@ async def post_to_feed(
) -> MessageOut: ) -> MessageOut:
if not body and image is None: if not body and image is None:
raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.") raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.")
raw = await _read_optional_image(image) raw = await storage.read_optional_image(image)
organization_id = await resolve_org(tournament_id) organization_id = await resolve_org(tournament_id)
async with org_connection(organization_id) as conn, translate_db_errors(): async with org_connection(organization_id) as conn, translate_db_errors():

View file

@ -0,0 +1,269 @@
"""
Kommentarer/bilder frittstående runder (ADR-044) -- "Banter Board" for
en enkelt runde (`round`/migrasjon 020), pluss en samlet `/feed`-side som
aggregerer disse tvers av runder brukeren har innsyn i.
To bevisste forskjeller fra org-feeden (`messaging.py`, ADR-025):
- Egen `round_message`-tabell (migrasjon 058), IKKE en utvidelse av den
org-scopede `message`-tabellen -- `round` har verken `organization_id`
eller RLS (ADR-033 Beslutning A), all autorisasjon her skjer i
app-laget via `plain_connection()`, ikke database-policy.
- Skriverett = leserett: ALLE som kan SE runden (`_can_view_round`,
gjenbrukt uendret fra `rounds.py`, ADR-036 fase 2) kan også POSTE --
samme "kan se = kan bidra"-modell som org-feeden, men her er
synlighetssjekken allerede bygget og trenger ingen egen
medlemskaps-/deltakelses-sjekk i tillegg.
Sanntid: INGEN egen WebSocket-kanal. Gjenbruker `broadcast_round_update`
(app/realtime.py), som allerede kringkastes ved hver hull-endring og
allerede konsumeres av `/ws/rounds/{id}/live` / `/ws/public/rounds/{id}/
live` -- klienten reagerer med å hente meldingslisten nytt, samme
"noe endret seg"-mønster som resten av runde-sanntiden.
"""
from datetime import datetime
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
from pydantic import BaseModel
from .. import storage
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
router = APIRouter(tags=["round-messages"])
_ROUND_MESSAGE_COLUMNS = """
id::text, round_id::text, author_user_id::text, author_display_name, body, image_key, created_at
"""
class RoundMessageOut(BaseModel):
id: str
round_id: str
author_user_id: str
author_display_name: str
body: str | None
image_url: str | None
created_at: str
def _row_to_round_message(row) -> RoundMessageOut:
return RoundMessageOut(
id=row["id"],
round_id=row["round_id"],
author_user_id=row["author_user_id"],
author_display_name=row["author_display_name"],
body=row["body"],
image_url=storage.public_url(row["image_key"]) if row["image_key"] else None,
created_at=row["created_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 her (runder er ikke
org-scopet) -- `app_user.display_name` er NOT NULL og alltid til
stede, ingen ekstra fallback trengs."""
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"]
# ---------------------------------------------------------------------------
# Runde-nivå kommentarer/bilder
# ---------------------------------------------------------------------------
@router.get("/rounds/{round_id}/messages", response_model=list[RoundMessageOut])
async def list_round_messages(
round_id: str,
user: CurrentUser | None = Depends(get_current_user_optional),
) -> list[RoundMessageOut]:
async with plain_connection() as conn:
await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
rows = await conn.fetch(
f"SELECT {_ROUND_MESSAGE_COLUMNS} FROM round_message WHERE round_id = $1 ORDER BY created_at",
round_id,
)
return [_row_to_round_message(r) for r in rows]
@router.post("/rounds/{round_id}/messages", response_model=RoundMessageOut, status_code=201)
async def post_round_message(
round_id: str,
body: str | None = Form(default=None),
image: UploadFile | None = File(default=None),
user: CurrentUser = Depends(get_current_user),
) -> RoundMessageOut:
if not body and image is None:
raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.")
raw = await storage.read_optional_image(image)
async with plain_connection() as conn, translate_db_errors():
# Selve synlighetssjekken ER post-sjekken -- "kan se = kan bidra",
# ingen snevrere gate i tillegg (ADR-044 Beslutning A).
await _get_viewable_round_or_404(conn, round_id, user.user_id)
image_key = None
if raw is not None:
try:
image_key = await storage.upload_image("round_messages", round_id, raw)
except storage.InvalidImageError:
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
display_name = await _resolve_round_message_author_name(conn, user.user_id)
row = await conn.fetchrow(
f"""
INSERT INTO round_message (round_id, author_user_id, author_display_name, body, image_key)
VALUES ($1, $2, $3, $4, $5)
RETURNING {_ROUND_MESSAGE_COLUMNS}
""",
round_id,
user.user_id,
display_name,
body,
image_key,
)
message = _row_to_round_message(row)
await broadcast_round_update(round_id)
return message
@router.delete("/rounds/{round_id}/messages/{message_id}", status_code=204)
async def delete_round_message(
round_id: str,
message_id: str,
user: CurrentUser = Depends(get_current_user),
) -> None:
"""Forfatteren selv, ELLER rundens eier (moderering -- rundens analog
til org-admin), samme modell som org-feedens `delete_feed_message`."""
async with plain_connection() as conn:
row = await conn.fetchrow(
"""
SELECT rm.author_user_id::text AS author_user_id, r.owner_user_id::text AS owner_user_id
FROM round_message rm JOIN round r ON r.id = rm.round_id
WHERE rm.id = $1 AND rm.round_id = $2
""",
message_id,
round_id,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Innlegget finnes ikke.")
if user.user_id not in (row["author_user_id"], row["owner_user_id"]):
raise app_error(
403,
"NOT_MESSAGE_AUTHOR",
"Du kan kun slette dine egne innlegg (eller som rundeeier).",
)
await conn.execute("DELETE FROM round_message WHERE id = $1", message_id)
await broadcast_round_update(round_id)
# ---------------------------------------------------------------------------
# Samlet feed -- egne runder + venners synlige runder (ADR-036 fase 2)
# ---------------------------------------------------------------------------
class FeedEntryOut(BaseModel):
id: str
round_id: str
round_owner_user_id: str
round_owner_display_name: str
course_name: str
played_at: str
author_user_id: str
author_display_name: str
body: str | None
image_url: str | None
created_at: str
@router.get("/feed", response_model=list[FeedEntryOut])
async def get_feed(
before: datetime | None = Query(default=None),
limit: int = Query(default=20, ge=1, le=50),
user: CurrentUser = Depends(get_current_user),
) -> list[FeedEntryOut]:
# Reverst av `_can_view_round` (starter fra VIEWEREN, ikke fra
# rundeeieren) -- samme retning og samme 'friends'-predikat som
# `list_friends_on_course` (rounds.py), kopiert inn her i stedet for
# faktorisert ut, samme bevisste "grei duplisering på denne skalaen"
# begrunnelse som der (se rounds.py sin kommentar over
# `list_friends_on_course`). Justeringer mot den funksjonen: egne
# runder er ALLTID med (uansett visibility_mode), ingen "spiller
# nå/siste 24t"-filter -- dette er en all-time, paginert liste av
# round_message-rader, ikke en "hvem er live nå"-widget.
async with plain_connection() as conn:
rows = await conn.fetch(
"""
SELECT rm.id::text AS id, rm.round_id::text AS round_id,
r.owner_user_id::text AS round_owner_user_id,
COALESCE(owner.first_name || ' ' || owner.last_name, owner.display_name) AS round_owner_display_name,
r.course_name_snapshot AS course_name, r.played_at,
rm.author_user_id::text AS author_user_id, rm.author_display_name,
rm.body, rm.image_key, rm.created_at
FROM round_message rm
JOIN round r ON r.id = rm.round_id
JOIN app_user owner ON owner.id = r.owner_user_id
WHERE (
r.owner_user_id = $1
OR EXISTS (
SELECT 1 FROM round_participant rp
WHERE rp.round_id = r.id AND rp.user_id = $1
)
OR r.visibility_mode = 'public'
OR (
r.visibility_mode = 'friends'
AND EXISTS (
SELECT 1 FROM friendship f
WHERE f.status = 'accepted'
AND ((f.requester_user_id = $1 AND f.addressee_user_id = r.owner_user_id)
OR (f.requester_user_id = r.owner_user_id AND f.addressee_user_id = $1))
)
AND EXISTS (
SELECT 1 FROM friend_categorization fc
WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1
)
AND NOT EXISTS (
SELECT 1 FROM friend_categorization fc
WHERE fc.owner_user_id = r.owner_user_id AND fc.friend_user_id = $1
AND fc.category NOT IN (
SELECT category FROM round_visible_category WHERE round_id = r.id
)
)
)
)
AND ($2::timestamptz IS NULL OR rm.created_at < $2::timestamptz)
ORDER BY rm.created_at DESC
LIMIT $3
""",
user.user_id,
before,
limit,
)
return [
FeedEntryOut(
id=r["id"],
round_id=r["round_id"],
round_owner_user_id=r["round_owner_user_id"],
round_owner_display_name=r["round_owner_display_name"],
course_name=r["course_name"],
played_at=r["played_at"].isoformat(),
author_user_id=r["author_user_id"],
author_display_name=r["author_display_name"],
body=r["body"],
image_url=storage.public_url(r["image_key"]) if r["image_key"] else None,
created_at=r["created_at"].isoformat(),
)
for r in rows
]

View file

@ -20,10 +20,12 @@ import io
import time import time
import pillow_avif # noqa: F401 -- registrerer AVIF-plugin i Pillow ved import import pillow_avif # noqa: F401 -- registrerer AVIF-plugin i Pillow ved import
from fastapi import UploadFile
from minio import Minio from minio import Minio
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from .config import settings from .config import settings
from .errors import app_error
ALLOWED_INPUT_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} ALLOWED_INPUT_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
MAX_UPLOAD_BYTES = 8 * 1024 * 1024 # 8 MB rå input, før konvertering MAX_UPLOAD_BYTES = 8 * 1024 * 1024 # 8 MB rå input, før konvertering
@ -56,6 +58,21 @@ class InvalidImageError(Exception):
pass pass
async def read_optional_image(image: UploadFile | None) -> bytes | None:
"""Delt valideringssteg for et valgfritt multipart-bildefelt (brukt av
både org-feeden/lag-chatten, messaging.py, og round_messages.py) --
content-type-allowlist + størrelsesgrense, ingen faktisk dekoding her
(det gjør _convert_to_avif_sync, kalt separat av upload_image)."""
if image is None:
return None
if image.content_type not in ALLOWED_INPUT_CONTENT_TYPES:
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).")
return raw
def _ensure_bucket_sync() -> None: def _ensure_bucket_sync() -> None:
if not _client.bucket_exists(BUCKET): if not _client.bucket_exists(BUCKET):
_client.make_bucket(BUCKET) _client.make_bucket(BUCKET)

View file

@ -0,0 +1,5 @@
import { Feed } from "@/components/feed"
export default function FeedPage() {
return <Feed />
}

View file

@ -977,7 +977,7 @@ function friendOnCourseInitials(name: string): string {
function LiveFriends({ friends }: { friends: ApiFriendOnCourse[] }) { function LiveFriends({ friends }: { friends: ApiFriendOnCourse[] }) {
return ( return (
<section aria-label="Venner på banen"> <section aria-label="Venner på banen">
<SectionHeader title="Venner på banen" /> <SectionHeader title="Venner på banen" action={<SeeAllLink href="/my-feed" label="Se feed" />} />
{friends.length > 0 ? ( {friends.length > 0 ? (
<div className="-mx-4 flex snap-x gap-3 overflow-x-auto px-4 pb-1 sm:mx-0 sm:px-0"> <div className="-mx-4 flex snap-x gap-3 overflow-x-auto px-4 pb-1 sm:mx-0 sm:px-0">
{friends.map((friend, i) => { {friends.map((friend, i) => {

View file

@ -0,0 +1,232 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
/** Rå feed-oppføring fra backend. */
export type ApiFeedEntry = {
id: string
round_id: string
round_owner_user_id: string
round_owner_display_name: string
course_name: string
played_at: string // dato, "YYYY-MM-DD"
author_user_id: string
author_display_name: string
body: string | null
image_url: string | null
created_at: string // ISO 8601
}
const PAGE_SIZE = 20
const MAX_IMAGE_HEIGHT = 300
/**
* Formaterer "YYYY-MM-DD" til kort norsk dato, f.eks. "3. aug".
* Parses som lokal dato (ikke UTC) for å unngå at datoen hopper en dag.
*/
function formatPlayedAt(ymd: string): string {
const [y, m, d] = ymd.split("-").map(Number)
if (!y || !m || !d) return ymd
const date = new Date(y, m - 1, d)
return date.toLocaleDateString("nb-NO", { day: "numeric", month: "short" })
}
export function Feed() {
const [currentUserId, setCurrentUserId] = useState<string | null>(null)
const [entries, setEntries] = useState<ApiFeedEntry[]>([])
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading")
const [loadingMore, setLoadingMore] = useState(false)
const [moreError, setMoreError] = useState<string | null>(null)
// Om det finnes flere sider å hente (forrige respons var full side).
const [hasMore, setHasMore] = useState(false)
// Innlogget bruker (samme mønster som round-detail.tsx) -- kun brukt for
// å avgjøre "{navn} sin runde"-linjen per kort, ikke for selve
// autorisasjonen (den håndheves av /feed-endepunktet via sesjonscookien).
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
}
}, [])
// Første innlasting ved mount.
useEffect(() => {
const controller = new AbortController()
async function loadInitial() {
setStatus("loading")
try {
const res = await fetch(`/feed?limit=${PAGE_SIZE}`, {
credentials: "include",
signal: controller.signal,
})
if (!res.ok) throw new Error(`Uventet svar (${res.status})`)
const data: ApiFeedEntry[] = await res.json()
setEntries(data)
setHasMore(data.length >= PAGE_SIZE)
setStatus("ready")
} catch (err) {
if (controller.signal.aborted) return
console.log("[v0] Feil ved lasting av feed:", err)
setStatus("error")
}
}
loadInitial()
return () => controller.abort()
}, [])
const loadMore = useCallback(async () => {
if (loadingMore || entries.length === 0) return
const oldest = entries[entries.length - 1]?.created_at
if (!oldest) return
setLoadingMore(true)
setMoreError(null)
try {
const res = await fetch(
`/feed?limit=${PAGE_SIZE}&before=${encodeURIComponent(oldest)}`,
{ credentials: "include" },
)
if (!res.ok) throw new Error(`Uventet svar (${res.status})`)
const data: ApiFeedEntry[] = await res.json()
setEntries((prev) => [...prev, ...data])
setHasMore(data.length >= PAGE_SIZE)
} catch (err) {
console.log("[v0] Feil ved lasting av flere innlegg:", err)
setMoreError("Kunne ikke laste flere innlegg. Prøv igjen.")
} finally {
setLoadingMore(false)
}
}, [entries, loadingMore])
return (
<div className="min-h-dvh bg-background text-foreground">
{/* Sticky sideoverskrift */}
<header className="sticky top-0 z-10 border-b border-border bg-background/95 pt-[env(safe-area-inset-top)] backdrop-blur">
<div className="mx-auto w-full max-w-xl px-4 py-4">
<h1 className="text-2xl font-bold">Feed</h1>
</div>
</header>
<main className="mx-auto w-full max-w-xl px-4 pb-10">
{status === "loading" ? (
<div className="flex justify-center py-16" aria-live="polite" aria-busy="true">
<span
aria-hidden="true"
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
<span className="sr-only">Laster feed...</span>
</div>
) : status === "error" ? (
<p role="alert" className="py-16 text-center text-base font-semibold text-destructive text-pretty">
Kunne ikke laste feeden. Sjekk tilkoblingen og prøv igjen.
</p>
) : entries.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-16 text-center">
<p className="text-base font-semibold text-foreground">Ingen aktivitet ennå</p>
<p className="max-w-xs text-sm leading-relaxed text-muted-foreground text-pretty">
Kommentarer og bilder fra dine runder og venners runder dukker opp her.
</p>
</div>
) : (
<>
<ul className="divide-y divide-border">
{entries.map((entry) => (
<li key={entry.id}>
<FeedCard entry={entry} currentUserId={currentUserId} />
</li>
))}
</ul>
{hasMore && (
<div className="pt-4">
<button
type="button"
onClick={loadMore}
disabled={loadingMore}
className="flex min-h-11 w-full items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 text-base font-bold text-foreground transition-colors hover:border-primary/50 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-60"
>
{loadingMore ? (
<>
<span
aria-hidden="true"
className="size-4 animate-spin rounded-full border-2 border-primary/30 border-t-primary"
/>
Laster...
</>
) : (
"Last inn flere"
)}
</button>
</div>
)}
{moreError && (
<p role="alert" className="pt-3 text-center text-sm font-semibold text-destructive text-pretty">
{moreError}
</p>
)}
</>
)}
</main>
</div>
)
}
function FeedCard({ entry, currentUserId }: { entry: ApiFeedEntry; currentUserId: string | null }) {
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
</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"
/>
)}
{/* Tekst */}
{entry.body && (
<p className={cn("text-base leading-relaxed text-foreground text-pretty whitespace-pre-wrap")}>
{entry.body}
</p>
)}
</Link>
)
}
export default Feed

View file

@ -9,7 +9,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { Bell, Building2, ChevronRight, LogOut, UserCircle, UserPlus } from "lucide-react" import { Bell, Building2, ChevronRight, LogOut, MessageSquare, UserCircle, UserPlus } from "lucide-react"
import { BottomTabBar } from "@/components/dashboard" import { BottomTabBar } from "@/components/dashboard"
const C = { const C = {
@ -64,6 +64,7 @@ export function MoreMenu() {
> >
<MenuLink href="/account" icon={UserCircle} label="Konto" /> <MenuLink href="/account" icon={UserCircle} label="Konto" />
<MenuLink href="/my-friends" icon={UserPlus} label="Venner" /> <MenuLink href="/my-friends" icon={UserPlus} label="Venner" />
<MenuLink href="/my-feed" icon={MessageSquare} label="Feed" />
<MenuLink href="/my-notifications" icon={Bell} label="Varsler" last={me?.organizations.length === 0} /> <MenuLink href="/my-notifications" icon={Bell} label="Varsler" last={me?.organizations.length === 0} />
{me?.organizations.map((o, i) => ( {me?.organizations.map((o, i) => (
<MenuLink <MenuLink

View file

@ -96,7 +96,7 @@ type Player = {
type Side = { id: string; label: string } type Side = { id: string; label: string }
type S1Sub = "source" | "official-search" | "official-courses" | "own-search" | "own-search-template" | "own-create" | "fields" type S1Sub = "source" | "template-source" | "official-search" | "official-courses" | "own-search" | "own-create" | "fields"
// --- Formater ------------------------------------------------------------------ // --- Formater ------------------------------------------------------------------
@ -363,6 +363,14 @@ export function NewRound() {
// mal-søket ("Basér på en eksisterende bane" inne i opprett-skjemaet), // mal-søket ("Basér på en eksisterende bane" inne i opprett-skjemaet),
// eller broen fra offisielle baner. // eller broen fra offisielle baner.
const [ownCreateOrigin, setOwnCreateOrigin] = useState<S1Sub>("own-search") const [ownCreateOrigin, setOwnCreateOrigin] = useState<S1Sub>("own-search")
// Mal-modus (2026-08-05) -- true KUN når offisiell-/egen-bane-søket er
// nådd via "Basér på en eksisterende bane i stedet" (fra det blanke
// opprett-ny-bane-skjemaet). Styrer om "Bruk som mal for egen bane"
// vises i BÅDE CourseList (offisielle baner) og OwnSearch (egne baner)
// -- samme skjermer som brukes til vanlig banevalg, men knappen skal
// KUN vises når brukeren faktisk har sagt at målet er å opprette en ny
// bane, ikke når målet bare er å velge en bane å spille.
const [templateMode, setTemplateMode] = useState(false)
const [courseSource, setCourseSource] = useState<"official" | "own">("official") const [courseSource, setCourseSource] = useState<"official" | "own">("official")
const [selectedClub, setSelectedClub] = useState<OfficialClub | null>(null) const [selectedClub, setSelectedClub] = useState<OfficialClub | null>(null)
const [course, setCourse] = useState<Course | null>(null) const [course, setCourse] = useState<Course | null>(null)
@ -490,14 +498,14 @@ export function NewRound() {
if (stepN === 1) { if (stepN === 1) {
if (s1Sub === "source") { if (s1Sub === "source") {
router.push("/my-rounds") router.push("/my-rounds")
} else if (s1Sub === "template-source") {
setS1Sub("own-create")
} else if (s1Sub === "official-search") { } else if (s1Sub === "official-search") {
setS1Sub("source") setS1Sub(templateMode ? "template-source" : "source")
} else if (s1Sub === "official-courses") { } else if (s1Sub === "official-courses") {
setS1Sub("official-search") setS1Sub("official-search")
} else if (s1Sub === "own-search") { } else if (s1Sub === "own-search") {
setS1Sub("source") setS1Sub(templateMode ? "template-source" : "source")
} else if (s1Sub === "own-search-template") {
setS1Sub("own-create")
} else if (s1Sub === "own-create") { } else if (s1Sub === "own-create") {
setS1Sub(ownCreateOrigin) setS1Sub(ownCreateOrigin)
} else if (s1Sub === "fields") { } else if (s1Sub === "fields") {
@ -778,6 +786,8 @@ export function NewRound() {
s1Sub={s1Sub} s1Sub={s1Sub}
setS1Sub={setS1Sub} setS1Sub={setS1Sub}
setOwnCreateOrigin={setOwnCreateOrigin} setOwnCreateOrigin={setOwnCreateOrigin}
templateMode={templateMode}
setTemplateMode={setTemplateMode}
selectedClub={selectedClub} selectedClub={selectedClub}
setSelectedClub={setSelectedClub} setSelectedClub={setSelectedClub}
setCourseSource={setCourseSource} setCourseSource={setCourseSource}
@ -1091,6 +1101,8 @@ function Step1(props: {
s1Sub: S1Sub s1Sub: S1Sub
setS1Sub: (s: S1Sub) => void setS1Sub: (s: S1Sub) => void
setOwnCreateOrigin: (s: S1Sub) => void setOwnCreateOrigin: (s: S1Sub) => void
templateMode: boolean
setTemplateMode: (v: boolean) => void
selectedClub: OfficialClub | null selectedClub: OfficialClub | null
setSelectedClub: (c: OfficialClub | null) => void setSelectedClub: (c: OfficialClub | null) => void
setCourseSource: (s: "official" | "own") => void setCourseSource: (s: "official" | "own") => void
@ -1108,7 +1120,7 @@ function Step1(props: {
startHole: string startHole: string
setStartHole: (v: string) => void setStartHole: (v: string) => void
}) { }) {
const { s1Sub, setS1Sub, setOwnCreateOrigin, selectedClub, setSelectedClub, setCourseSource, course, chooseCourse, ownGender } = props const { s1Sub, setS1Sub, setOwnCreateOrigin, templateMode, setTemplateMode, selectedClub, setSelectedClub, setCourseSource, course, chooseCourse, ownGender } = props
const [resolvingClub, setResolvingClub] = useState(false) const [resolvingClub, setResolvingClub] = useState(false)
// "Bruk som mal"-broen (2026-08-04): forhåndsutfylling for own-create, // "Bruk som mal"-broen (2026-08-04): forhåndsutfylling for own-create,
// satt fra enten TeeOff-baneutvalget eller det offentlige bane-søket. // satt fra enten TeeOff-baneutvalget eller det offentlige bane-søket.
@ -1124,6 +1136,7 @@ function Step1(props: {
title="Offisiell bane" title="Offisiell bane"
description="Søk opp banen i det offisielle baneregisteret." description="Søk opp banen i det offisielle baneregisteret."
onClick={() => { onClick={() => {
setTemplateMode(false)
setCourseSource("official") setCourseSource("official")
setS1Sub("official-search") setS1Sub("official-search")
}} }}
@ -1133,6 +1146,43 @@ function Step1(props: {
title="Egen bane" title="Egen bane"
description="Søk opp eller opprett en personlig bane." description="Søk opp eller opprett en personlig bane."
onClick={() => { onClick={() => {
setTemplateMode(false)
setCourseSource("own")
setS1Sub("own-search")
}}
/>
</div>
</div>
)
}
// Mal-kilde-velger (2026-08-05) -- nådd KUN fra "Basér på en eksisterende
// bane i stedet" i det blanke opprett-ny-bane-skjemaet. Samme to kilder
// som det aller første valget, men her setter vi templateMode(true) FØR
// vi går videre inn i official-search/own-search, slik at "Bruk som mal
// for egen bane" faktisk vises i de skjermene -- den vanlige "source"-
// skjermen over lar `templateMode` stå urørt (alltid false derfra).
if (s1Sub === "template-source") {
return (
<div className="flex flex-col gap-8">
<StepHeader title="Basér på en eksisterende bane" description="Velg om malen skal komme fra en offisiell bane eller en av dine egne." />
<div className="grid gap-4 sm:grid-cols-2">
<SourceCard
icon={<Trophy aria-hidden="true" className="size-7 text-muted-foreground" />}
title="Offisiell bane"
description="Søk opp banen i det offisielle baneregisteret."
onClick={() => {
setTemplateMode(true)
setCourseSource("official")
setS1Sub("official-search")
}}
/>
<SourceCard
icon={<Pencil aria-hidden="true" className="size-7 text-muted-foreground" />}
title="Egen bane"
description="Søk blant dine egne baner."
onClick={() => {
setTemplateMode(true)
setCourseSource("own") setCourseSource("own")
setS1Sub("own-search") setS1Sub("own-search")
}} }}
@ -1176,29 +1226,37 @@ function Step1(props: {
) )
} }
// "Bruk som mal for egen bane" vises KUN i templateMode (2026-08-05) --
// nådd via "template-source" (fra "Basér på en eksisterende bane i
// stedet" i det blanke opprett-ny-bane-skjemaet). Uten templateMode er
// dette bare den vanlige "velg en offisiell bane å spille"-listen, der
// en mal-forgrening ville vært forvirrende (brukeren rapporterte
// nettopp dette: knappen dukket opp når målet bare var å velge
// Hovedbanen på Tjøme GK for å spille en vanlig runde).
if (s1Sub === "official-courses" && selectedClub) { if (s1Sub === "official-courses" && selectedClub) {
return ( return (
<CourseList <CourseList
title={selectedClub.name} title={selectedClub.name}
description="Velg hvilken bane du skal spille." description={templateMode ? "Velg hvilken bane du vil bruke som mal for den nye banen din." : "Velg hvilken bane du skal spille."}
courses={selectedClub.courses} courses={selectedClub.courses}
onSelect={(c) => chooseCourse(c, { source: "teeoff", facilitySlug: selectedClub.id, teeoffCourseId: Number(c.id) }, "official")} onSelect={(c) => chooseCourse(c, { source: "teeoff", facilitySlug: selectedClub.id, teeoffCourseId: Number(c.id) }, "official")}
onUseAsTemplate={(c) => { onUseAsTemplate={
setTemplatePrefill({ values: { name: c.name, holes: c.templateHoles ?? [], tees: c.templateTees ?? [] } }) templateMode
setCourseSource("own") ? (c) => {
setOwnCreateOrigin("official-courses") setTemplatePrefill({ values: { name: c.name, holes: c.templateHoles ?? [], tees: c.templateTees ?? [] } })
setS1Sub("own-create") setCourseSource("own")
}} setOwnCreateOrigin("template-source")
setS1Sub("own-create")
}
: undefined
}
/> />
) )
} }
// Vanlig "finn en bane å spille"-søk (2026-08-05) -- IKKE "Bruk som mal // Samme resonnement for egne baner: "Bruk som mal for egen bane" vises
// for egen bane" her lenger. Den knappen hører hjemme der brukeren // KUN i templateMode, ikke i det vanlige "finn en bane å spille"-søket
// FAKTISK har sagt at målet er å OPPRETTE en ny bane (se // (samme skjerm brukes til begge -- kun malknappen varierer).
// "own-search-template" under, nådd fra "Basér på en eksisterende
// bane"-lenken inne i det blanke opprett-skjemaet) -- ikke i det
// vanlige søket der målet som oftest bare er å velge en bane å spille.
if (s1Sub === "own-search") { if (s1Sub === "own-search") {
return ( return (
<OwnSearch <OwnSearch
@ -1212,6 +1270,21 @@ function Step1(props: {
"own", "own",
) )
}} }}
onUseAsTemplate={
templateMode
? async (picked) => {
const res = await fetch(`/personal-courses/${picked.id}`, { credentials: "include" })
if (!res.ok) return
const detail: ApiPersonalCourseDetail = await res.json()
setTemplatePrefill({
values: { name: detail.name, holes: detail.holes, tees: detail.full_tees },
forkedFromId: detail.id,
})
setOwnCreateOrigin("template-source")
setS1Sub("own-create")
}
: undefined
}
onCreate={() => { onCreate={() => {
setTemplatePrefill(null) setTemplatePrefill(null)
setOwnCreateOrigin("own-search") setOwnCreateOrigin("own-search")
@ -1221,52 +1294,16 @@ function Step1(props: {
) )
} }
// Mal-søk (2026-08-05) -- samme søkeskjerm som over, men nådd KUN fra
// "Basér på en eksisterende bane" inne i opprett-ny-bane-skjemaet, så
// "Bruk som mal for egen bane" er meningsfull her (brukeren har allerede
// sagt at målet er å opprette en ny bane).
if (s1Sub === "own-search-template") {
return (
<OwnSearch
onSelect={async (picked) => {
const res = await fetch(`/personal-courses/${picked.id}`, { credentials: "include" })
if (!res.ok) return
const detail: ApiPersonalCourseDetail = await res.json()
chooseCourse(
{ id: detail.id, name: detail.name, tees: apiTeesToTees(detail.tees) },
{ source: "custom", personalCourseId: detail.id },
"own",
)
}}
onUseAsTemplate={async (picked) => {
const res = await fetch(`/personal-courses/${picked.id}`, { credentials: "include" })
if (!res.ok) return
const detail: ApiPersonalCourseDetail = await res.json()
setTemplatePrefill({
values: { name: detail.name, holes: detail.holes, tees: detail.full_tees },
forkedFromId: detail.id,
})
setOwnCreateOrigin("own-search-template")
setS1Sub("own-create")
}}
onCreate={() => {
setTemplatePrefill(null)
setOwnCreateOrigin("own-search-template")
setS1Sub("own-create")
}}
/>
)
}
if (s1Sub === "own-create") { if (s1Sub === "own-create") {
return ( return (
<OwnCreateStep <OwnCreateStep
initial={templatePrefill?.values} initial={templatePrefill?.values}
forkedFromId={templatePrefill?.forkedFromId} forkedFromId={templatePrefill?.forkedFromId}
onCreated={(created) => chooseCourse(created, { source: "custom", personalCourseId: created.id }, "own")} onCreated={(created) => chooseCourse(created, { source: "custom", personalCourseId: created.id }, "own")}
onSearchTemplate={() => setS1Sub("own-search-template")} onSearchTemplate={() => setS1Sub("template-source")}
onCancel={() => { onCancel={() => {
setTemplatePrefill(null) setTemplatePrefill(null)
setTemplateMode(false)
setS1Sub("own-search") setS1Sub("own-search")
}} }}
/> />
@ -1493,9 +1530,10 @@ function OwnSearch({
onCreate, onCreate,
}: { }: {
onSelect: (c: ApiPersonalCourse) => void onSelect: (c: ApiPersonalCourse) => void
// Kun satt når søket er nådd med hensikten å OPPRETTE en ny bane (se // Kun satt når `templateMode` er true i Step1 (nådd via "template-source"
// "own-search-template" i Step1) -- undefined i det vanlige "finn en // fra "Basér på en eksisterende bane i stedet") -- undefined i det
// bane å spille"-søket, som skjuler knappen under (2026-08-05). // vanlige "finn en bane å spille"-søket, som skjuler knappen under
// (2026-08-05).
onUseAsTemplate?: (c: ApiPersonalCourse) => void onUseAsTemplate?: (c: ApiPersonalCourse) => void
onCreate: () => void onCreate: () => void
}) { }) {

View file

@ -13,6 +13,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { RoundPageShell } from "@/components/round-page-shell" import { RoundPageShell } from "@/components/round-page-shell"
import { RoundMessages } from "@/components/round-messages"
import { import {
ArrowDown, ArrowDown,
ArrowLeft, ArrowLeft,
@ -449,6 +450,9 @@ export function RoundDetail({ roundId }: { roundId: string }) {
// tavlen skjer (hull-registrering, websocket-signal), slik at // tavlen skjer (hull-registrering, websocket-signal), slik at
// formatResult-hentingen under vet den skal hente på nytt. // formatResult-hentingen under vet den skal hente på nytt.
const [formatResultRefreshTick, setFormatResultRefreshTick] = useState(0) const [formatResultRefreshTick, setFormatResultRefreshTick] = useState(0)
// Samme "noe endret seg"-signal, brukt av RoundMessages (ADR-044) til å
// hente kommentarer/bilder på nytt ved WS-signal, uten en egen socket.
const [messagesRefreshTick, setMessagesRefreshTick] = useState(0)
// Løftet OPP hit fra FormatResultPanel (2026-07-29) -- ScorecardGrid/ // Løftet OPP hit fra FormatResultPanel (2026-07-29) -- ScorecardGrid/
// SideScorecardGrid trenger NÅ den samme per-hull-oppløsningen (hvem vant // SideScorecardGrid trenger NÅ den samme per-hull-oppløsningen (hvem vant
// hvilket hull) for å farge scorecellene og vise en løpende Stilling-rad, // hvilket hull) for å farge scorecellene og vise en løpende Stilling-rad,
@ -810,6 +814,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
void loadSideHoles(id) void loadSideHoles(id)
} }
setFormatResultRefreshTick((n) => n + 1) setFormatResultRefreshTick((n) => n + 1)
setMessagesRefreshTick((n) => n + 1)
} }
return () => socket.close() return () => socket.close()
}, [roundId, loadRound, loadHoles, loadSideHoles]) }, [roundId, loadRound, loadHoles, loadSideHoles])
@ -1585,6 +1590,17 @@ export function RoundDetail({ roundId }: { roundId: string }) {
)} )}
</> </>
)} )}
{round && viewerId && (
<div className="mt-6">
<RoundMessages
roundId={roundId}
currentUserId={viewerId}
roundOwnerUserId={round.participants.find((p) => p.is_owner)?.user_id ?? ""}
refreshKey={messagesRefreshTick}
/>
</div>
)}
</> </>
)} )}
</main> </main>

View file

@ -0,0 +1,350 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { ImagePlus, Send, Trash2, X } from "lucide-react"
/** Rå meldingsform fra backend. */
export type ApiRoundMessage = {
id: string
round_id: string
author_user_id: string
author_display_name: string
body: string | null
image_url: string | null
created_at: string // ISO 8601
}
export type RoundMessagesProps = {
roundId: string
currentUserId: string
roundOwnerUserId: string
// Økes av round-detail.tsx sitt "/ws/rounds/{id}/live"-signal (samme
// "noe endret seg, hent på nytt"-mønster som resten av rundens
// sanntid) -- trigger en ny henting av meldingslisten uten å måtte
// koble en egen WebSocket til akkurat denne komponenten.
refreshKey?: number
}
const ACCEPTED_IMAGE_TYPES = "image/jpeg,image/png,image/webp,image/gif"
const MAX_IMAGE_HEIGHT = 300
/** Kort, norsk relativt tidsstempel (f.eks. "nå", "5 min", "2 t", "i går"). */
function formatRelativeTime(iso: string): { label: string; title: string } {
const then = new Date(iso)
const title = then.toLocaleString("nb-NO", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
})
const diffMs = Date.now() - then.getTime()
const diffSec = Math.round(diffMs / 1000)
const diffMin = Math.round(diffSec / 60)
const diffHour = Math.round(diffMin / 60)
const diffDay = Math.round(diffHour / 24)
let label: string
if (diffSec < 45) label = "nå nettopp"
else if (diffMin < 60) label = `for ${diffMin} min siden`
else if (diffHour < 24) label = `for ${diffHour} t siden`
else if (diffDay === 1) label = "i går"
else if (diffDay < 7) label = `for ${diffDay} dager siden`
else
label = then.toLocaleDateString("nb-NO", {
day: "numeric",
month: "short",
})
return { label, title }
}
export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refreshKey }: RoundMessagesProps) {
const [messages, setMessages] = useState<ApiRoundMessage[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [body, setBody] = useState("")
const [imageFile, setImageFile] = useState<File | null>(null)
const [imagePreview, setImagePreview] = useState<string | null>(null)
const [posting, setPosting] = useState(false)
const [postError, setPostError] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
// Hent meldinger ved mount, og på nytt hver gang refreshKey endres
// (WS-signal fra round-detail.tsx).
useEffect(() => {
const controller = new AbortController()
async function load() {
setLoading(true)
setLoadError(null)
try {
const res = await fetch(`/rounds/${roundId}/messages`, {
credentials: "include",
signal: controller.signal,
})
if (!res.ok) throw new Error(`Status ${res.status}`)
const data: ApiRoundMessage[] = await res.json()
setMessages(data)
} catch (err) {
if ((err as Error).name === "AbortError") return
setLoadError("Kunne ikke laste kommentarene. Prøv å laste siden på nytt.")
} finally {
setLoading(false)
}
}
load()
return () => controller.abort()
}, [roundId, refreshKey])
// Rydd opp objekt-URL-en for bildeforhåndsvisning.
useEffect(() => {
return () => {
if (imagePreview) URL.revokeObjectURL(imagePreview)
}
}, [imagePreview])
const clearSelectedImage = useCallback(() => {
setImageFile(null)
setImagePreview((prev) => {
if (prev) URL.revokeObjectURL(prev)
return null
})
if (fileInputRef.current) fileInputRef.current.value = ""
}, [])
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setPostError(null)
setImageFile(file)
setImagePreview((prev) => {
if (prev) URL.revokeObjectURL(prev)
return URL.createObjectURL(file)
})
}
const trimmedBody = body.trim()
const canPost = (trimmedBody.length > 0 || imageFile != null) && !posting
async function handlePost(e: React.FormEvent) {
e.preventDefault()
if (!canPost) return
setPosting(true)
setPostError(null)
try {
const formData = new FormData()
if (trimmedBody.length > 0) formData.append("body", trimmedBody)
if (imageFile) formData.append("image", imageFile)
const res = await fetch(`/rounds/${roundId}/messages`, {
method: "POST",
credentials: "include",
body: formData,
})
if (res.status !== 201) throw new Error(`Status ${res.status}`)
const created: ApiRoundMessage = await res.json()
// Legg nederst — listen er kronologisk, eldst til nyest.
setMessages((prev) => [...prev, created])
setBody("")
clearSelectedImage()
} catch {
setPostError("Kunne ikke poste kommentaren. Prøv igjen.")
} finally {
setPosting(false)
}
}
async function handleDelete(id: string) {
setDeletingId(id)
setPostError(null)
try {
const res = await fetch(`/rounds/${roundId}/messages/${id}`, {
method: "DELETE",
credentials: "include",
})
if (res.status !== 204) throw new Error(`Status ${res.status}`)
setMessages((prev) => prev.filter((m) => m.id !== id))
} catch {
setPostError("Kunne ikke slette kommentaren. Prøv igjen.")
} finally {
setDeletingId(null)
}
}
return (
<section className="flex flex-col gap-4" aria-label="Kommentarer og bilder fra runden">
{/* Composer */}
<form
onSubmit={handlePost}
className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5"
>
<label htmlFor="round-message-body" className="sr-only">
Skriv en kommentar
</label>
<textarea
id="round-message-body"
value={body}
onChange={(e) => {
setBody(e.target.value)
if (postError) setPostError(null)
}}
placeholder="Skriv en kommentar..."
rows={3}
className="w-full resize-y rounded-xl border border-border bg-background px-3 py-2.5 text-base text-foreground outline-none transition-all duration-200 ease-in-out placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
{/* Valgt bilde forhåndsvisning */}
{imagePreview && (
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted p-2.5">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imagePreview || "/placeholder.svg"}
alt="Forhåndsvisning av valgt bilde"
className="size-14 shrink-0 rounded-lg object-cover"
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{imageFile?.name}
</span>
<button
type="button"
onClick={clearSelectedImage}
className="inline-flex h-11 min-w-11 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium text-muted-foreground outline-none transition-all duration-200 ease-in-out hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]"
>
<X aria-hidden="true" className="size-4" />
Fjern
</button>
</div>
)}
<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>
<button
type="submit"
disabled={!canPost}
className="inline-flex h-11 min-w-11 items-center justify-center gap-2 rounded-lg bg-primary px-5 text-base font-medium text-primary-foreground outline-none transition-all duration-200 ease-in-out hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50"
>
{posting ? (
<span
aria-hidden="true"
className="size-4 animate-spin rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground"
/>
) : (
<Send aria-hidden="true" className="size-5" />
)}
{posting ? "Poster..." : "Post"}
</button>
</div>
{postError && (
<p role="alert" className="text-sm font-medium text-destructive">
{postError}
</p>
)}
</form>
{/* Meldingsliste */}
{loading ? (
<div className="flex justify-center py-12" aria-live="polite" aria-busy="true">
<span
aria-hidden="true"
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
<span className="sr-only">Laster kommentarer...</span>
</div>
) : loadError ? (
<p role="alert" className="rounded-2xl border border-border bg-card p-5 text-base text-destructive">
{loadError}
</p>
) : messages.length === 0 ? (
<p className="rounded-2xl border border-border bg-card p-5 text-base text-muted-foreground">
Ingen kommentarer ennå vær den første til å dele et bilde eller en kommentar fra runden.
</p>
) : (
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
{messages.map((m) => {
const canDelete = m.author_user_id === currentUserId || roundOwnerUserId === currentUserId
const isDeleting = deletingId === m.id
const { label, title } = formatRelativeTime(m.created_at)
return (
<li key={m.id} className="flex flex-col gap-3 p-4 sm:p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="truncate text-base font-semibold text-foreground">
{m.author_display_name}
</span>
<time
dateTime={m.created_at}
title={title}
className="text-sm font-medium text-muted-foreground"
>
{label}
</time>
</div>
{canDelete && (
<button
type="button"
onClick={() => handleDelete(m.id)}
disabled={isDeleting}
className="inline-flex h-11 min-w-11 shrink-0 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium text-destructive outline-none transition-all duration-200 ease-in-out hover:bg-destructive/10 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50"
>
{isDeleting ? (
<span
aria-hidden="true"
className="size-4 animate-spin rounded-full border-2 border-destructive/30 border-t-destructive"
/>
) : (
<Trash2 aria-hidden="true" className="size-4" />
)}
Slett
</button>
)}
</div>
{m.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={m.image_url || "/placeholder.svg"}
alt={`Bilde delt av ${m.author_display_name}`}
style={{ maxHeight: MAX_IMAGE_HEIGHT }}
className="w-full rounded-xl object-cover"
/>
)}
{m.body && (
<p className="text-base leading-relaxed text-foreground whitespace-pre-wrap">
{m.body}
</p>
)}
</li>
)
})}
</ul>
)}
</section>
)
}
export default RoundMessages

View file

@ -55,6 +55,10 @@ const nextConfig = {
// frontend-side bruker dette prefikset, kun fetch() fra /account. // frontend-side bruker dette prefikset, kun fetch() fra /account.
{ source: "/push/:path*", destination: `${API_ORIGIN}/push/:path*` }, { source: "/push/:path*", destination: `${API_ORIGIN}/push/:path*` },
{ source: "/push", destination: `${API_ORIGIN}/push` }, { source: "/push", destination: `${API_ORIGIN}/push` },
// Kommentarer/bilder på runder + samlet feed (ADR-044, 2026-08-06).
// Samme kollisjonsklasse som over: frontend-siden heter /my-feed,
// ALDRI /feed -- API-endepunktet /feed er flatt (ingen understier).
{ source: "/feed", destination: `${API_ORIGIN}/feed` },
{ source: "/health", destination: `${API_ORIGIN}/health` }, { source: "/health", destination: `${API_ORIGIN}/health` },
] ]
}, },

File diff suppressed because one or more lines are too long