"use client" import type React from "react" import { useCallback, useMemo, useState } from "react" import { MessageSquare, Reply, Trash2 } from "lucide-react" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" import type { Tag } from "@/lib/mentions" import { MentionTextarea, TaggedText } from "./mention-input" /* ------------------------------------------------------------------ */ /* Data contracts (match the real API shape) */ /* ------------------------------------------------------------------ */ export type ReactionSummary = { emoji: string count: number reacted_by_me: boolean reactors: string[] } export type CommentOut = { id: string parent_comment_id: string | null author_user_id: string author_display_name: string body: string created_at: string // ISO 8601 // Valgfritt (ADR-063): kun round_message_comment (frittstående runder) // har tagging. Org-lagchat/turneringsfeedens tilsvarende CommentOut- // former (team-chat.tsx/public-tournament.tsx) har aldri dette feltet. tags?: Tag[] } export type PostEngagementProps = { /** Fully-resolved base path, e.g. "/rounds/{id}/messages/{id}". This * component appends "/reaction" and "/comments". */ apiBase: string // Valgfri (ADR-063): kun satt for frittstående runders "Banter Board" // (round-messages.tsx/feed.tsx) -- MentionTextarea sitt @-forslags-søk // går mot /rounds/{roundId}/taggable-people. Org-lagchat/turneringsfeed // (team-chat.tsx/public-tournament.tsx) har ikke dette konseptet og // lar feltet stå utelatt -- komposereren faller da tilbake til en vanlig // tekstboks uten @-tagging, ingen regresjon for de flytene. roundId?: string currentUserId: string | null initialReactions: ReactionSummary[] initialCommentCount: number canModerate: boolean } /** Curated, fixed reaction set — exact order, no free picker. */ const REACTION_SET = ["👍", "❤️", "😂", "😮", "😢", "🙏"] as const /** Cap visual indent growth so deep threads stay readable on mobile. */ const MAX_INDENT_DEPTH = 3 const GENERIC_ERROR = "Noe gikk galt. Prøv igjen." /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ /** Parse `{ detail: { code, message } }` error envelope, with fallback. */ async function readError(res: Response): Promise { try { const data = await res.json() const msg = data?.detail?.message return typeof msg === "string" && msg.trim() !== "" ? msg : GENERIC_ERROR } catch { return GENERIC_ERROR } } /** "Kari, Ola og 3 andre reagerte" — 2026-08-07, brukerens eksplisitte krav * om å faktisk kunne se HVEM som reagerte, ikke bare et antall. */ function formatReactorSummary(names: string[]): string { if (names.length === 0) return "" if (names.length === 1) return `${names[0]} reagerte` if (names.length === 2) return `${names[0]} og ${names[1]} reagerte` const rest = names.length - 2 return `${names[0]}, ${names[1]} og ${rest} ${rest === 1 ? "annen" : "andre"} reagerte` } /** Short relative / clock-style timestamp, matching the rest of the app. */ function formatTimestamp(iso: string): string { const then = new Date(iso) if (Number.isNaN(then.getTime())) return "" const now = Date.now() const diffMs = now - then.getTime() const min = Math.floor(diffMs / 60000) if (min < 1) return "nå" if (min < 60) return `${min} min` const hours = Math.floor(min / 60) if (hours < 24) return `${hours} t` const days = Math.floor(hours / 24) if (days < 7) return `${days} d` return then.toLocaleDateString("nb-NO", { day: "numeric", month: "short" }) } /* ------------------------------------------------------------------ */ /* Main component */ /* ------------------------------------------------------------------ */ export function PostEngagement({ apiBase, roundId, currentUserId, initialReactions, initialCommentCount, canModerate, }: PostEngagementProps) { const loggedIn = currentUserId !== null /* -------------------------- Reactions -------------------------- */ const [reactions, setReactions] = useState(initialReactions) const [reactionError, setReactionError] = useState(null) const [reactionPending, setReactionPending] = useState(false) // Merge the curated set with server data so all six always render in order, // even when a given emoji has no reactions yet. const reactionRow = useMemo(() => { const byEmoji = new Map(reactions.map((r) => [r.emoji, r])) return REACTION_SET.map( (emoji) => byEmoji.get(emoji) ?? { emoji, count: 0, reacted_by_me: false, reactors: [] }, ) }, [reactions]) const toggleReaction = useCallback( async (r: ReactionSummary) => { if (!loggedIn || reactionPending) return setReactionPending(true) setReactionError(null) // Tap your own reaction -> DELETE; otherwise PUT (switching is just a PUT, // the backend replaces any prior reaction of yours). const method = r.reacted_by_me ? "DELETE" : "PUT" try { const res = await fetch(`${apiBase}/reaction`, { method, credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ emoji: r.emoji }), }) if (!res.ok) { setReactionError(await readError(res)) return } const fresh = (await res.json()) as ReactionSummary[] setReactions(fresh) } catch { setReactionError(GENERIC_ERROR) } finally { setReactionPending(false) } }, [apiBase, loggedIn, reactionPending], ) /* --------------------------- Comments -------------------------- */ const [expanded, setExpanded] = useState(false) const [comments, setComments] = useState([]) const [fetched, setFetched] = useState(false) const [loading, setLoading] = useState(false) const [threadError, setThreadError] = useState(null) const [commentCount, setCommentCount] = useState(initialCommentCount) const loadComments = useCallback(async () => { setLoading(true) setThreadError(null) try { const res = await fetch(`${apiBase}/comments`, { credentials: "include" }) if (!res.ok) { setThreadError(await readError(res)) return } const data = (await res.json()) as CommentOut[] setComments(data) setFetched(true) } catch { setThreadError(GENERIC_ERROR) } finally { setLoading(false) } }, [apiBase]) const toggleExpanded = useCallback(() => { setExpanded((prev) => { const next = !prev if (next && !fetched && !loading) void loadComments() return next }) }, [fetched, loading, loadComments]) // Build a nested tree from the flat list, preserving API order (chronological // oldest-first). Children are grouped by parent id. const { roots, childrenOf } = useMemo(() => { const childrenOf = new Map() const roots: CommentOut[] = [] for (const c of comments) { if (c.parent_comment_id === null) { roots.push(c) } else { const arr = childrenOf.get(c.parent_comment_id) ?? [] arr.push(c) childrenOf.set(c.parent_comment_id, arr) } } return { roots, childrenOf } }, [comments]) const appendComment = useCallback((c: CommentOut) => { setComments((prev) => [...prev, c]) setCommentCount((n) => n + 1) }, []) const removeSubtree = useCallback((id: string) => { setComments((prev) => { // Collect the target plus all transitive descendants. const toRemove = new Set([id]) let grew = true while (grew) { grew = false for (const c of prev) { if (c.parent_comment_id && toRemove.has(c.parent_comment_id) && !toRemove.has(c.id)) { toRemove.add(c.id) grew = true } } } setCommentCount((n) => Math.max(0, n - toRemove.size)) return prev.filter((c) => !toRemove.has(c.id)) }) }, []) /* One reply composer open at a time, tracked by target comment id. */ const [replyingTo, setReplyingTo] = useState(null) /* Hvem-reagerte-detalj (2026-08-07) — dataen er allerede i reactionRow * (samme batch-henting som selve tellingen), så dette er ren * visningslogikk, ingen ekstra fetch. */ const [showReactors, setShowReactors] = useState(false) const activeReactionGroups = reactionRow.filter((r) => r.count > 0) const allReactorNames = activeReactionGroups.flatMap((r) => r.reactors) return (
{/* ---------------------------- Reactions ---------------------------- */}
{reactionRow.map((r) => { const active = r.reacted_by_me return ( ) })}
{reactionError && (

{reactionError}

)} {/* Hvem reagerte -- alltid synlig sammendrag (ikke bare hover/tap, tilgjengelighetsregelen), utvidbart til full per-emoji-liste. */} {allReactorNames.length > 0 && (
{showReactors && (
    {activeReactionGroups.map((r) => (
  • {r.reactors.join(", ")}
  • ))}
)}
)}
{/* ------------------------ Expand / count ------------------------ */}
{/* ---------------------------- Thread ---------------------------- */} {expanded && (
{loading && (
)} {threadError && !loading && (

{threadError}

)} {!loading && !threadError && fetched && ( <> {roots.length === 0 ? (

Ingen kommentarer ennå.

) : (
    {roots.map((c) => ( ))}
)} {/* Bottom composer (top-level) */} {loggedIn ? ( ) : (

Logg inn for å kommentere.

)} )}
)}
) } /* ------------------------------------------------------------------ */ /* Single comment (recursive) */ /* ------------------------------------------------------------------ */ function CommentNode({ comment, depth, childrenOf, apiBase, roundId, currentUserId, canModerate, replyingTo, setReplyingTo, onAppend, onRemoveSubtree, }: { comment: CommentOut depth: number childrenOf: Map apiBase: string roundId?: string currentUserId: string | null canModerate: boolean replyingTo: string | null setReplyingTo: (id: string | null) => void onAppend: (c: CommentOut) => void onRemoveSubtree: (id: string) => void }) { const loggedIn = currentUserId !== null const children = childrenOf.get(comment.id) ?? [] const hasReplies = children.length > 0 const canDelete = comment.author_user_id === currentUserId || canModerate const [confirming, setConfirming] = useState(false) const [deleting, setDeleting] = useState(false) const [deleteError, setDeleteError] = useState(null) const isReplyOpen = replyingTo === comment.id const handleDelete = useCallback(async () => { setDeleting(true) setDeleteError(null) try { const res = await fetch(`${apiBase}/comments/${comment.id}`, { method: "DELETE", credentials: "include", }) if (!res.ok) { setDeleteError(await readError(res)) return } onRemoveSubtree(comment.id) } catch { setDeleteError(GENERIC_ERROR) } finally { setDeleting(false) setConfirming(false) } }, [apiBase, comment.id, onRemoveSubtree]) // Indent per level via a left border, but stop growing past the cap so long // threads on mobile don't squeeze content into a sliver. const indented = depth > 0 const cappedForChildren = Math.min(depth, MAX_INDENT_DEPTH) return (
  • {/* Author + timestamp */}
    {comment.author_display_name} {formatTimestamp(comment.created_at)}
    {/* Body */}

    {/* Actions */}
    {loggedIn && ( )} {canDelete && !confirming && ( )} {canDelete && confirming && (
    {hasReplies && (

    Sletter du denne, forsvinner også svarene under.

    )}
    )}
    {deleteError && (

    {deleteError}

    )} {/* Inline reply composer */} {isReplyOpen && loggedIn && ( { onAppend(c) setReplyingTo(null) }} onCancel={() => setReplyingTo(null)} /> )}
    {/* Children */} {hasReplies && (
      {children.map((child) => ( ))}
    )}
  • ) } /* ------------------------------------------------------------------ */ /* Composer */ /* ------------------------------------------------------------------ */ function CommentComposer({ apiBase, roundId, parentCommentId, onPosted, onCancel, autoFocus, }: { apiBase: string roundId?: string parentCommentId: string | null onPosted: (c: CommentOut) => void onCancel?: () => void autoFocus?: boolean }) { const [body, setBody] = useState("") const [tags, setTags] = useState([]) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) const empty = body.trim() === "" const submit = useCallback(async () => { if (empty || submitting) return setSubmitting(true) setError(null) try { const res = await fetch(`${apiBase}/comments`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ body: body.trim(), ...(parentCommentId ? { parent_comment_id: parentCommentId } : {}), // Kun med når roundId faktisk er satt (round_message_comment- // flyten) -- org-lagchat/turneringsfeedens comment-endepunkt // aksepterer ikke (og trenger ikke) dette feltet. ...(roundId ? { tags } : {}), }), }) if (!res.ok) { setError(await readError(res)) return } const created = (await res.json()) as CommentOut onPosted(created) setBody("") setTags([]) } catch { setError(GENERIC_ERROR) } finally { setSubmitting(false) } }, [apiBase, body, empty, onPosted, parentCommentId, roundId, submitting, tags]) const composerClassName = "min-h-11 w-full resize-y rounded-2xl border border-border bg-card px-4 py-3 text-base leading-relaxed text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" function handleComposerKeyDown(e: React.KeyboardEvent) { // Cmd/Ctrl+Enter submits; plain Enter keeps newlines. Respect IME. if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && !e.nativeEvent.isComposing && e.keyCode !== 229) { e.preventDefault() void submit() } } return (
    {roundId ? ( { setBody(v) setTags(t) }} onKeyDownWhenClosed={handleComposerKeyDown} rows={parentCommentId ? 2 : 3} placeholder={parentCommentId ? "Skriv et svar … (@ for å tagge)" : "Skriv en kommentar … (@ for å tagge)"} className={composerClassName} /> ) : (