Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 152 additions & 30 deletions frontend/src/components/NoteCard.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<HTMLDivElement>(null)
const badgeRef = useRef<HTMLButtonElement>(null)
const popRef = useRef<HTMLDivElement>(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 (
<div className="flex items-start gap-1 mt-auto pt-1.5 shrink-0">
<div ref={rowRef} className="flex flex-wrap gap-1 flex-1 min-w-0 h-5 overflow-hidden">
{tags.map((tag) =>
onTagClick ? (
<button
key={tag}
type="button"
title="Search this tag"
className={`${chipClass} hover:underline`}
style={chipStyle}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); onTagClick(tag) }}
>
#{tag}
</button>
) : (
<span key={tag} className={chipClass} style={chipStyle}>#{tag}</span>
),
)}
</div>
{hidden > 0 && (
<button
ref={badgeRef}
type="button"
title={`Show all ${tags.length} tags`}
aria-label={`Show all ${tags.length} tags`}
aria-expanded={pos !== null}
className={`shrink-0 h-5 min-w-5 px-1 rounded-full text-[10px] font-semibold leading-5 text-center ${
hasImage ? '' : 'bg-gray-200 text-gray-600 hover:bg-gray-300 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500'
}`}
style={hasImage ? { backgroundColor: 'rgba(255,255,255,0.35)', color: 'white', textShadow } : undefined}
onPointerDown={(e) => e.stopPropagation()}
onClick={toggleAll}
>
+{hidden}
</button>
)}
{pos && createPortal(
<div
ref={popRef}
role="dialog"
aria-label="All tags"
className="fixed z-50 w-64 max-w-[calc(100vw-1rem)] overflow-y-auto rounded-xl border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-700 dark:bg-gray-800 flex flex-wrap gap-1"
style={{ right: pos.right, bottom: pos.bottom, maxHeight: pos.maxHeight }}
// A portal's events still bubble through React to the card, which would open the note.
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{tags.map((tag) => (
<TagChip
key={tag}
tag={tag}
clickTitle="Search this tag"
onClick={onTagClick ? (t) => { setPos(null); onTagClick(t) } : undefined}
/>
))}
</div>,
document.body,
)}
</div>
)
}

