Add tag editing and PDF preview to documents feature

Each document's tags are now editable inline: click Edit to enter a tag
editor (Enter/comma to add, × to remove, Save to persist). The View
button opens the PDF in a new browser tab via blob URL. Both features
work through the existing proxy — no proxy changes needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-14 16:12:45 +02:00
parent 0b92db87d1
commit 18295e8e4f
4 changed files with 157 additions and 12 deletions
+12
View File
@@ -136,6 +136,18 @@ export const downloadDocument = async (id: string, filename: string) => {
URL.revokeObjectURL(url);
};
export const viewDocument = async (id: string): Promise<void> => {
const response = await api.get(`/documents/${id}/file`, { responseType: "blob" });
const url = URL.createObjectURL(response.data);
const win = window.open(url, "_blank");
// Revoke after a generous delay — the new tab needs time to load the blob
setTimeout(() => URL.revokeObjectURL(url), 60_000);
if (!win) alert("Pop-up blocked. Please allow pop-ups for this site to preview PDFs.");
};
export const updateDocumentTags = (id: string, tags: string[]) =>
api.patch<DocumentOut>(`/documents/${id}/tags`, { tags }).then((r) => r.data);
export const assignCategory = (docId: string, catId: string) =>
api.post(`/documents/${docId}/categories/${catId}`);
+118 -11
View File
@@ -6,11 +6,13 @@ import {
uploadDocument,
deleteDocument,
downloadDocument,
viewDocument,
getDocumentStatus,
listCategories,
createCategory,
assignCategory,
removeCategory,
updateDocumentTags,
type DocumentOut,
type CategoryOut,
} from "../api/client";
@@ -87,6 +89,110 @@ function SuggestionChip({ name, existing, onAccept, onDismiss }: SuggestionChipP
// ── Document row ────────────────────────────────────────────────────────────
// ── Tag editor ──────────────────────────────────────────────────────────────
function TagEditor({
docId,
initialTags,
onSaved,
}: {
docId: string;
initialTags: string[];
onSaved: () => void;
}) {
const [tags, setTags] = useState<string[]>(initialTags);
const [input, setInput] = useState("");
const [editing, setEditing] = useState(false);
const saveMut = useMutation({
mutationFn: (t: string[]) => updateDocumentTags(docId, t),
onSuccess: () => { onSaved(); setEditing(false); },
});
const addTag = () => {
const val = input.trim();
if (!val || tags.map((t) => t.toLowerCase()).includes(val.toLowerCase())) {
setInput("");
return;
}
const next = [...tags, val];
setTags(next);
setInput("");
};
const removeTag = (idx: number) => {
setTags((prev) => prev.filter((_, i) => i !== idx));
};
if (!editing) {
return (
<div style={{ marginTop: 10 }}>
<strong>Tags:</strong>{" "}
{tags.length === 0 && <span style={{ color: "#aaa", fontSize: 13 }}>none</span>}
{tags.map((t) => (
<span key={t} style={{ fontSize: 12, background: "#eee", borderRadius: 3, padding: "2px 6px", marginRight: 4 }}>
{t}
</span>
))}
<button
onClick={() => setEditing(true)}
style={{ fontSize: 11, marginLeft: 6, cursor: "pointer", color: "#555", background: "none", border: "1px solid #ccc", borderRadius: 3, padding: "1px 6px" }}
>
Edit
</button>
</div>
);
}
return (
<div style={{ marginTop: 10 }}>
<strong>Tags:</strong>
<div style={{ marginTop: 6, display: "flex", flexWrap: "wrap", gap: 6, alignItems: "center" }}>
{tags.map((t, i) => (
<span key={t} style={{ fontSize: 12, background: "#eee", borderRadius: 3, padding: "2px 6px", display: "inline-flex", alignItems: "center", gap: 4 }}>
{t}
<button
onClick={() => removeTag(i)}
style={{ fontSize: 10, cursor: "pointer", background: "none", border: "none", color: "#888", lineHeight: 1 }}
>
×
</button>
</span>
))}
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") { e.preventDefault(); addTag(); }
if (e.key === "Escape") { setEditing(false); setTags(initialTags); setInput(""); }
}}
placeholder="Add tag…"
style={{ fontSize: 12, padding: "2px 6px", border: "1px solid #ccc", borderRadius: 3, width: 100 }}
autoFocus
/>
</div>
<div style={{ marginTop: 6, display: "flex", gap: 6 }}>
<button
onClick={() => saveMut.mutate(tags)}
disabled={saveMut.isPending}
style={{ fontSize: 12, cursor: "pointer", padding: "3px 10px", background: "#222", color: "#fff", border: "none", borderRadius: 3 }}
>
{saveMut.isPending ? "Saving…" : "Save"}
</button>
<button
onClick={() => { setEditing(false); setTags(initialTags); setInput(""); }}
style={{ fontSize: 12, cursor: "pointer", padding: "3px 10px", border: "1px solid #ccc", borderRadius: 3, background: "none" }}
>
Cancel
</button>
</div>
<p style={{ fontSize: 11, color: "#999", margin: "4px 0 0" }}>Enter or comma to add · Esc to cancel</p>
</div>
);
}
// ── Document row ────────────────────────────────────────────────────────────
function DocumentRow({
doc,
categories,
@@ -192,6 +298,12 @@ function DocumentRow({
<span style={{ fontSize: 12, color: "#999" }}>
{(doc.file_size / 1024).toFixed(0)} KB
</span>
<button
onClick={(e) => { e.stopPropagation(); viewDocument(doc.id); }}
style={{ fontSize: 12, cursor: "pointer" }}
>
View
</button>
<button
onClick={(e) => { e.stopPropagation(); downloadDocument(doc.id, doc.filename); }}
style={{ fontSize: 12, cursor: "pointer" }}
@@ -213,17 +325,12 @@ function DocumentRow({
{expanded && (
<div style={{ padding: "0 16px 16px", borderTop: "1px solid #eee" }}>
{/* Tags */}
{tags.length > 0 && (
<div style={{ marginTop: 10 }}>
<strong>Tags:</strong>{" "}
{tags.map((t) => (
<span key={t} style={{ fontSize: 12, background: "#eee", borderRadius: 3, padding: "2px 6px", marginRight: 4 }}>
{t}
</span>
))}
</div>
)}
{/* Tags — always shown, editable */}
<TagEditor
docId={doc.id}
initialTags={tags}
onSaved={() => qc.invalidateQueries({ queryKey: ["documents"] })}
/>
{/* Extracted fields (excluding internal-only keys) */}
{extractedData && (