"use client" import { useCallback, useEffect, useRef, useState } from "react" import { Camera, Clock, ImagePlus, RefreshCw, Send, Trash2, WifiOff, X } from "lucide-react" import { PostEngagement, type ReactionSummary } from "./post-engagement" import { cn } from "@/lib/utils" import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue" /** * Rundespesifikk kommentartråd for org-turneringer (ADR-102) -- ekte * parallell til `RoundMessages` (frittstående runder), IKKE en utvidelse * av den eksisterende hele-turnering-brede Banter Board (ADR-025). * * Bevisst UTENFOR omfang i v1 (se ADR-102): ingen @-tagging (samme * presedens som team-chat.tsx/public-tournament.tsx -- PostEngagement * sin `roundId`-prop utelates bevisst, komposereren er en vanlig * tekstboks), ingen sanntid/WebSocket-piggyback (denne autentiserte * visningen har ingen WS-tilkobling i det hele tatt ennå -- eget, * uprioritert gap). Refetches ved mount + en manuell "Oppdater"-knapp. */ export type ApiTournamentRoundMessage = { id: string tournament_round_id: string author_user_id: string author_display_name: string body: string | null image_url: string | null created_at: string // ISO 8601 reactions: ReactionSummary[] comment_count: number } // Et lokalt, ikke-synkronisert innlegg (offline-kø), speiler round-messages.tsx. type PendingMessage = ApiTournamentRoundMessage & { _clientMessageId: string } export type TournamentRoundMessagesProps = { base: string // `/orgs/{organizationId}/tournaments/{tournamentId}` roundId: string currentUserId: string isOrgAdmin: boolean } const ACCEPTED_IMAGE_TYPES = "image/*" const MAX_IMAGE_HEIGHT = 300 const MAX_UPLOAD_BYTES = 20 * 1024 * 1024 async function readErrorMessage(res: Response, fallback: string): Promise { try { const data = await res.json() const message = data?.detail?.message if (typeof message === "string" && message.length > 0) return message } catch { // Ikke JSON -- bruk fallback. } return fallback } 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 TournamentRoundMessages({ base, roundId, currentUserId, isOrgAdmin }: TournamentRoundMessagesProps) { const messagesUrl = `${base}/rounds/${roundId}/messages` const [messages, setMessages] = useState([]) const [loading, setLoading] = useState(true) const [loadError, setLoadError] = useState(null) const [body, setBody] = useState("") const [imageFile, setImageFile] = useState(null) const [imagePreview, setImagePreview] = useState(null) const [posting, setPosting] = useState(false) const [postError, setPostError] = useState(null) const [deletingId, setDeletingId] = useState(null) // Egen kø-navnerom -- unngår kollisjon med en evt. fremtidig hull-score-kø // på samme runde (gap-punkt 1, ikke koblet på ennå), selv om begge ville // delt samme IndexedDB-database. const messagesMatchId = `${base}:${roundId}:tournament-messages` const [isOnline, setIsOnline] = useState(true) const [pendingCount, setPendingCount] = useState(0) const [syncingMessages, setSyncingMessages] = useState(false) const [pendingMessages, setPendingMessages] = useState([]) const fileInputRef = useRef(null) const cameraInputRef = useRef(null) const load = useCallback(async () => { setLoading(true) setLoadError(null) try { const res = await fetch(messagesUrl, { credentials: "include" }) if (!res.ok) throw new Error(`Status ${res.status}`) const data: ApiTournamentRoundMessage[] = await res.json() setMessages(data) } catch { setLoadError("Kunne ikke laste kommentarene. Prøv å laste siden på nytt.") } finally { setLoading(false) } }, [messagesUrl]) useEffect(() => { void load() }, [load]) const flushPendingMessages = useCallback(async () => { if (syncingMessages) return setSyncingMessages(true) try { const outcomes = await flushQueue(messagesMatchId) if (outcomes.length === 0) return for (const o of outcomes) { const sentBody = o.entry.body as Record const clientMessageId = sentBody.client_message_id as string | undefined setPendingMessages((prev) => { const match = prev.find((m) => m._clientMessageId === clientMessageId) if (match?.image_url) URL.revokeObjectURL(match.image_url) return prev.filter((m) => m._clientMessageId !== clientMessageId) }) } const failed = outcomes.filter((o) => !o.ok) if (failed.length > 0) { setPostError( `${failed.length} ${failed.length === 1 ? "kommentar" : "kommentarer"} kunne ikke synkroniseres: ${failed[0].message}`, ) } setPendingCount(await queueCount(messagesMatchId)) await load() } finally { setSyncingMessages(false) } }, [messagesMatchId, syncingMessages, load]) useEffect(() => { setIsOnline(navigator.onLine) function handleOnline() { setIsOnline(true) void flushPendingMessages() } function handleOffline() { setIsOnline(false) } window.addEventListener("online", handleOnline) window.addEventListener("offline", handleOffline) return () => { window.removeEventListener("online", handleOnline) window.removeEventListener("offline", handleOffline) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [messagesMatchId]) useEffect(() => { let cancelled = false queueCount(messagesMatchId) .then((count) => { if (cancelled) return setPendingCount(count) if (count > 0 && navigator.onLine) void flushPendingMessages() }) .catch(() => {}) return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [messagesMatchId]) 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 = "" if (cameraInputRef.current) cameraInputRef.current.value = "" }, []) function handleFileChange(e: React.ChangeEvent) { const file = e.target.files?.[0] if (!file) return if (file.size > MAX_UPLOAD_BYTES) { setPostError("Bildet er for stort (maks 20 MB). Velg et mindre bilde.") e.target.value = "" 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 queueMessageOffline(fields: Record, clientMessageId: string) { await enqueueWrite({ url: messagesUrl, method: "POST", body: fields, matchId: messagesMatchId, isMultipart: true, }) const pendingImageUrl = imageFile ? URL.createObjectURL(imageFile) : null setPendingMessages((prev) => [ { id: `pending-${clientMessageId}`, tournament_round_id: roundId, author_user_id: currentUserId, author_display_name: "Du", body: trimmedBody || null, image_url: pendingImageUrl, created_at: new Date().toISOString(), reactions: [], comment_count: 0, _clientMessageId: clientMessageId, }, ...prev, ]) setPendingCount((c) => c + 1) setBody("") clearSelectedImage() } async function handlePost(e: React.FormEvent) { e.preventDefault() if (!canPost) return setPosting(true) setPostError(null) const clientMessageId = crypto.randomUUID() const fields: Record = { client_message_id: clientMessageId } if (trimmedBody.length > 0) fields.body = trimmedBody if (imageFile) fields.image = imageFile if (!navigator.onLine) { await queueMessageOffline(fields, clientMessageId) setPosting(false) return } try { const formData = new FormData() for (const [k, v] of Object.entries(fields)) formData.append(k, v) const res = await fetch(messagesUrl, { method: "POST", credentials: "include", body: formData, }) if (!res.ok) { setPostError(await readErrorMessage(res, "Kunne ikke poste kommentaren. Prøv igjen.")) return } const created: ApiTournamentRoundMessage = await res.json() setMessages((prev) => [created, ...prev]) setBody("") clearSelectedImage() } catch { await queueMessageOffline(fields, clientMessageId) } finally { setPosting(false) } } async function handleDelete(id: string) { setDeletingId(id) setPostError(null) try { const res = await fetch(`${messagesUrl}/${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 (

Kommentarer

{/* Composer */}