From aaec9c811d0216379efa056f341a4486f3314fa8 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Tue, 11 Aug 2026 10:15:02 +0200 Subject: [PATCH] @-tagging (frontend): egen-implementert etter V0-credits tomme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/routers/round_messages.py | 3 + frontend/components/feed.tsx | 6 +- frontend/components/mention-input.tsx | 238 ++++++++++++++++++++++++ frontend/components/post-engagement.tsx | 93 ++++++--- frontend/components/round-messages.tsx | 20 +- frontend/lib/mentions.test.ts | 121 ++++++++++++ frontend/lib/mentions.ts | 126 +++++++++++++ 7 files changed, 578 insertions(+), 29 deletions(-) create mode 100644 frontend/components/mention-input.tsx create mode 100644 frontend/lib/mentions.test.ts create mode 100644 frontend/lib/mentions.ts diff --git a/app/routers/round_messages.py b/app/routers/round_messages.py index 6b3a2f3..d0312c1 100644 --- a/app/routers/round_messages.py +++ b/app/routers/round_messages.py @@ -639,6 +639,7 @@ class FeedEntryOut(BaseModel): created_at: str reactions: list[ReactionSummary] = [] comment_count: int = 0 + tags: list[TagOut] = [] @router.get("/feed", response_model=list[FeedEntryOut]) @@ -703,6 +704,7 @@ async def get_feed( entry_ids = [r["id"] for r in rows] reactions_by_id = await _round_message_reactions(conn, entry_ids, user.user_id) comment_counts = await _round_message_comment_counts(conn, entry_ids) + tags_by_id = await _tags_for_messages(conn, "round_message_tag", "round_message_id", entry_ids) return [ FeedEntryOut( id=r["id"], @@ -718,6 +720,7 @@ async def get_feed( created_at=r["created_at"].isoformat(), reactions=reactions_by_id.get(r["id"], []), comment_count=comment_counts.get(r["id"], 0), + tags=tags_by_id.get(r["id"], []), ) for r in rows ] diff --git a/frontend/components/feed.tsx b/frontend/components/feed.tsx index 14a46c6..2e5257d 100644 --- a/frontend/components/feed.tsx +++ b/frontend/components/feed.tsx @@ -5,6 +5,8 @@ import Link from "next/link" import { cn } from "@/lib/utils" import { PostEngagement, type ReactionSummary } from "./post-engagement" +import { TaggedText } from "./mention-input" +import type { Tag } from "@/lib/mentions" import { BottomNav } from "@/components/teecup/bottom-nav" /** Rå feed-oppføring fra backend. */ @@ -22,6 +24,7 @@ export type ApiFeedEntry = { created_at: string // ISO 8601 reactions: ReactionSummary[] comment_count: number + tags: Tag[] } const PAGE_SIZE = 20 @@ -232,13 +235,14 @@ function FeedCard({ entry, currentUserId }: { entry: ApiFeedEntry; currentUserId {/* Tekst */} {entry.body && (

- {entry.body} +

)} 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) => void +} + +export function MentionTextarea({ + roundId, + value, + tags, + onChange, + placeholder, + rows = 3, + id, + className, + autoFocus, + onKeyDownWhenClosed, +}: MentionTextareaProps) { + const textareaRef = useRef(null) + const [mention, setMention] = useState<{ start: number; query: string } | null>(null) + const [suggestions, setSuggestions] = useState([]) + 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(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 + //