interface Props {
note: NoteListItem
category?: Category
Expand All @@ -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()
Expand Down Expand Up @@ -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. */}
<div className="relative z-10 flex flex-col flex-1 min-h-0 px-3 pt-3 pb-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center justify-between gap-2 shrink-0">
<div className="min-w-0" style={hasImage ? { filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.5))' } : undefined}>
{category
? <CategoryBadge category={category} />
Expand Down Expand Up @@ -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. */}
<div
className="flex flex-wrap items-center gap-x-2 text-[11px] leading-tight mt-1 text-gray-500 dark:text-gray-400"
className="flex flex-wrap items-center gap-x-2 text-[11px] leading-tight mt-1 shrink-0 text-gray-500 dark:text-gray-400"
style={hasImage ? { color: 'white', textShadow } : undefined}
>
<span title={new Date(note.created_at).toLocaleString()}>Created {relativeDate(note.created_at)}</span>
Expand All @@ -223,7 +367,7 @@ export default function NoteCard({ note, category, onClick, onPin, selected = fa
</div>

<h3
className="font-semibold text-sm leading-tight mt-2 mb-1 line-clamp-2 text-gray-900 dark:text-gray-100"
className="font-semibold text-sm leading-tight mt-2 mb-1 shrink-0 line-clamp-2 text-gray-900 dark:text-gray-100"
style={hasImage ? { color: 'white', textShadow } : undefined}
>
{note.title || 'Untitled'}
Expand All @@ -234,29 +378,7 @@ export default function NoteCard({ note, category, onClick, onPin, selected = fa
>
{note.content_preview || 'No content'}
</p>
{visibleTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-auto pt-1.5">
{visibleTags.map((tag) => (
<span
key={tag}
className={`text-xs px-1.5 py-0.5 rounded-full ${hasImage ? '' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'}`}
style={hasImage
? { backgroundColor: 'rgba(255,255,255,0.25)', color: 'white', textShadow }
: undefined}
>
#{tag}
</span>
))}
{note.tags.length > 3 && (
<span
className={`text-xs px-1 ${hasImage ? '' : 'text-gray-400 dark:text-gray-500'}`}
style={hasImage ? { color: 'rgba(255,255,255,0.7)' } : undefined}
>
+{note.tags.length - 3}
</span>
)}
</div>
)}
{note.tags.length > 0 && <CardTags tags={note.tags} hasImage={hasImage} onTagClick={onTagClick} />}
</div>
</div>
)
Expand Down Expand Up @@ -332,7 +454,7 @@ export default function NoteCard({ note, category, onClick, onPin, selected = fa
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-4 mb-2">{note.content_preview || 'No content'}</p>
{note.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{note.tags.slice(0, 4).map((tag) => <TagChip key={tag} tag={tag} />)}
{note.tags.slice(0, 4).map((tag) => <TagChip key={tag} tag={tag} onClick={onTagClick} clickTitle="Search this tag" />)}
{note.tags.length > 4 && <span className="text-xs text-gray-400 px-1">+{note.tags.length - 4} more</span>}
</div>
)}
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/components/TagChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-gray-100 text-gray-700 border border-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600">
{onClick ? (
<button
type="button"
className="cursor-pointer hover:underline focus:outline-none"
title="Copy tag"
title={clickTitle}
onClick={(e) => { e.stopPropagation(); onClick(tag) }}
>
#{tag}
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/services/smartQuery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Clicking a tag searches for `tags:"name"`. That query must round-trip back to the tag
* so it can run as a direct tag filter, and must not swallow anything more elaborate —
* those still go to the AI.
*/

import { describe, expect, it } from 'vitest'
import { parseTagSearchQuery, tagSearchQuery } from './smartQuery'

describe('tag search query', () => {
it('puts the tag name in quotes', () => {
expect(tagSearchQuery('confocal-raman')).toBe('tags:"confocal-raman"')
})

it('round-trips, including spaces and non-ASCII', () => {
for (const tag of ['confocal-raman', 'to do', 'café']) {
expect(parseTagSearchQuery(tagSearchQuery(tag))).toBe(tag)
}
})

it('drops stray quotes so the query stays parseable', () => {
expect(parseTagSearchQuery(tagSearchQuery('say "hi"'))).toBe('say hi')
})

it('tolerates surrounding whitespace and any case for the operator', () => {
expect(parseTagSearchQuery(' TAGS:"Animals" ')).toBe('Animals')
})

it('leaves anything more than a lone quoted tag to the AI', () => {
expect(parseTagSearchQuery('tags:Animals')).toBeNull()
expect(parseTagSearchQuery('tags:"a" and category:Ideas')).toBeNull()
expect(parseTagSearchQuery('tags:""')).toBeNull()
expect(parseTagSearchQuery('notes about tags')).toBeNull()
})
})
13 changes: 13 additions & 0 deletions frontend/src/services/smartQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ import type { NoteSearchFilter, AnnualRange } from '@/api/notes'

export interface SmartQueryCategory { id: string; label: string }

/** The search-box query for "every note with this tag" — what clicking a tag produces. */
export function tagSearchQuery(tag: string): string {
return `tags:"${tag.replace(/"/g, '')}"`
}

/** The tag if `query` is exactly a `tags:"name"` search (see tagSearchQuery), else null.
* Such a query is precise syntax, so it runs as a plain tag filter with no AI round
* trip — and still works when no AI provider is configured. */
export function parseTagSearchQuery(query: string): string | null {
const m = query.trim().match(/^tags:"([^"]+)"$/i)
return m ? m[1] : null
}

export const SMART_QUERY_INSTRUCTIONS = `You turn a note-search query into a structured JSON filter. The query may be natural language (e.g. "find notes from the first week of January about the new year") or advanced syntax (e.g. "tags:Animals", "date:2026-02-02 and category:Ideas"), or a mix. Output ONLY a single JSON object matching this shape (every field is optional — omit ones that don't apply):

{
Expand Down
21 changes: 19 additions & 2 deletions frontend/src/views/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ import { importUrlApi, type UrlExtractResult } from '@/api/importUrl'
import { resolveFolderIcon } from '@/utils/folderIcons'
import { indexById, findArchiveFolder, isInArchive, ancestorIds, folderMenuTarget, type FolderMenuTarget } from '@/utils/folderTree'
import { notesApi } from '@/api/notes'
import type { NoteListItem } from '@/api/notes'
import type { NoteListItem, NoteSearchFilter } from '@/api/notes'
import type { Folder } from '@/api/folders'
import { generateNoteFilter } from '@/services/smartQuery'
import { generateNoteFilter, tagSearchQuery, parseTagSearchQuery } from '@/services/smartQuery'

type ViewMode = 'list' | 'card'

Expand Down Expand Up @@ -252,6 +252,17 @@ export default function ListView() {
clearSelection()
setDeepLoading(true)
try {
// A lone `tags:"name"` (what clicking a tag produces) is exact syntax: filter by
// that tag directly rather than asking the AI, so it also works with no AI set up
// (the keyword fallback below only looks at titles and bodies, never tags).
const tag = parseTagSearchQuery(query)
if (tag) {
const filter: NoteSearchFilter = { tags: [tag] }
if (activeCategoryId) filter.category_ids = [activeCategoryId]
const result = await notesApi.smartSearch(filter)
setDeepResults(result.data)
return
}
if (aiService) {
try {
const filter = await generateNoteFilter(aiService, { query, categories })
Expand Down Expand Up @@ -292,6 +303,11 @@ export default function ListView() {
setUrlQuery(folder.search_query ?? null)
}

// Clicking a tag on a note searches every note with that tag (partial matches count).
function searchByTag(tag: string) {
setUrlQuery(tagSearchQuery(tag))
}

function toggleSelect(id: string) {
setSelectedIds((prev) => {
const next = new Set(prev)
Expand Down Expand Up @@ -660,6 +676,7 @@ export default function ListView() {
onArchive={handleArchiveNote}
onRestore={handleRestoreNote}
onDeletePermanent={handleDeleteNotePermanent}
onTagClick={searchByTag}
/>
</DraggableNote>
))
Expand Down
Loading