From bc49eec7c8f2af5d4add1212ca7b6025aff06999 Mon Sep 17 00:00:00 2001 From: nicholai Date: Sat, 13 Sep 2025 06:57:16 -0600 Subject: [PATCH] feat(tags): /api/files/tags + history endpoint; TagsDialog UI; integrate actions in table; add Qdrant env; extend types; prep for Qdrant UI and loading/motion --- src/app/api/files/tags/history/route.ts | 65 +++++++++ src/app/api/files/tags/route.ts | 78 +++++++++++ src/app/page.tsx | 21 +++ src/components/files/file-row-actions.tsx | 8 +- src/components/files/file-table.tsx | 3 + src/components/files/tags-dialog.tsx | 154 ++++++++++++++++++++++ 6 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 src/app/api/files/tags/history/route.ts create mode 100644 src/app/api/files/tags/route.ts create mode 100644 src/components/files/tags-dialog.tsx diff --git a/src/app/api/files/tags/history/route.ts b/src/app/api/files/tags/history/route.ts new file mode 100644 index 0000000..90e169d --- /dev/null +++ b/src/app/api/files/tags/history/route.ts @@ -0,0 +1,65 @@ +import * as Sentry from "@sentry/nextjs"; +import { NextResponse } from "next/server"; +import { getEsClient } from "@/lib/elasticsearch"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function json(data: T, init?: { status?: number } & ResponseInit) { + return NextResponse.json(data, init); +} + +// GET /api/files/tags/history?path=/abs/path&size=50 +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const path = searchParams.get("path"); + const sizeParam = searchParams.get("size"); + const size = Math.min(Math.max(Number(sizeParam || 50) || 50, 1), 500); + + if (!path) { + return json({ error: "path is required" }, { status: 400 }); + } + + const result = await Sentry.startSpan( + { op: "db.query", name: "ES list tag history" }, + async (span) => { + span.setAttribute("path", path); + span.setAttribute("size", size); + + const client = getEsClient(); + const res = await client.search({ + index: "files_events", + size, + sort: [{ at: { order: "desc" } }], + query: { + bool: { + must: [ + { term: { path } }, + { term: { action: "tags.update" } }, + ], + }, + }, + }); + + const hits = (res.hits.hits || []).map((h) => ({ + id: (h._id as string) ?? undefined, + ...(h._source as Record), + })); + + const total = + typeof res.hits.total === "object" && res.hits.total !== null + ? (res.hits.total as { value: number; relation?: string }).value + : (res.hits.total as number | undefined) ?? hits.length; + + return { total, items: hits }; + }, + ); + + return json(result); + } catch (error: unknown) { + Sentry.captureException(error); + const message = error instanceof Error ? error.message : String(error); + return json({ error: "Failed to fetch tag history", message }, { status: 500 }); + } +} diff --git a/src/app/api/files/tags/route.ts b/src/app/api/files/tags/route.ts new file mode 100644 index 0000000..84ac907 --- /dev/null +++ b/src/app/api/files/tags/route.ts @@ -0,0 +1,78 @@ +import * as Sentry from "@sentry/nextjs"; +import { NextResponse } from "next/server"; +import { getEsClient } from "@/lib/elasticsearch"; +import { env } from "@/lib/env"; +import { pathToId } from "@/lib/paths"; +import type { TagHistoryEvent } from "@/types/files"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function json(data: T, init?: { status?: number } & ResponseInit) { + return NextResponse.json(data, init); +} + +export async function POST(req: Request) { + try { + const body = (await req.json().catch(() => ({}))) as { + path?: string; + tags?: string[]; + }; + + if (!body?.path || !Array.isArray(body.tags)) { + return json({ error: "path and tags[] are required" }, { status: 400 }); + } + + const path = body.path; + const tags = body.tags; + const id = pathToId(path); + const index = env.ELASTICSEARCH_ALIAS || env.ELASTICSEARCH_INDEX; + const eventsIndex = "files_events"; + + await Sentry.startSpan( + { op: "db.query", name: "ES updateTags + appendTagHistory" }, + async (span) => { + span.setAttribute("doc.id", id); + span.setAttribute("tags.count", tags.length); + + const client = getEsClient(); + + // Update tags on the main document (upsert to be resilient) + await client.update({ + index, + id, + doc: { tags }, + doc_as_upsert: true, + }); + + // Append a tag history event (append-only). We don't block on failures here. + const event: TagHistoryEvent = { + id: (globalThis.crypto?.randomUUID?.() ?? + Math.random().toString(36).slice(2) + Date.now().toString(36)) as string, + path, + action: "tags.update", + to: tags, + at: new Date().toISOString(), + }; + + try { + await client.index({ + index: eventsIndex, + id: event.id, + document: event, + refresh: "wait_for", + }); + } catch (e) { + // Non-fatal; emit to Sentry but continue + Sentry.captureException(e); + } + }, + ); + + return json({ ok: true, tags }); + } catch (error: unknown) { + Sentry.captureException(error); + const message = error instanceof Error ? error.message : String(error); + return json({ error: "Failed to update tags", message }, { status: 500 }); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index e018301..0229c2e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -12,6 +12,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { MarkdownEditor } from "@/components/editor/markdown-editor"; import { toast } from "sonner"; +import { TagsDialog } from "@/components/files/tags-dialog"; type FilesListResponse = { total: number; @@ -82,6 +83,9 @@ export default function Home() { // Editor state const [editPath, setEditPath] = React.useState(null); + // Tags dialog state + const [tagsPath, setTagsPath] = React.useState(null); + const filesQuery = useQuery({ queryKey: ["files", path, page, perPage], queryFn: () => fetchFiles(path, page, perPage), @@ -192,6 +196,10 @@ export default function Home() { } } + function handleTags(item: FileRow) { + setTagsPath(item.path); + } + function handleUploaded() { if (!searching) { filesQuery.refetch(); @@ -256,6 +264,7 @@ export default function Home() { onRename={handleRename} onCopy={handleCopy} onDelete={handleDelete} + onTags={handleTags} /> @@ -271,6 +280,18 @@ export default function Home() { + + {/* Tags dialog */} + !v && setTagsPath(null)} + initialTags={[]} + onSaved={() => { + // Optionally refresh file list to reflect updated tags when surface supports it + if (!searching) filesQuery.refetch(); + }} + /> ); } diff --git a/src/components/files/file-row-actions.tsx b/src/components/files/file-row-actions.tsx index 8cef402..7f21d04 100644 --- a/src/components/files/file-row-actions.tsx +++ b/src/components/files/file-row-actions.tsx @@ -9,7 +9,7 @@ import { DropdownMenuItem, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; -import { MoreHorizontal, Pencil, Copy, Trash2, Download, ScanText, MoveRight } from "lucide-react"; +import { MoreHorizontal, Pencil, Copy, Trash2, Download, MoveRight, Tag } from "lucide-react"; export type FileRowActionHandlers = { onEdit?: () => void; @@ -17,6 +17,7 @@ export type FileRowActionHandlers = { onRename?: () => void; onCopy?: () => void; onDelete?: () => void; + onTags?: () => void; }; export function FileRowActions({ @@ -25,6 +26,7 @@ export function FileRowActions({ onRename, onCopy, onDelete, + onTags, }: FileRowActionHandlers) { return ( @@ -47,6 +49,10 @@ export function FileRowActions({ Rename / Move + + + Tags + Copy diff --git a/src/components/files/file-table.tsx b/src/components/files/file-table.tsx index 4126186..96e09c9 100644 --- a/src/components/files/file-table.tsx +++ b/src/components/files/file-table.tsx @@ -33,6 +33,7 @@ export function FileTable({ onRename, onCopy, onDelete, + onTags, }: { items: FileRow[]; onOpen?: (item: FileRow) => void; @@ -41,6 +42,7 @@ export function FileTable({ onRename?: (item: FileRow) => void; onCopy?: (item: FileRow) => void; onDelete?: (item: FileRow) => void; + onTags?: (item: FileRow) => void; }) { return (
@@ -83,6 +85,7 @@ export function FileTable({ onRename={() => onRename?.(it)} onCopy={() => onCopy?.(it)} onDelete={() => onDelete?.(it)} + onTags={() => onTags?.(it)} />
diff --git a/src/components/files/tags-dialog.tsx b/src/components/files/tags-dialog.tsx new file mode 100644 index 0000000..cc02e53 --- /dev/null +++ b/src/components/files/tags-dialog.tsx @@ -0,0 +1,154 @@ +"use client"; + +import * as React from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; + +export function parseTags(input: string): string[] { + return input + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +async function fetchHistory(path: string, size = 50) { + const url = new URL("/api/files/tags/history", window.location.origin); + url.searchParams.set("path", path); + url.searchParams.set("size", String(size)); + const res = await fetch(url.toString(), { cache: "no-store" }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload?.message || `Failed to load tag history (${res.status})`); + } + return (await res.json()) as { total: number; items: Array<{ id?: string; to?: string[]; at?: string }> }; +} + +async function saveTags(path: string, tags: string[]) { + const res = await fetch("/api/files/tags", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path, tags }), + }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload?.message || `Failed to save tags (${res.status})`); + } + return (await res.json()) as { ok: boolean; tags: string[] }; +} + +export function TagsDialog({ + path, + open, + onOpenChange, + initialTags, + onSaved, +}: { + path: string; + open: boolean; + onOpenChange: (v: boolean) => void; + initialTags?: string[]; + onSaved?: (tags: string[]) => void; +}) { + const [value, setValue] = React.useState((initialTags ?? []).join(", ")); + + React.useEffect(() => { + setValue((initialTags ?? []).join(", ")); + }, [initialTags, path]); + + const historyQuery = useQuery({ + queryKey: ["tags-history", path], + queryFn: () => fetchHistory(path), + enabled: open && !!path, + }); + + const mutation = useMutation({ + mutationFn: (tags: string[]) => saveTags(path, tags), + onSuccess: (data) => { + toast.success("Tags saved"); + onSaved?.(data.tags); + onOpenChange(false); + }, + onError: (err: unknown) => toast.error(err instanceof Error ? err.message : String(err)), + }); + + const currentTags = parseTags(value); + + return ( + !mutation.isPending && onOpenChange(v)}> + + + Tags +
+ File: {path} +
+
+ +
+
+ + setValue(e.target.value)} + placeholder="tag1, tag2, tag3" + disabled={mutation.isPending} + /> +
+ {currentTags.map((t) => ( + {t} + ))} + {currentTags.length === 0 && ( +
No tags
+ )} +
+
+ +
+
History
+
+ {historyQuery.isLoading &&
Loading history…
} + {historyQuery.error && ( +
+ {(historyQuery.error as Error).message} +
+ )} + {!historyQuery.isLoading && !historyQuery.error && ( +
    + {(historyQuery.data?.items ?? []).map((ev, idx) => ( +
  • + {ev.at ? new Date(ev.at).toLocaleString() : ""} + + + {(ev.to ?? []).map((t) => ( + {t} + ))} + {(ev.to ?? []).length === 0 && ( + [] + )} + +
  • + ))} + {(historyQuery.data?.items ?? []).length === 0 && ( +
  • No history yet
  • + )} +
+ )} +
+
+
+ + + + + +
+
+ ); +}