teecup/frontend/components/watch-round.tsx

284 lines
11 KiB
TypeScript
Raw Normal View History

"use client"
// Tredjeparts/offentlig live-visning av en frittstående runde (ADR-036
2026-08-07 22:02:56 +02:00
// 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).
//
2026-08-07 22:02:56 +02:00
// 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 dynamic from "next/dynamic"
import { Radio, Trophy } from "lucide-react"
2026-08-07 22:02:56 +02:00
import { WatchTabs } from "@/components/watch-tabs"
import { WatchPlayers, type ApiPublicParticipant, type ApiLeaderboardEntry, type ApiFlightSummary } from "@/components/watch-players"
import { RoundMessages } from "@/components/round-messages"
import type { FlagMapEntry } from "@/components/flag-map-overview"
const FlagMapOverviewLazy = dynamic(() => import("@/components/flag-map-overview"), { ssr: false })
// Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) --
// tilskuer-varianten, mot /public/rounds/{id}/flag-map. Tilgang er
// allerede gatet av _get_viewable_round_or_404 (samme regel som resten
// av denne siden, styrt av round.visibility_mode) -- selve INNHOLDET
// (alle flagg vs. ingenting) styres i tillegg av round.flag_map_visible
// server-side (`visible_to_all` i responsen), en tilskuer har jo ikke
// noe "eget flagg" å falle tilbake til når bryteren er av.
type ApiFlagMapPublic = { visible_to_all: boolean; flags: FlagMapEntry[] }
function WatchFlagMap({ roundId, refreshKey }: { roundId: string; refreshKey: number }) {
const [data, setData] = useState<ApiFlagMapPublic | null>(null)
const [expanded, setExpanded] = useState(false)
useEffect(() => {
let cancelled = false
fetch(`/public/rounds/${roundId}/flag-map`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((json: { visible_to_all: boolean; flags: Array<Record<string, unknown>> } | null) => {
if (cancelled || !json) return
setData({
visible_to_all: json.visible_to_all,
flags: json.flags.map((f) => ({
participantId: f.participant_id as string,
displayName: f.display_name as string,
lap: f.lap as number,
holeNumber: f.hole_number as number,
lat: f.lat as number,
lng: f.lng as number,
onGreen: f.on_green as boolean,
distanceToPinCm: f.distance_to_pin_cm as number | null,
})),
})
})
.catch(() => {})
return () => {
cancelled = true
}
}, [roundId, refreshKey])
// Ingen synlig seksjon i det hele tatt når bryteren er av OG tilskueren
// (naturligvis) ikke har noe eget flagg -- ikke en tom/forvirrende boks.
if (!data || (!data.visible_to_all && data.flags.length === 0)) return null
return (
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex min-h-11 items-center justify-between gap-2 text-left"
aria-expanded={expanded}
>
<span className="text-base font-bold text-foreground">Kartoversikt over flagg</span>
<span className="text-sm font-semibold text-primary">{expanded ? "Skjul" : "Vis kart"}</span>
</button>
{expanded && (
<div className="h-80 overflow-hidden rounded-xl border border-border">
<FlagMapOverviewLazy flags={data.flags} visibleToAll={data.visible_to_all} />
</div>
)}
</div>
)
}
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
2026-08-07 22:02:56 +02:00
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)
2026-08-07 22:02:56 +02:00
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)
2026-08-07 22:02:56 +02:00
// 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)
2026-08-07 22:02:56 +02:00
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])
2026-08-07 22:02:56 +02:00
// 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>
2026-08-07 22:02:56 +02:00
<WatchTabs roundId={roundId} active="score" />
</header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
2026-07-29 12:44:51 +02:00
<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
</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>
2026-08-07 22:02:56 +02:00
<WatchPlayers
participants={round.participants}
leaderboardEntries={leaderboard?.entries ?? []}
flightGroup={flightGroup}
/>
{round.play_format === "flag" && <WatchFlagMap roundId={roundId} refreshKey={refreshKey} />}
2026-08-07 22:02:56 +02:00
{/* Kommentarer/bilder (ADR-044) -- BUG funnet 2026-08-06: fantes
aldri her, kun 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),
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>
)
}