214 lines
8.4 KiB
TypeScript
214 lines
8.4 KiB
TypeScript
"use client"
|
|
|
|
// Tredjeparts/offentlig live-visning av en frittstående runde (ADR-036
|
|
// fase 2, 2026-07-28). Ferskbygget, IKKE en gjenbruk av round-detail.tsx
|
|
// (som antar eier-/deltaker-tilgang og full skrive-UI) -- denne siden er
|
|
// rendyrket lesevisning mot de nye /public/rounds/*-endepunktene, som
|
|
// fungerer like fint anonymt som innlogget (get_current_user_optional).
|
|
//
|
|
// Score-fanen (denne filen): kompakt status + "Spillere og runde"
|
|
// (deltakerliste, HCP/utslag/tildelte slag, "så langt", flight-
|
|
// gruppering) -- se watch-players.tsx. Selve matchstatus/skins-tavle/
|
|
// leaderboard flyttet til /watch/[id]/leaderboard (ADR-044, 2026-08-06),
|
|
// som gjenbruker den ALLEREDE eksisterende round-leaderboard.tsx uendret
|
|
// (kun `publicMode`-bryteren), for full formatparitet med eiersiden.
|
|
|
|
import { useEffect, useState } from "react"
|
|
import Link from "next/link"
|
|
import { Radio, Trophy } from "lucide-react"
|
|
import { WatchTabs } from "@/components/watch-tabs"
|
|
import { WatchPlayers, type ApiPublicParticipant, type ApiLeaderboardEntry, type ApiFlightSummary } from "@/components/watch-players"
|
|
import { RoundMessages } from "@/components/round-messages"
|
|
|
|
type ApiSide = { id: string; label: string | null }
|
|
|
|
type ApiRound = {
|
|
id: string
|
|
name: string | null
|
|
course_name_snapshot: string
|
|
tee_name_snapshot: string
|
|
played_at: string
|
|
play_format: string
|
|
completed_at: string | null
|
|
owner_display_name: string
|
|
owner_user_id: string
|
|
participants: ApiPublicParticipant[]
|
|
sides: ApiSide[]
|
|
}
|
|
|
|
type ApiLeaderboard = { holes_planned: number; completed: boolean; entries: ApiLeaderboardEntry[] }
|
|
|
|
const FORMAT_LABELS: Record<string, string> = {
|
|
stroke: "Slagspill",
|
|
stableford: "Stableford",
|
|
match: "Match",
|
|
skins: "Skins",
|
|
fourball: "Fourball",
|
|
foursome: "Foursome",
|
|
greensome: "Greensome",
|
|
scramble_2: "Scramble (2)",
|
|
scramble_4: "Scramble (4)",
|
|
chapman: "Chapman",
|
|
copenhagen: "Københavner",
|
|
bbb: "Bingo Bango Bongo",
|
|
flag: "Flaggturnering",
|
|
shamble: "Shamble",
|
|
money_ball: "Money Ball",
|
|
high_low_high: "High-low-high",
|
|
scramble_solo: "Scramble mot enkeltspiller",
|
|
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
|
|
}
|
|
|
|
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
|
|
|
export function WatchRound({ roundId }: { roundId: string }) {
|
|
const [round, setRound] = useState<ApiRound | null>(null)
|
|
const [leaderboard, setLeaderboard] = useState<ApiLeaderboard | null>(null)
|
|
const [flightGroup, setFlightGroup] = useState<{ flight_group_id: string | null; flights: ApiFlightSummary[] } | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [refreshKey, setRefreshKey] = useState(0)
|
|
// Innlogget bruker (kan være anonym -- se moduldoc). Kun brukt til
|
|
// RoundMessages sin slett-knapp-synlighet; "" er trygt som fallback
|
|
// siden ingen ekte author_user_id/eier-id noensinne er en tom streng.
|
|
const [viewerId, setViewerId] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
fetch("/auth/me", { credentials: "include" })
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
.then((data: { id: string } | null) => {
|
|
if (!cancelled && data) setViewerId(data.id)
|
|
})
|
|
.catch(() => {})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
|
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/public/rounds/${roundId}/live`)
|
|
socket.onmessage = () => setRefreshKey((k) => k + 1)
|
|
return () => socket.close()
|
|
}, [roundId])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
async function load() {
|
|
const roundRes = await fetch(`/public/rounds/${roundId}`, { credentials: "include" })
|
|
if (roundRes.status === 404) {
|
|
if (!cancelled) setError("Denne runden finnes ikke.")
|
|
return
|
|
}
|
|
if (roundRes.status === 403) {
|
|
if (!cancelled) setError("Du har ikke tilgang til å se denne runden.")
|
|
return
|
|
}
|
|
if (!roundRes.ok) {
|
|
if (!cancelled) setError("Klarte ikke å hente runden. Prøv igjen om litt.")
|
|
return
|
|
}
|
|
const roundData: ApiRound = await roundRes.json()
|
|
if (cancelled) return
|
|
setRound(roundData)
|
|
|
|
const [lbRes, flightRes] = await Promise.all([
|
|
fetch(`/public/rounds/${roundId}/leaderboard`, { credentials: "include" }),
|
|
fetch(`/public/rounds/${roundId}/flight-group`, { credentials: "include" }),
|
|
])
|
|
if (lbRes.ok && !cancelled) setLeaderboard(await lbRes.json())
|
|
if (flightRes.ok && !cancelled) setFlightGroup(await flightRes.json())
|
|
}
|
|
void load()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [roundId, refreshKey])
|
|
|
|
// Anker-scroll til kommentarseksjonen (ADR-047) -- se samme mønster/
|
|
// begrunnelse i round-detail.tsx.
|
|
useEffect(() => {
|
|
if (!round) return
|
|
if (window.location.hash !== "#kommentarer") return
|
|
document.getElementById("kommentarer")?.scrollIntoView({ block: "start" })
|
|
}, [round])
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-5 text-center">
|
|
<p className="text-base font-medium text-destructive">{error}</p>
|
|
<Link href="/dashboard" className="text-base font-semibold text-primary underline underline-offset-2">
|
|
Tilbake til dashbordet
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!round) {
|
|
return (
|
|
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
|
|
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const ongoing = round.completed_at === null
|
|
|
|
return (
|
|
<div className="min-h-dvh bg-background">
|
|
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
|
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
|
<span className="flex min-h-11 items-center gap-1.5 text-base font-bold text-foreground">
|
|
<Trophy aria-hidden="true" className="size-5 text-primary" />
|
|
Følger live
|
|
</span>
|
|
</div>
|
|
<WatchTabs roundId={roundId} active="score" />
|
|
</header>
|
|
|
|
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
|
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
|
<span className="flex flex-wrap items-center gap-2">
|
|
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
|
|
{round.name?.trim() || round.course_name_snapshot}
|
|
</span>
|
|
{ongoing && (
|
|
<span className="flex shrink-0 items-center gap-1 rounded-full bg-primary px-2 py-0.5 text-xs font-bold text-primary-foreground">
|
|
<Radio aria-hidden="true" className="size-3 animate-pulse" />
|
|
Pågår nå
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="text-sm font-semibold text-muted-foreground">
|
|
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
|
|
{FORMAT_LABELS[round.play_format] ?? round.play_format} · {dateFormatter.format(new Date(round.played_at))}
|
|
</span>
|
|
<span className="text-sm text-muted-foreground">Spilt av {round.owner_display_name}</span>
|
|
</div>
|
|
|
|
<WatchPlayers
|
|
participants={round.participants}
|
|
leaderboardEntries={leaderboard?.entries ?? []}
|
|
flightGroup={flightGroup}
|
|
/>
|
|
|
|
{/* Kommentarer/bilder (ADR-044) -- BUG funnet 2026-08-06: fantes
|
|
aldri her, kun på round-detail.tsx sin egen "Score"-fane. En
|
|
person runden er delt med hadde dermed ingen vei til å se eller
|
|
delta i samtalen. `GET/POST /rounds/{id}/messages` er allerede
|
|
synlighets-gatet (_can_view_round, ikke eier/deltaker-only), så
|
|
samme endepunkt gjenbrukes uendret -- ingen ny backend-kode. */}
|
|
{/* Anker-mål for "Kommentarer"-lenken fra leaderboardets utvidede
|
|
rader (round-leaderboard.tsx) -- samme seksjon, ikke en duplikat. */}
|
|
<div id="kommentarer" className="scroll-mt-20">
|
|
<RoundMessages
|
|
roundId={roundId}
|
|
currentUserId={viewerId ?? ""}
|
|
roundOwnerUserId={round.owner_user_id}
|
|
refreshKey={refreshKey}
|
|
/>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|