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
This commit is contained in:
parent
362a97cada
commit
bc49eec7c8
65
src/app/api/files/tags/history/route.ts
Normal file
65
src/app/api/files/tags/history/route.ts
Normal file
@ -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<T>(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<string, unknown>),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/app/api/files/tags/route.ts
Normal file
78
src/app/api/files/tags/route.ts
Normal file
@ -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<T>(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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { MarkdownEditor } from "@/components/editor/markdown-editor";
|
import { MarkdownEditor } from "@/components/editor/markdown-editor";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { TagsDialog } from "@/components/files/tags-dialog";
|
||||||
|
|
||||||
type FilesListResponse = {
|
type FilesListResponse = {
|
||||||
total: number;
|
total: number;
|
||||||
@ -82,6 +83,9 @@ export default function Home() {
|
|||||||
// Editor state
|
// Editor state
|
||||||
const [editPath, setEditPath] = React.useState<string | null>(null);
|
const [editPath, setEditPath] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
// Tags dialog state
|
||||||
|
const [tagsPath, setTagsPath] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const filesQuery = useQuery({
|
const filesQuery = useQuery({
|
||||||
queryKey: ["files", path, page, perPage],
|
queryKey: ["files", path, page, perPage],
|
||||||
queryFn: () => fetchFiles(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() {
|
function handleUploaded() {
|
||||||
if (!searching) {
|
if (!searching) {
|
||||||
filesQuery.refetch();
|
filesQuery.refetch();
|
||||||
@ -256,6 +264,7 @@ export default function Home() {
|
|||||||
onRename={handleRename}
|
onRename={handleRename}
|
||||||
onCopy={handleCopy}
|
onCopy={handleCopy}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onTags={handleTags}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@ -271,6 +280,18 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Tags dialog */}
|
||||||
|
<TagsDialog
|
||||||
|
path={tagsPath ?? ""}
|
||||||
|
open={!!tagsPath}
|
||||||
|
onOpenChange={(v) => !v && setTagsPath(null)}
|
||||||
|
initialTags={[]}
|
||||||
|
onSaved={() => {
|
||||||
|
// Optionally refresh file list to reflect updated tags when surface supports it
|
||||||
|
if (!searching) filesQuery.refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} 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 = {
|
export type FileRowActionHandlers = {
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
@ -17,6 +17,7 @@ export type FileRowActionHandlers = {
|
|||||||
onRename?: () => void;
|
onRename?: () => void;
|
||||||
onCopy?: () => void;
|
onCopy?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
|
onTags?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FileRowActions({
|
export function FileRowActions({
|
||||||
@ -25,6 +26,7 @@ export function FileRowActions({
|
|||||||
onRename,
|
onRename,
|
||||||
onCopy,
|
onCopy,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onTags,
|
||||||
}: FileRowActionHandlers) {
|
}: FileRowActionHandlers) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@ -47,6 +49,10 @@ export function FileRowActions({
|
|||||||
<MoveRight className="mr-2 h-4 w-4" />
|
<MoveRight className="mr-2 h-4 w-4" />
|
||||||
Rename / Move
|
Rename / Move
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onTags}>
|
||||||
|
<Tag className="mr-2 h-4 w-4" />
|
||||||
|
Tags
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={onCopy}>
|
<DropdownMenuItem onClick={onCopy}>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
Copy
|
Copy
|
||||||
|
|||||||
@ -33,6 +33,7 @@ export function FileTable({
|
|||||||
onRename,
|
onRename,
|
||||||
onCopy,
|
onCopy,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onTags,
|
||||||
}: {
|
}: {
|
||||||
items: FileRow[];
|
items: FileRow[];
|
||||||
onOpen?: (item: FileRow) => void;
|
onOpen?: (item: FileRow) => void;
|
||||||
@ -41,6 +42,7 @@ export function FileTable({
|
|||||||
onRename?: (item: FileRow) => void;
|
onRename?: (item: FileRow) => void;
|
||||||
onCopy?: (item: FileRow) => void;
|
onCopy?: (item: FileRow) => void;
|
||||||
onDelete?: (item: FileRow) => void;
|
onDelete?: (item: FileRow) => void;
|
||||||
|
onTags?: (item: FileRow) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
@ -83,6 +85,7 @@ export function FileTable({
|
|||||||
onRename={() => onRename?.(it)}
|
onRename={() => onRename?.(it)}
|
||||||
onCopy={() => onCopy?.(it)}
|
onCopy={() => onCopy?.(it)}
|
||||||
onDelete={() => onDelete?.(it)}
|
onDelete={() => onDelete?.(it)}
|
||||||
|
onTags={() => onTags?.(it)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
154
src/components/files/tags-dialog.tsx
Normal file
154
src/components/files/tags-dialog.tsx
Normal file
@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !mutation.isPending && onOpenChange(v)}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Tags</DialogTitle>
|
||||||
|
<div className="text-xs text-muted-foreground break-all mt-1">
|
||||||
|
File: <code>{path}</code>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Edit tags (comma-separated)</label>
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder="tag1, tag2, tag3"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{currentTags.map((t) => (
|
||||||
|
<Badge key={t} variant="secondary">{t}</Badge>
|
||||||
|
))}
|
||||||
|
{currentTags.length === 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground">No tags</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium mb-1">History</div>
|
||||||
|
<div className="max-h-48 overflow-auto border rounded p-2 text-sm">
|
||||||
|
{historyQuery.isLoading && <div className="text-muted-foreground">Loading history…</div>}
|
||||||
|
{historyQuery.error && (
|
||||||
|
<div className="text-destructive">
|
||||||
|
{(historyQuery.error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!historyQuery.isLoading && !historyQuery.error && (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{(historyQuery.data?.items ?? []).map((ev, idx) => (
|
||||||
|
<li key={ev.id ?? idx} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">{ev.at ? new Date(ev.at).toLocaleString() : ""}</span>
|
||||||
|
<span>→</span>
|
||||||
|
<span className="flex flex-wrap gap-1">
|
||||||
|
{(ev.to ?? []).map((t) => (
|
||||||
|
<Badge key={t} variant="outline">{t}</Badge>
|
||||||
|
))}
|
||||||
|
{(ev.to ?? []).length === 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">[]</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{(historyQuery.data?.items ?? []).length === 0 && (
|
||||||
|
<li className="text-xs text-muted-foreground">No history yet</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={mutation.isPending}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => mutation.mutate(currentTags)} disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user