425 lines
17 KiB
TypeScript
425 lines
17 KiB
TypeScript
|
|
"use client"
|
|||
|
|
|
|||
|
|
import { useEffect, useMemo, useState } from "react"
|
|||
|
|
import { ChevronDown, Plus, Ruler, Trash2 } from "lucide-react"
|
|||
|
|
import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet"
|
|||
|
|
import { bearingDegrees } from "@/lib/geo"
|
|||
|
|
import { midpoint } from "@/lib/hole-geometry"
|
|||
|
|
import { cn } from "@/lib/utils"
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Slag-for-slag GPS-avstandsmåling (ADR-048), UI-laget som binder
|
|||
|
|
* `ShotMeasurementSheet`/`MapPointPicker` (begge helt generiske, ingen
|
|||
|
|
* endring) mot et faktisk backend-endepunkt. Generalisert 2026-08-22
|
|||
|
|
* (ADR-103) fra en lokal funksjon i round-detail.tsx til denne delte
|
|||
|
|
* filen -- kalleren bygger nå URL-ene selv i stedet for at komponenten
|
|||
|
|
* antar `/rounds/{roundId}/...`-formen, slik at BÅDE frittstående
|
|||
|
|
* runder OG org-turneringer (helt ulike REST-stier) kan gjenbruke den
|
|||
|
|
* uendret.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
export type ShotRecord = {
|
|||
|
|
id: string
|
|||
|
|
club: string
|
|||
|
|
distance_meters: number
|
|||
|
|
shared: boolean
|
|||
|
|
start_lat: number
|
|||
|
|
start_lng: number
|
|||
|
|
end_lat: number
|
|||
|
|
end_lng: number
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** De to backendene navngir delings-lenken ulikt (`shared_round_message_id`
|
|||
|
|
* vs. `shared_tournament_round_message_id`, ulike måltabeller) --
|
|||
|
|
* normaliseres her til ett felles `shared`-flagg, resten av feltene er
|
|||
|
|
* identiske på tvers av begge API-kontraktene. */
|
|||
|
|
function normalizeShot(raw: Record<string, unknown>): ShotRecord {
|
|||
|
|
return {
|
|||
|
|
id: raw.id as string,
|
|||
|
|
club: raw.club as string,
|
|||
|
|
distance_meters: raw.distance_meters as number,
|
|||
|
|
shared: Boolean(raw.shared_round_message_id ?? raw.shared_tournament_round_message_id),
|
|||
|
|
start_lat: raw.start_lat as number,
|
|||
|
|
start_lng: raw.start_lng as number,
|
|||
|
|
end_lat: raw.end_lat as number,
|
|||
|
|
end_lng: raw.end_lng as number,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type ShotMeasurementEntryProps = {
|
|||
|
|
/** GET (liste) + POST (opprett) -- fullt sti, f.eks.
|
|||
|
|
* `/rounds/{roundId}/participants/{id}/holes/{n}/shots`. */
|
|||
|
|
shotsUrl: string
|
|||
|
|
/** Rundescopet base for DELETE `${x}/${id}` og POST `${x}/${id}/share`
|
|||
|
|
* -- f.eks. `/rounds/{roundId}/shots`. */
|
|||
|
|
shotsRoundBaseUrl: string
|
|||
|
|
/** Banegeometri (utslag->green) for kartretning -- utelates helt for
|
|||
|
|
* en bane uten kjent geometri, faller da tilbake til forrige-slag-
|
|||
|
|
* retning (uendret atferd). */
|
|||
|
|
targetPointsUrl?: string
|
|||
|
|
holeNumber: number
|
|||
|
|
ownBagClubs: string[]
|
|||
|
|
readOnly: boolean
|
|||
|
|
variant: "inline" | "badge"
|
|||
|
|
label?: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function ShotMeasurementEntry({
|
|||
|
|
shotsUrl,
|
|||
|
|
shotsRoundBaseUrl,
|
|||
|
|
targetPointsUrl,
|
|||
|
|
holeNumber,
|
|||
|
|
ownBagClubs,
|
|||
|
|
readOnly,
|
|||
|
|
variant,
|
|||
|
|
label,
|
|||
|
|
}: ShotMeasurementEntryProps) {
|
|||
|
|
const [open, setOpen] = useState(false)
|
|||
|
|
// Brukertilbakemelding 2026-08-08: "jeg burde jo se slaget selv, selv om
|
|||
|
|
// det ikke er delt" -- merkelappen viste tidligere KUN et antall, ingen
|
|||
|
|
// måte å se kølle/avstand for egne, private slag. Lagrer nå hele listen
|
|||
|
|
// (ikke bare .length), viser den i en utvidbar liste, med sletting av et
|
|||
|
|
// feilmålt slag.
|
|||
|
|
const [shots, setShots] = useState<ShotRecord[]>([])
|
|||
|
|
const [listOpen, setListOpen] = useState(false)
|
|||
|
|
const [submitting, setSubmitting] = useState(false)
|
|||
|
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
|||
|
|
const [shareError, setShareError] = useState<string | null>(null)
|
|||
|
|
useEffect(() => {
|
|||
|
|
let cancelled = false
|
|||
|
|
fetch(shotsUrl, { credentials: "include" })
|
|||
|
|
.then((res) => (res.ok ? res.json() : []))
|
|||
|
|
.then((data: Record<string, unknown>[]) => {
|
|||
|
|
if (!cancelled) setShots(data.map(normalizeShot))
|
|||
|
|
})
|
|||
|
|
.catch(() => {})
|
|||
|
|
return () => {
|
|||
|
|
cancelled = true
|
|||
|
|
}
|
|||
|
|
}, [shotsUrl])
|
|||
|
|
const count = shots.length
|
|||
|
|
|
|||
|
|
// Ekte banegeometri (2026-08-21, brukerønske) -- når hullet har BÅDE
|
|||
|
|
// utslags- OG green-koordinater kjent (samme datakilde som
|
|||
|
|
// HoleTargetDistance/HoleDiagram bruker), skal kartretningen ALLTID
|
|||
|
|
// følge utslag->green, ikke gjettes fra spillerens forrige slag (som
|
|||
|
|
// kan avvike, f.eks. en slice/hook).
|
|||
|
|
const [courseBearing, setCourseBearing] = useState<number | undefined>(undefined)
|
|||
|
|
useEffect(() => {
|
|||
|
|
let cancelled = false
|
|||
|
|
setCourseBearing(undefined)
|
|||
|
|
if (!targetPointsUrl) return
|
|||
|
|
fetch(targetPointsUrl, { credentials: "include" })
|
|||
|
|
.then((res) => (res.ok ? res.json() : []))
|
|||
|
|
.then((points: { poi_type: string; location: string | null; latitude: number; longitude: number }[]) => {
|
|||
|
|
if (cancelled) return
|
|||
|
|
const middle = points.find((p) => p.poi_type === "green" && p.location === "middle")
|
|||
|
|
const teeFront = points.find((p) => p.poi_type === "tee_front")
|
|||
|
|
const teeBack = points.find((p) => p.poi_type === "tee_back")
|
|||
|
|
const teeRef =
|
|||
|
|
teeFront && teeBack
|
|||
|
|
? midpoint({ lat: teeFront.latitude, lng: teeFront.longitude }, { lat: teeBack.latitude, lng: teeBack.longitude })
|
|||
|
|
: teeFront
|
|||
|
|
? { lat: teeFront.latitude, lng: teeFront.longitude }
|
|||
|
|
: teeBack
|
|||
|
|
? { lat: teeBack.latitude, lng: teeBack.longitude }
|
|||
|
|
: null
|
|||
|
|
setCourseBearing(
|
|||
|
|
teeRef && middle ? bearingDegrees(teeRef, { lat: middle.latitude, lng: middle.longitude }) : undefined,
|
|||
|
|
)
|
|||
|
|
})
|
|||
|
|
.catch(() => {
|
|||
|
|
if (!cancelled) setCourseBearing(undefined)
|
|||
|
|
})
|
|||
|
|
return () => {
|
|||
|
|
cancelled = true
|
|||
|
|
}
|
|||
|
|
}, [targetPointsUrl])
|
|||
|
|
|
|||
|
|
// Kartretning ved slagmåling (brukerønske 2026-08-10, utvidet
|
|||
|
|
// 2026-08-21): "opp på skjermen" følger banens EGEN spillretning
|
|||
|
|
// (utslag->green, courseBearing over) når den er kjent -- ellers
|
|||
|
|
// faller tilbake til spillerens EGEN gangretning, regnet ut fra det
|
|||
|
|
// SISTE tidligere målte slaget på dette hullet (start→slutt-retningen).
|
|||
|
|
// `undefined` (ingen rotasjon regnet ut ennå) for det aller første
|
|||
|
|
// slaget på et hull uten kjent banegeometri -- MapPointPicker faller
|
|||
|
|
// da selv tilbake til enhetens GPS-heading/nord.
|
|||
|
|
const initialBearing = useMemo(() => {
|
|||
|
|
if (typeof courseBearing === "number") return courseBearing
|
|||
|
|
const previous = shots[shots.length - 1]
|
|||
|
|
if (!previous) return undefined
|
|||
|
|
return bearingDegrees(
|
|||
|
|
{ lat: previous.start_lat, lng: previous.start_lng },
|
|||
|
|
{ lat: previous.end_lat, lng: previous.end_lng },
|
|||
|
|
)
|
|||
|
|
}, [shots, courseBearing])
|
|||
|
|
|
|||
|
|
async function deleteShot(shotId: string) {
|
|||
|
|
const res = await fetch(`${shotsRoundBaseUrl}/${shotId}`, { method: "DELETE", credentials: "include" })
|
|||
|
|
if (res.ok) {
|
|||
|
|
setShots((prev) => prev.filter((s) => s.id !== shotId))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Reell bug funnet 2026-08-08: en avvist innsending (f.eks. serverens
|
|||
|
|
// distance_meters > 0-sjekk, hvis brukeren rakk å målt begge punkter for
|
|||
|
|
// fort) lukket tidligere arket STILLE -- ingen feilmelding, brukeren så
|
|||
|
|
// aldri hvorfor ingenting ble lagret/delt. Viser nå en reell feil og
|
|||
|
|
// holder arket åpent i stedet for å anta suksess.
|
|||
|
|
async function submitShot(result: {
|
|||
|
|
club: string
|
|||
|
|
distanceMeters: number
|
|||
|
|
startMethod: "gps" | "map_tap"
|
|||
|
|
startLat: number
|
|||
|
|
startLng: number
|
|||
|
|
endMethod: "gps" | "map_tap"
|
|||
|
|
endLat: number
|
|||
|
|
endLng: number
|
|||
|
|
share: boolean
|
|||
|
|
shareText: string
|
|||
|
|
}) {
|
|||
|
|
setSubmitError(null)
|
|||
|
|
setSubmitting(true)
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(shotsUrl, {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
credentials: "include",
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
club: result.club,
|
|||
|
|
distance_meters: result.distanceMeters,
|
|||
|
|
start_method: result.startMethod,
|
|||
|
|
start_lat: result.startLat,
|
|||
|
|
start_lng: result.startLng,
|
|||
|
|
end_method: result.endMethod,
|
|||
|
|
end_lat: result.endLat,
|
|||
|
|
end_lng: result.endLng,
|
|||
|
|
}),
|
|||
|
|
})
|
|||
|
|
if (!res.ok) {
|
|||
|
|
const body = await res.json().catch(() => null)
|
|||
|
|
const detail = body?.detail
|
|||
|
|
const message =
|
|||
|
|
typeof detail === "string"
|
|||
|
|
? detail
|
|||
|
|
: typeof detail?.message === "string"
|
|||
|
|
? detail.message
|
|||
|
|
: "Kunne ikke lagre slaget. Sjekk at avstanden er gyldig (over 0 meter) og prøv igjen."
|
|||
|
|
setSubmitError(message)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
let shot = normalizeShot(await res.json())
|
|||
|
|
setShareError(null)
|
|||
|
|
if (result.share) {
|
|||
|
|
const shareRes = await fetch(`${shotsRoundBaseUrl}/${shot.id}/share`, {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
credentials: "include",
|
|||
|
|
body: JSON.stringify({ body: result.shareText }),
|
|||
|
|
})
|
|||
|
|
// Reell bug funnet 2026-08-08: dette svaret ble aldri sjekket -- en
|
|||
|
|
// avvist deling (f.eks. en driftsfeil) lot slaget lagres som om alt
|
|||
|
|
// gikk bra, ingen feilmelding, ingen melding i feeden. Slaget er
|
|||
|
|
// uansett lagret på dette tidspunktet -- arket lukkes derfor alltid
|
|||
|
|
// her (unngår en duplikat-innsending ved gjentatt "Lagre slag"), og
|
|||
|
|
// en mislykket deling vises separat ved siden av merkelappen i
|
|||
|
|
// stedet for å holde arket åpent.
|
|||
|
|
if (shareRes.ok) {
|
|||
|
|
shot = normalizeShot(await shareRes.json())
|
|||
|
|
} else {
|
|||
|
|
setShareError("Slaget ble lagret, men delingen feilet. Prøv å dele på nytt fra listen.")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
setShots((prev) => [...prev, shot])
|
|||
|
|
setOpen(false)
|
|||
|
|
} catch {
|
|||
|
|
setSubmitError("Klarte ikke å nå serveren. Sjekk tilkoblingen og prøv igjen.")
|
|||
|
|
} finally {
|
|||
|
|
setSubmitting(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function shareExisting(shot: ShotRecord) {
|
|||
|
|
const res = await fetch(`${shotsRoundBaseUrl}/${shot.id}/share`, {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
credentials: "include",
|
|||
|
|
body: JSON.stringify({ body: `⛳ ${shot.club}, ${shot.distance_meters} m – hull ${holeNumber}` }),
|
|||
|
|
})
|
|||
|
|
if (res.ok) {
|
|||
|
|
const updated = normalizeShot(await res.json())
|
|||
|
|
setShots((prev) => prev.map((s) => (s.id === shot.id ? updated : s)))
|
|||
|
|
setShareError(null)
|
|||
|
|
} else {
|
|||
|
|
setShareError("Delingen feilet. Prøv igjen om litt.")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (readOnly) {
|
|||
|
|
// Skrivebeskyttet visning (fullført/eldre runde): fortsatt nyttig å SE
|
|||
|
|
// egne målte slag, bare uten å kunne måle/dele/slette nye.
|
|||
|
|
if (count === 0) return null
|
|||
|
|
return (
|
|||
|
|
<div className={cn("flex flex-col gap-1.5", variant === "inline" ? "" : "items-end")}>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={(e) => {
|
|||
|
|
e.stopPropagation()
|
|||
|
|
setListOpen((o) => !o)
|
|||
|
|
}}
|
|||
|
|
aria-expanded={listOpen}
|
|||
|
|
className="inline-flex min-h-11 items-center gap-1.5 rounded-full border border-dashed border-border bg-background px-3 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|||
|
|
>
|
|||
|
|
<Ruler className="size-3.5 shrink-0" aria-hidden="true" />
|
|||
|
|
{label ? `${label}: ` : ""}
|
|||
|
|
{count} slag målt
|
|||
|
|
<ChevronDown className={cn("size-3.5 shrink-0 transition-transform", listOpen && "rotate-180")} aria-hidden="true" />
|
|||
|
|
</button>
|
|||
|
|
{listOpen && <ShotList shots={shots} onDelete={undefined} onShare={undefined} />}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const addLabel = label ? `${label}: Mål et slag` : "Mål et slag"
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className={cn("flex flex-col gap-1.5", variant === "inline" ? "" : "items-end")}>
|
|||
|
|
<div className="flex items-center gap-1.5">
|
|||
|
|
{count > 0 && (
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={(e) => {
|
|||
|
|
e.stopPropagation()
|
|||
|
|
setListOpen((o) => !o)
|
|||
|
|
}}
|
|||
|
|
aria-expanded={listOpen}
|
|||
|
|
className={cn(
|
|||
|
|
"inline-flex items-center gap-1.5 rounded-full border border-dashed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|||
|
|
variant === "inline"
|
|||
|
|
? "min-h-11 justify-center border-border bg-card px-4 text-base font-semibold text-foreground hover:bg-accent/50"
|
|||
|
|
: "min-h-11 border-border bg-background px-3 text-xs font-semibold text-muted-foreground hover:bg-accent/50",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<Ruler className={variant === "inline" ? "size-5 shrink-0" : "size-3.5 shrink-0"} aria-hidden="true" />
|
|||
|
|
{label ? `${label}: ` : ""}
|
|||
|
|
{count} slag målt
|
|||
|
|
<ChevronDown className={cn("size-3.5 shrink-0 transition-transform", listOpen && "rotate-180")} aria-hidden="true" />
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={(e) => {
|
|||
|
|
e.stopPropagation()
|
|||
|
|
setSubmitError(null)
|
|||
|
|
setOpen(true)
|
|||
|
|
}}
|
|||
|
|
aria-label={addLabel}
|
|||
|
|
className={cn(
|
|||
|
|
"inline-flex items-center justify-center gap-1.5 rounded-full border border-dashed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|||
|
|
count > 0
|
|||
|
|
? "size-11 shrink-0 border-border bg-card text-foreground hover:bg-accent/50"
|
|||
|
|
: variant === "inline"
|
|||
|
|
? "min-h-11 justify-center border-border bg-card px-4 text-base font-semibold text-foreground hover:bg-accent/50"
|
|||
|
|
: "min-h-11 border-border bg-background px-3 text-xs font-semibold text-muted-foreground hover:bg-accent/50",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<Plus className={cn(count > 0 ? "size-5" : variant === "inline" ? "size-5 shrink-0" : "size-3.5 shrink-0")} aria-hidden="true" />
|
|||
|
|
{count === 0 && addLabel}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{shareError && (
|
|||
|
|
<p role="alert" className="text-xs font-medium text-destructive">
|
|||
|
|
{shareError}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{listOpen && count > 0 && <ShotList shots={shots} onDelete={deleteShot} onShare={shareExisting} />}
|
|||
|
|
|
|||
|
|
{open && (
|
|||
|
|
<ShotMeasurementSheet
|
|||
|
|
holeNumber={holeNumber}
|
|||
|
|
existingShotCount={count}
|
|||
|
|
ownBagClubs={ownBagClubs}
|
|||
|
|
initialBearing={initialBearing}
|
|||
|
|
submitError={submitError}
|
|||
|
|
submitting={submitting}
|
|||
|
|
onSubmit={submitShot}
|
|||
|
|
onCancel={() => setOpen(false)}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Satellittutsnitt for et allerede målt slag (brukerønske 2026-08-08: "jeg
|
|||
|
|
// burde kunne se på et satellittfoto hvor jeg har slått hvert slag" -- listen
|
|||
|
|
// viste tidligere kun tekst, ingen måte å se selve plasseringen). Samme
|
|||
|
|
// mønster som forhåndsvisningen i shot-measurement-sheet.tsx sitt
|
|||
|
|
// resultat-steg: klient-side, OFFENTLIG (URL-restriktert) Mapbox-token,
|
|||
|
|
// ingen server-tur-retur -- koordinatene er allerede en del av ShotRecord.
|
|||
|
|
function shotThumbnailUrl(shot: ShotRecord): string | null {
|
|||
|
|
if (!process.env.NEXT_PUBLIC_MAPBOX_TOKEN) return null
|
|||
|
|
return `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/static/pin-s-a+ff5a1f(${shot.start_lng},${shot.start_lat}),pin-s-b+2f7a3f(${shot.end_lng},${shot.end_lat})/auto/160x160@2x?padding=30&access_token=${process.env.NEXT_PUBLIC_MAPBOX_TOKEN}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ShotList({
|
|||
|
|
shots,
|
|||
|
|
onDelete,
|
|||
|
|
onShare,
|
|||
|
|
}: {
|
|||
|
|
shots: ShotRecord[]
|
|||
|
|
onDelete?: (shotId: string) => void
|
|||
|
|
onShare?: (shot: ShotRecord) => void
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<ul className="flex w-full min-w-[16rem] flex-col gap-1.5 rounded-xl border border-border bg-card p-1.5">
|
|||
|
|
{shots.map((shot) => {
|
|||
|
|
const thumbUrl = shotThumbnailUrl(shot)
|
|||
|
|
return (
|
|||
|
|
<li key={shot.id} className="flex items-center gap-3 rounded-lg p-1.5 text-sm">
|
|||
|
|
{thumbUrl ? (
|
|||
|
|
// eslint-disable-next-line @next/next/no-img-element -- ekstern Mapbox-URL, ikke next/image-verdt
|
|||
|
|
<img
|
|||
|
|
src={thumbUrl || "/placeholder.svg"}
|
|||
|
|
alt={`Satellittutsnitt av ${shot.club}-slaget, ${shot.distance_meters} meter, med start- og sluttpunkt markert`}
|
|||
|
|
className="size-14 shrink-0 rounded-lg border border-border object-cover"
|
|||
|
|
/>
|
|||
|
|
) : null}
|
|||
|
|
<span className="flex-1 truncate font-medium text-foreground">
|
|||
|
|
{shot.club} <span className="tabular-nums text-muted-foreground">— {shot.distance_meters} m</span>
|
|||
|
|
</span>
|
|||
|
|
{shot.shared ? (
|
|||
|
|
<span className="text-[11px] font-semibold text-primary">Delt</span>
|
|||
|
|
) : onShare ? (
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={(e) => {
|
|||
|
|
e.stopPropagation()
|
|||
|
|
onShare(shot)
|
|||
|
|
}}
|
|||
|
|
className="text-[11px] font-semibold text-primary underline-offset-2 hover:underline"
|
|||
|
|
>
|
|||
|
|
Del
|
|||
|
|
</button>
|
|||
|
|
) : null}
|
|||
|
|
{onDelete && (
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={(e) => {
|
|||
|
|
e.stopPropagation()
|
|||
|
|
onDelete(shot.id)
|
|||
|
|
}}
|
|||
|
|
aria-label={`Slett ${shot.club}, ${shot.distance_meters} meter`}
|
|||
|
|
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/50 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|||
|
|
>
|
|||
|
|
<Trash2 className="size-4" aria-hidden="true" />
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</li>
|
|||
|
|
)
|
|||
|
|
})}
|
|||
|
|
</ul>
|
|||
|
|
)
|
|||
|
|
}
|