teecup/frontend/components/post-engagement.tsx
2026-08-07 22:02:56 +02:00

674 lines
23 KiB
TypeScript

"use client"
import { useCallback, useMemo, useState } from "react"
import { MessageSquare, Reply, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
/* ------------------------------------------------------------------ */
/* 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
}
export type PostEngagementProps = {
/** Fully-resolved base path, e.g. "/rounds/{id}/messages/{id}". This
* component appends "/reaction" and "/comments". */
apiBase: 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,
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}
currentUserId={currentUserId}
canModerate={canModerate}
replyingTo={replyingTo}
setReplyingTo={setReplyingTo}
onAppend={appendComment}
onRemoveSubtree={removeSubtree}
/>
))}
</ul>
)}
{/* Bottom composer (top-level) */}
{loggedIn ? (
<CommentComposer
apiBase={apiBase}
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,
currentUserId,
canModerate,
replyingTo,
setReplyingTo,
onAppend,
onRemoveSubtree,
}: {
comment: CommentOut
depth: number
childrenOf: Map<string, CommentOut[]>
apiBase: 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">
{comment.body}
</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}
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}
currentUserId={currentUserId}
canModerate={canModerate}
replyingTo={replyingTo}
setReplyingTo={setReplyingTo}
onAppend={onAppend}
onRemoveSubtree={onRemoveSubtree}
/>
))}
</ul>
)}
</li>
)
}
/* ------------------------------------------------------------------ */
/* Composer */
/* ------------------------------------------------------------------ */
function CommentComposer({
apiBase,
parentCommentId,
onPosted,
onCancel,
autoFocus,
}: {
apiBase: string
parentCommentId: string | null
onPosted: (c: CommentOut) => void
onCancel?: () => void
autoFocus?: boolean
}) {
const [body, setBody] = useState("")
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 } : {}),
}),
})
if (!res.ok) {
setError(await readError(res))
return
}
const created = (await res.json()) as CommentOut
onPosted(created)
setBody("")
} catch {
setError(GENERIC_ERROR)
} finally {
setSubmitting(false)
}
}, [apiBase, body, empty, onPosted, parentCommentId, submitting])
return (
<div className="flex flex-col gap-2">
<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={(e) => {
// 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()
}
}}
rows={parentCommentId ? 2 : 3}
placeholder={parentCommentId ? "Skriv et svar …" : "Skriv en kommentar …"}
className="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"
/>
{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