From 3bb89226d474793ee7006198b7c1b68e9ceb391d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 13:15:18 +0000 Subject: [PATCH] Card tags on one line with a +N popover; click a tag to search it - Card view: tags are a single row of whole chips. Anything that would wrap is clipped and counted in a "+N" circle at the bottom right; clicking it opens a popover listing every tag (opens upward, closes on outside click/Escape/scroll). - Fix the cause of the squeezed title: the title/dates/header are now non-shrinkable in the fixed-height card, so only the preview gives way and a two-line title is never cut off, whatever the tags do. - Clicking any tag (card, popover, or list view) searches tags:"". That exact form runs as a direct tag filter, so it also works with no AI provider configured (the keyword fallback only searches titles/bodies). Partial tag matches are fine, per the backend's existing substring match. - TagChip gets a clickTitle prop so its tooltip fits the caller. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_016UqmgUYdnBJ6ahmmJmWcNc --- frontend/src/components/NoteCard.tsx | 182 +++++++++++++++++++---- frontend/src/components/TagChip.tsx | 6 +- frontend/src/services/smartQuery.test.ts | 35 +++++ frontend/src/services/smartQuery.ts | 13 ++ frontend/src/views/ListView.tsx | 21 ++- 5 files changed, 223 insertions(+), 34 deletions(-) create mode 100644 frontend/src/services/smartQuery.test.ts diff --git a/frontend/src/components/NoteCard.tsx b/frontend/src/components/NoteCard.tsx index cf92ce9..336a7b6 100644 --- a/frontend/src/components/NoteCard.tsx +++ b/frontend/src/components/NoteCard.tsx @@ -1,3 +1,4 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { Pin, Globe, CheckCircle2, MoreHorizontal, Trash2, RotateCcw, type LucideIcon } from 'lucide-react' import { createPortal } from 'react-dom' import type { NoteListItem } from '@/api/notes' @@ -96,6 +97,147 @@ function NoteMenu({ noteId, inArchive, hasImage, onArchive, onRestore, onDeleteP ) } +// Tags on a card: a single line of whole chips. Whatever wraps past the first line is +// clipped, so a long title and preview are never squeezed by a second row of tags; the +// clipped ones are counted in a "+N" badge whose popover lists every tag. Clicking any +// tag searches for it. +interface CardTagsProps { + tags: string[] + hasImage: boolean + onTagClick?: (tag: string) => void +} + +function CardTags({ tags, hasImage, onTagClick }: CardTagsProps) { + const rowRef = useRef(null) + const badgeRef = useRef(null) + const popRef = useRef(null) + const [hidden, setHidden] = useState(0) + const [pos, setPos] = useState<{ right: number; bottom: number; maxHeight: number } | null>(null) + + // The row is exactly one chip tall with overflow hidden, so chips that wrap onto a + // later line are the ones clipped: count those whose top is below the first chip's. + useLayoutEffect(() => { + const row = rowRef.current + if (!row) return + const measure = () => { + const chips = Array.from(row.children) as HTMLElement[] + const firstTop = chips[0]?.offsetTop ?? 0 + setHidden(chips.filter((c) => c.offsetTop > firstTop).length) + } + measure() + // The row narrows when the badge appears and chips resize when fonts load. + const ro = new ResizeObserver(measure) + ro.observe(row) + Array.from(row.children).forEach((c) => ro.observe(c)) + return () => ro.disconnect() + }, [tags]) + + // Dismiss the popover on an outside press, Escape, page scroll or resize (it is + // fixed-positioned, so it would otherwise drift away from its badge). + useEffect(() => { + if (!pos) return + const close = () => setPos(null) + const onDown = (e: MouseEvent) => { + const t = e.target as Node + if (!popRef.current?.contains(t) && !badgeRef.current?.contains(t)) close() + } + const onScroll = (e: Event) => { if (!popRef.current?.contains(e.target as Node)) close() } + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close() } + document.addEventListener('mousedown', onDown) + document.addEventListener('keydown', onKey) + window.addEventListener('scroll', onScroll, true) + window.addEventListener('resize', close) + return () => { + document.removeEventListener('mousedown', onDown) + document.removeEventListener('keydown', onKey) + window.removeEventListener('scroll', onScroll, true) + window.removeEventListener('resize', close) + } + }, [pos]) + + function toggleAll(e: React.MouseEvent) { + e.stopPropagation() + if (pos || !badgeRef.current) { setPos(null); return } + const rect = badgeRef.current.getBoundingClientRect() + // Open upward: the badge sits at the card's bottom edge, often near the viewport's. + setPos({ + right: Math.max(8, window.innerWidth - rect.right), + bottom: window.innerHeight - rect.top + 4, + maxHeight: Math.max(96, Math.min(192, rect.top - 12)), + }) + } + + const chipClass = `text-xs px-1.5 py-0.5 rounded-full max-w-full truncate ${ + hasImage ? '' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' + }` + const chipStyle = hasImage + ? { backgroundColor: 'rgba(255,255,255,0.25)', color: 'white', textShadow } + : undefined + + return ( +
+
+ {tags.map((tag) => + onTagClick ? ( + + ) : ( + #{tag} + ), + )} +
+ {hidden > 0 && ( + + )} + {pos && createPortal( +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + {tags.map((tag) => ( + { setPos(null); onTagClick(t) } : undefined} + /> + ))} +
, + document.body, + )} +
+ ) +} + interface Props { note: NoteListItem category?: Category @@ -109,10 +251,11 @@ interface Props { onArchive?: (id: string) => void onRestore?: (id: string) => void onDeletePermanent?: (id: string) => void + /** Clicking a tag chip (card or list) — the list view turns it into a search. */ + onTagClick?: (tag: string) => void } -export default function NoteCard({ note, category, onClick, onPin, selected = false, onToggleSelect, onShareClick, viewMode = 'list', inArchive = false, onArchive, onRestore, onDeletePermanent }: Props) { - const visibleTags = note.tags.slice(0, 3) +export default function NoteCard({ note, category, onClick, onPin, selected = false, onToggleSelect, onShareClick, viewMode = 'list', inArchive = false, onArchive, onRestore, onDeletePermanent, onTagClick }: Props) { function handleShareClick(e: React.MouseEvent) { e.stopPropagation() @@ -157,9 +300,10 @@ export default function NoteCard({ note, category, onClick, onPin, selected = fa )} {/* Content column, top to bottom: category + actions, dates, title, preview, and - the tags pinned to the bottom edge. */} + the tags pinned to the bottom edge. Only the preview may give way when space is + short — the title always keeps its two lines. */}
-
+
{category ? @@ -214,7 +358,7 @@ export default function NoteCard({ note, category, onClick, onPin, selected = fa {/* Dates sit on their own line under the header so both fit on a narrow card. */}
Created {relativeDate(note.created_at)} @@ -223,7 +367,7 @@ export default function NoteCard({ note, category, onClick, onPin, selected = fa

{note.title || 'Untitled'} @@ -234,29 +378,7 @@ export default function NoteCard({ note, category, onClick, onPin, selected = fa > {note.content_preview || 'No content'}

- {visibleTags.length > 0 && ( -
- {visibleTags.map((tag) => ( - - #{tag} - - ))} - {note.tags.length > 3 && ( - - +{note.tags.length - 3} - - )} -
- )} + {note.tags.length > 0 && }

) @@ -332,7 +454,7 @@ export default function NoteCard({ note, category, onClick, onPin, selected = fa

{note.content_preview || 'No content'}

{note.tags.length > 0 && (
- {note.tags.slice(0, 4).map((tag) => )} + {note.tags.slice(0, 4).map((tag) => )} {note.tags.length > 4 && +{note.tags.length - 4} more}
)} diff --git a/frontend/src/components/TagChip.tsx b/frontend/src/components/TagChip.tsx index 590ce98..f9692ae 100644 --- a/frontend/src/components/TagChip.tsx +++ b/frontend/src/components/TagChip.tsx @@ -3,16 +3,18 @@ interface Props { removable?: boolean onRemove?: (tag: string) => void onClick?: (tag: string) => void + /** Tooltip for the clickable tag; what clicking does depends on the caller. */ + clickTitle?: string } -export default function TagChip({ tag, removable, onRemove, onClick }: Props) { +export default function TagChip({ tag, removable, onRemove, onClick, clickTitle = 'Copy tag' }: Props) { return ( {onClick ? (