Fullfører ADR-063 -- backend var allerede committet (206beef). Bruker
ba eksplisitt om at Claude selv implementerer frontend-promptet siden
V0-kreditter var oppbrukt.
lib/mentions.ts: ren offset-/diff-logikk (deteksjon, innsetting,
diff-justering ved redigering, tekst-segmentering), 15 enhetstester.
components/mention-input.tsx: MentionTextarea (autocomplete-dropdown,
tastaturnavigasjon) + TaggedText (lenker til /my-friends/{id}). Koblet
inn i round-messages.tsx (innlegg) og post-engagement.tsx (kommentarer,
delt av flere meldingssystemer -- roundId/tags gjort valgfrie for å
ikke bryte de org-scopede callerne uten tagging).
Rettet også et hull oppdaget underveis: backendens GET /feed manglet
tags (kun list_round_messages/comments hadde det) -- det er nettopp
/my-feed-siden som var det opprinnelige brukseksempelet.
Scratch-verifisert (egen DB/rolle/MinIO/API-/frontend-container, to
ekte testbrukere, venn-relasjon, felles runde): autocomplete, tagging
i både innlegg og kommentar, rendring som lenke i rundevisning OG i
aggregert feed, varsel opprettet, personvern-scoping bekreftet (kun
faktiske relasjoner tilbys, ikke seg selv, ikke fremmede), lys+mørk.
tsc --noEmit rent, 45/45 vitest grønt. teecup_db urørt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
721 lines
25 KiB
TypeScript
721 lines
25 KiB
TypeScript
"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<string> {
|
|
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<ReactionSummary[]>(initialReactions)
|
|
const [reactionError, setReactionError] = useState<string | null>(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<CommentOut[]>([])
|
|
const [fetched, setFetched] = useState(false)
|
|
const [loading, setLoading] = useState(false)
|
|
const [threadError, setThreadError] = useState<string | null>(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<string, CommentOut[]>()
|
|
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<string>([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<string | null>(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 (
|
|
<section className="flex flex-col gap-4 pt-2" aria-label="Reaksjoner og kommentarer">
|
|
{/* ---------------------------- Reactions ---------------------------- */}
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex flex-wrap gap-1 rounded-full bg-muted p-1" role="group" aria-label="Reaksjoner">
|
|
{reactionRow.map((r) => {
|
|
const active = r.reacted_by_me
|
|
return (
|
|
<button
|
|
key={r.emoji}
|
|
type="button"
|
|
onClick={() => toggleReaction(r)}
|
|
disabled={!loggedIn || reactionPending}
|
|
aria-pressed={active}
|
|
aria-label={`Reager med ${r.emoji}${r.count > 0 ? `, ${r.count}` : ""}`}
|
|
className={cn(
|
|
"inline-flex min-h-11 items-center justify-center gap-1.5 rounded-full px-3 text-lg leading-none transition-transform focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
"enabled:hover:brightness-95 enabled:active:scale-[0.98] disabled:cursor-default disabled:opacity-70",
|
|
active
|
|
? "bg-primary text-primary-foreground"
|
|
: "border border-border bg-card text-foreground",
|
|
)}
|
|
>
|
|
<span aria-hidden="true">{r.emoji}</span>
|
|
{r.count > 0 && (
|
|
<span
|
|
className={cn(
|
|
"text-sm font-bold tabular-nums",
|
|
active ? "text-primary-foreground" : "text-muted-foreground",
|
|
)}
|
|
>
|
|
{r.count}
|
|
</span>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
{reactionError && (
|
|
<p role="alert" className="px-1 text-sm font-semibold text-destructive">
|
|
{reactionError}
|
|
</p>
|
|
)}
|
|
|
|
{/* Hvem reagerte -- alltid synlig sammendrag (ikke bare hover/tap,
|
|
tilgjengelighetsregelen), utvidbart til full per-emoji-liste. */}
|
|
{allReactorNames.length > 0 && (
|
|
<div className="flex flex-col gap-1 px-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowReactors((v) => !v)}
|
|
aria-expanded={showReactors}
|
|
className="flex min-h-11 w-fit items-center text-left text-sm text-muted-foreground hover:text-foreground hover:underline"
|
|
>
|
|
{formatReactorSummary(allReactorNames)}
|
|
</button>
|
|
{showReactors && (
|
|
<ul className="flex flex-col gap-1">
|
|
{activeReactionGroups.map((r) => (
|
|
<li key={r.emoji} className="flex items-start gap-2 text-sm text-foreground">
|
|
<span aria-hidden="true">{r.emoji}</span>
|
|
<span>{r.reactors.join(", ")}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ------------------------ Expand / count ------------------------ */}
|
|
<div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={toggleExpanded}
|
|
aria-expanded={expanded}
|
|
className="h-11 gap-2 rounded-xl px-3 text-base font-bold text-foreground hover:bg-accent/50 active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<MessageSquare aria-hidden="true" className="size-5" />
|
|
Kommentarer ({commentCount})
|
|
</Button>
|
|
</div>
|
|
|
|
{/* ---------------------------- Thread ---------------------------- */}
|
|
{expanded && (
|
|
<div className="flex flex-col gap-4">
|
|
{loading && (
|
|
<div className="flex justify-center py-6">
|
|
<span
|
|
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
|
role="status"
|
|
aria-label="Laster kommentarer"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{threadError && !loading && (
|
|
<div className="flex flex-col gap-2">
|
|
<p role="alert" className="text-sm font-semibold text-destructive">
|
|
{threadError}
|
|
</p>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => void loadComments()}
|
|
className="h-11 w-fit rounded-xl text-base font-bold active:scale-[0.98]"
|
|
>
|
|
Prøv igjen
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !threadError && fetched && (
|
|
<>
|
|
{roots.length === 0 ? (
|
|
<p className="py-2 text-base text-muted-foreground">Ingen kommentarer ennå.</p>
|
|
) : (
|
|
<ul className="flex flex-col gap-4">
|
|
{roots.map((c) => (
|
|
<CommentNode
|
|
key={c.id}
|
|
comment={c}
|
|
depth={0}
|
|
childrenOf={childrenOf}
|
|
apiBase={apiBase}
|
|
roundId={roundId}
|
|
currentUserId={currentUserId}
|
|
canModerate={canModerate}
|
|
replyingTo={replyingTo}
|
|
setReplyingTo={setReplyingTo}
|
|
onAppend={appendComment}
|
|
onRemoveSubtree={removeSubtree}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{/* Bottom composer (top-level) */}
|
|
{loggedIn ? (
|
|
<CommentComposer
|
|
apiBase={apiBase}
|
|
roundId={roundId}
|
|
parentCommentId={null}
|
|
onPosted={appendComment}
|
|
autoFocus={false}
|
|
/>
|
|
) : (
|
|
<p className="text-base text-muted-foreground">Logg inn for å kommentere.</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Single comment (recursive) */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function CommentNode({
|
|
comment,
|
|
depth,
|
|
childrenOf,
|
|
apiBase,
|
|
roundId,
|
|
currentUserId,
|
|
canModerate,
|
|
replyingTo,
|
|
setReplyingTo,
|
|
onAppend,
|
|
onRemoveSubtree,
|
|
}: {
|
|
comment: CommentOut
|
|
depth: number
|
|
childrenOf: Map<string, CommentOut[]>
|
|
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<string | null>(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 (
|
|
<li
|
|
className={cn(indented && "border-l-2 border-border pl-4")}
|
|
>
|
|
<div className="flex flex-col gap-2">
|
|
{/* Author + timestamp */}
|
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
|
<span className="text-base font-bold text-foreground">{comment.author_display_name}</span>
|
|
<span className="text-sm tabular-nums text-muted-foreground">
|
|
{formatTimestamp(comment.created_at)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<p className="whitespace-pre-wrap break-words text-base leading-relaxed text-foreground">
|
|
<TaggedText body={comment.body} tags={comment.tags ?? []} />
|
|
</p>
|
|
|
|
{/* Actions */}
|
|
<div className="flex flex-wrap items-center gap-1">
|
|
{loggedIn && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => setReplyingTo(isReplyOpen ? null : comment.id)}
|
|
aria-expanded={isReplyOpen}
|
|
className="h-11 gap-1.5 rounded-xl px-3 text-sm font-bold text-muted-foreground hover:bg-accent/50 hover:text-foreground active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<Reply aria-hidden="true" className="size-4" />
|
|
Svar
|
|
</Button>
|
|
)}
|
|
|
|
{canDelete && !confirming && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => setConfirming(true)}
|
|
className="h-11 gap-1.5 rounded-xl px-3 text-sm font-bold text-destructive hover:bg-destructive/10 hover:text-destructive active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<Trash2 aria-hidden="true" className="size-4" />
|
|
Slett
|
|
</Button>
|
|
)}
|
|
|
|
{canDelete && confirming && (
|
|
<div className="flex flex-wrap items-center gap-1">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={handleDelete}
|
|
disabled={deleting}
|
|
className="h-11 gap-1.5 rounded-xl px-3 text-sm font-bold text-destructive hover:bg-destructive/10 hover:text-destructive active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<Trash2 aria-hidden="true" className="size-4" />
|
|
Sikker?
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => setConfirming(false)}
|
|
disabled={deleting}
|
|
className="h-11 rounded-xl px-3 text-sm font-bold text-foreground hover:bg-accent/50 active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
Avbryt
|
|
</Button>
|
|
{hasReplies && (
|
|
<p className="basis-full text-sm text-muted-foreground text-pretty">
|
|
Sletter du denne, forsvinner også svarene under.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{deleteError && (
|
|
<p role="alert" className="text-sm font-semibold text-destructive">
|
|
{deleteError}
|
|
</p>
|
|
)}
|
|
|
|
{/* Inline reply composer */}
|
|
{isReplyOpen && loggedIn && (
|
|
<CommentComposer
|
|
apiBase={apiBase}
|
|
roundId={roundId}
|
|
parentCommentId={comment.id}
|
|
autoFocus
|
|
onPosted={(c) => {
|
|
onAppend(c)
|
|
setReplyingTo(null)
|
|
}}
|
|
onCancel={() => setReplyingTo(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Children */}
|
|
{hasReplies && (
|
|
<ul className="mt-4 flex flex-col gap-4">
|
|
{children.map((child) => (
|
|
<CommentNode
|
|
key={child.id}
|
|
comment={child}
|
|
depth={cappedForChildren + 1}
|
|
childrenOf={childrenOf}
|
|
apiBase={apiBase}
|
|
roundId={roundId}
|
|
currentUserId={currentUserId}
|
|
canModerate={canModerate}
|
|
replyingTo={replyingTo}
|
|
setReplyingTo={setReplyingTo}
|
|
onAppend={onAppend}
|
|
onRemoveSubtree={onRemoveSubtree}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</li>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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<Tag[]>([])
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [error, setError] = useState<string | null>(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<HTMLTextAreaElement>) {
|
|
// 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 (
|
|
<div className="flex flex-col gap-2">
|
|
{roundId ? (
|
|
<MentionTextarea
|
|
roundId={roundId}
|
|
// eslint-disable-next-line jsx-a11y/no-autofocus -- intentional: composer opens on explicit user action
|
|
autoFocus={autoFocus}
|
|
value={body}
|
|
tags={tags}
|
|
onChange={(v, t) => {
|
|
setBody(v)
|
|
setTags(t)
|
|
}}
|
|
onKeyDownWhenClosed={handleComposerKeyDown}
|
|
rows={parentCommentId ? 2 : 3}
|
|
placeholder={parentCommentId ? "Skriv et svar … (@ for å tagge)" : "Skriv en kommentar … (@ for å tagge)"}
|
|
className={composerClassName}
|
|
/>
|
|
) : (
|
|
<textarea
|
|
// eslint-disable-next-line jsx-a11y/no-autofocus -- intentional: composer opens on explicit user action
|
|
autoFocus={autoFocus}
|
|
value={body}
|
|
onChange={(e) => setBody(e.target.value)}
|
|
onKeyDown={handleComposerKeyDown}
|
|
rows={parentCommentId ? 2 : 3}
|
|
placeholder={parentCommentId ? "Skriv et svar …" : "Skriv en kommentar …"}
|
|
className={composerClassName}
|
|
/>
|
|
)}
|
|
{error && (
|
|
<p role="alert" className="text-sm font-semibold text-destructive">
|
|
{error}
|
|
</p>
|
|
)}
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
onClick={() => void submit()}
|
|
disabled={empty || submitting}
|
|
className="h-11 rounded-xl px-5 text-base font-bold active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
{submitting ? "Sender …" : "Send"}
|
|
</Button>
|
|
{onCancel && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={onCancel}
|
|
disabled={submitting}
|
|
className="h-11 rounded-xl px-4 text-base font-bold text-muted-foreground hover:bg-accent/50 hover:text-foreground active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
Avbryt
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default PostEngagement
|