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:
parent
038516d236
commit
f55a51e9e2
19 changed files with 1746 additions and 77 deletions
|
|
@ -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
43
058_round_messages.sql
Normal 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.
|
||||||
|
|
@ -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
|
||||||
|
på `/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:
|
||||||
|
|
|
||||||
158
CHANGELOG.md
158
CHANGELOG.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
394
TeeCup logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 406 KiB |
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
269
app/routers/round_messages.py
Normal file
269
app/routers/round_messages.py
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
"""
|
||||||
|
Kommentarer/bilder på frittstående runder (ADR-044) -- "Banter Board" for
|
||||||
|
en enkelt runde (`round`/migrasjon 020), pluss en samlet `/feed`-side som
|
||||||
|
aggregerer disse på 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), så 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 på 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 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
|
||||||
|
)
|
||||||
|
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
|
||||||
|
]
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
5
frontend/app/my-feed/page.tsx
Normal file
5
frontend/app/my-feed/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { Feed } from "@/components/feed"
|
||||||
|
|
||||||
|
export default function FeedPage() {
|
||||||
|
return <Feed />
|
||||||
|
}
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
232
frontend/components/feed.tsx
Normal file
232
frontend/components/feed.tsx
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}) {
|
}) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
350
frontend/components/round-messages.tsx
Normal file
350
frontend/components/round-messages.tsx
Normal 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
|
||||||
|
|
@ -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
Loading…
Reference in a new issue