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>
238 lines
7.8 KiB
TypeScript
238 lines
7.8 KiB
TypeScript
"use client"
|
|
|
|
// @-tagging (ADR-063) -- delt UI mellom innleggs-komposereren
|
|
// (round-messages.tsx) og kommentar-komposereren (post-engagement.tsx).
|
|
// Selve logikken (offset-regning, tekst-diffing) er bevisst flyttet ut i
|
|
// lib/mentions.ts og enhetstestet der -- denne fila er ren presentasjon
|
|
// og DOM-/fokushåndtering.
|
|
|
|
import type React from "react"
|
|
import Link from "next/link"
|
|
import { useEffect, useRef, useState } from "react"
|
|
import { cn } from "@/lib/utils"
|
|
import {
|
|
adjustTagsForEdit,
|
|
detectActiveMention,
|
|
fetchTaggablePeople,
|
|
insertMention,
|
|
segmentTaggedText,
|
|
type Tag,
|
|
type TaggablePerson,
|
|
} from "@/lib/mentions"
|
|
|
|
type MentionTextareaProps = {
|
|
roundId: string
|
|
value: string
|
|
tags: Tag[]
|
|
onChange: (value: string, tags: Tag[]) => void
|
|
placeholder?: string
|
|
rows?: number
|
|
id?: string
|
|
className?: string
|
|
autoFocus?: boolean
|
|
// Videresendes KUN når dropdownen ikke er åpen -- Enter/piltaster skal
|
|
// styre forslagslisten når den vises, ikke utløse f.eks. avsenderens
|
|
// egen Cmd/Ctrl+Enter-innsending samtidig (post-engagement.tsx).
|
|
onKeyDownWhenClosed?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
|
}
|
|
|
|
export function MentionTextarea({
|
|
roundId,
|
|
value,
|
|
tags,
|
|
onChange,
|
|
placeholder,
|
|
rows = 3,
|
|
id,
|
|
className,
|
|
autoFocus,
|
|
onKeyDownWhenClosed,
|
|
}: MentionTextareaProps) {
|
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
|
const [mention, setMention] = useState<{ start: number; query: string } | null>(null)
|
|
const [suggestions, setSuggestions] = useState<TaggablePerson[]>([])
|
|
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
|
const [highlighted, setHighlighted] = useState(0)
|
|
// Markørposisjonen som stod da @-forsøket sist ble oppdatert -- trengs
|
|
// for å vite hvor SLUTTEN av "@søketekst" er ved faktisk innsetting
|
|
// (markøren kan ha rukket å flytte seg videre siden selve deteksjonen).
|
|
const cursorAtDetectionRef = useRef(0)
|
|
const pendingCursorRef = useRef<number | null>(null)
|
|
|
|
const open = mention !== null
|
|
|
|
// Debouncet henting av kandidater -- kun mens dropdownen faktisk er åpen.
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setSuggestions([])
|
|
return
|
|
}
|
|
let cancelled = false
|
|
setLoadingSuggestions(true)
|
|
const handle = setTimeout(async () => {
|
|
const people = await fetchTaggablePeople(roundId, mention.query)
|
|
if (!cancelled) {
|
|
setSuggestions(people)
|
|
setHighlighted(0)
|
|
setLoadingSuggestions(false)
|
|
}
|
|
}, 200)
|
|
return () => {
|
|
cancelled = true
|
|
clearTimeout(handle)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- kun query/roundId skal trigge ny henting
|
|
}, [open, mention?.query, roundId])
|
|
|
|
// Sett markøren riktig sted ETTER at React har rendret den nye,
|
|
// lengre/kortere verdien -- kan ikke gjøres synkront i onChange, siden
|
|
// <textarea>s DOM-verdi ikke er oppdatert med den nye teksten ennå der.
|
|
useEffect(() => {
|
|
if (pendingCursorRef.current !== null && textareaRef.current) {
|
|
textareaRef.current.setSelectionRange(pendingCursorRef.current, pendingCursorRef.current)
|
|
pendingCursorRef.current = null
|
|
}
|
|
}, [value])
|
|
|
|
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
|
const newValue = e.target.value
|
|
const cursor = e.target.selectionStart
|
|
const adjustedTags = adjustTagsForEdit(value, newValue, tags)
|
|
onChange(newValue, adjustedTags)
|
|
|
|
const active = detectActiveMention(newValue, cursor)
|
|
cursorAtDetectionRef.current = cursor
|
|
setMention(active)
|
|
}
|
|
|
|
function selectSuggestion(person: TaggablePerson) {
|
|
if (!mention || !textareaRef.current) return
|
|
const { body: newBody, tag, cursor } = insertMention(value, mention.start, cursorAtDetectionRef.current, person)
|
|
onChange(newBody, [...tags, tag])
|
|
setMention(null)
|
|
pendingCursorRef.current = cursor
|
|
textareaRef.current.focus()
|
|
}
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
|
if (open && suggestions.length > 0) {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault()
|
|
setHighlighted((i) => (i + 1) % suggestions.length)
|
|
return
|
|
}
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault()
|
|
setHighlighted((i) => (i - 1 + suggestions.length) % suggestions.length)
|
|
return
|
|
}
|
|
if (e.key === "Enter" || e.key === "Tab") {
|
|
e.preventDefault()
|
|
selectSuggestion(suggestions[highlighted])
|
|
return
|
|
}
|
|
if (e.key === "Escape") {
|
|
e.preventDefault()
|
|
setMention(null)
|
|
return
|
|
}
|
|
} else if (open && e.key === "Escape") {
|
|
e.preventDefault()
|
|
setMention(null)
|
|
return
|
|
}
|
|
onKeyDownWhenClosed?.(e)
|
|
}
|
|
|
|
return (
|
|
<div className="relative">
|
|
<textarea
|
|
ref={textareaRef}
|
|
id={id}
|
|
autoFocus={autoFocus}
|
|
value={value}
|
|
onChange={handleChange}
|
|
onKeyDown={handleKeyDown}
|
|
onBlur={() => {
|
|
// Liten forsinkelse: en mouseDown på et forslag skal rekke å
|
|
// trigge FØR blur lukker listen (ellers mistes klikket).
|
|
window.setTimeout(() => setMention(null), 150)
|
|
}}
|
|
placeholder={placeholder}
|
|
rows={rows}
|
|
className={className}
|
|
/>
|
|
|
|
{open && (
|
|
<div
|
|
role="listbox"
|
|
aria-label="Forslag til tagging"
|
|
className="absolute left-0 right-0 top-full z-20 mt-1 max-h-60 overflow-y-auto rounded-xl border border-border bg-card shadow-lg"
|
|
>
|
|
{loadingSuggestions ? (
|
|
<div className="flex items-center justify-center p-3">
|
|
<span
|
|
aria-hidden="true"
|
|
className="size-5 animate-spin rounded-full border-2 border-primary/20 border-t-primary"
|
|
/>
|
|
<span className="sr-only">Søker...</span>
|
|
</div>
|
|
) : suggestions.length === 0 ? (
|
|
<p className="p-3 text-sm text-muted-foreground">Ingen treff</p>
|
|
) : (
|
|
<ul>
|
|
{suggestions.map((person, i) => (
|
|
<li key={person.user_id}>
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
aria-selected={i === highlighted}
|
|
onMouseDown={(e) => {
|
|
// preventDefault: unngår at textarea mister fokus
|
|
// FØR onClick rekker å kjøre.
|
|
e.preventDefault()
|
|
selectSuggestion(person)
|
|
}}
|
|
onMouseEnter={() => setHighlighted(i)}
|
|
className={cn(
|
|
"flex min-h-11 w-full items-center px-3 text-left text-base text-foreground",
|
|
i === highlighted ? "bg-accent" : "hover:bg-accent/50",
|
|
)}
|
|
>
|
|
{person.display_name}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Rendrer body-tekst med taggede utsnitt som lenker til vedkommendes
|
|
* profil (`/my-friends/{id}`) -- tydelig annerledes enn vanlig tekst,
|
|
* bevisst UTEN understrek (ligger for tett inntil resten av teksten
|
|
* visuelt sett til at en vanlig lenkestil er lesbar der). */
|
|
export function TaggedText({ body, tags, className }: { body: string; tags: Tag[]; className?: string }) {
|
|
if (tags.length === 0) return <span className={className}>{body}</span>
|
|
const segments = segmentTaggedText(body, tags)
|
|
return (
|
|
<span className={className}>
|
|
{segments.map((seg, i) =>
|
|
seg.tag ? (
|
|
<Link
|
|
key={i}
|
|
href={`/my-friends/${seg.tag.user_id}`}
|
|
className="font-semibold text-primary no-underline hover:underline"
|
|
>
|
|
{seg.text}
|
|
</Link>
|
|
) : (
|
|
<span key={i}>{seg.text}</span>
|
|
),
|
|
)}
|
|
</span>
|
|
)
|
|
}
|