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
+23 -1
View File
@@ -16,7 +16,7 @@ from app.deps import get_user_id
from app.models.category import DocumentCategory from app.models.category import DocumentCategory
from app.models.category_assignment import CategoryAssignment from app.models.category_assignment import CategoryAssignment
from app.models.document import Document from app.models.document import Document
from app.schemas.document import DocumentOut, DocumentStatusOut, DocumentTypeUpdate from app.schemas.document import DocumentOut, DocumentStatusOut, DocumentTypeUpdate, TagsUpdate
from app.services.ai_client import AIServiceError, classify_document from app.services.ai_client import AIServiceError, classify_document
from app.services.config_reader import load_doc_config from app.services.config_reader import load_doc_config
from app.services.storage import delete_file, get_upload_path, save_upload from app.services.storage import delete_file, get_upload_path, save_upload
@@ -205,6 +205,28 @@ async def update_document_type(
return _doc_with_categories(doc) return _doc_with_categories(doc)
@router.patch("/{doc_id}/tags", response_model=DocumentOut)
async def update_document_tags(
doc_id: str,
body: TagsUpdate,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db),
) -> DocumentOut:
doc = await _get_user_doc(doc_id, user_id, db)
# Normalise: strip whitespace, drop empties, deduplicate while preserving order
seen: set[str] = set()
clean: list[str] = []
for t in body.tags:
t = t.strip()
if t and t.lower() not in seen:
seen.add(t.lower())
clean.append(t)
doc.tags = json.dumps(clean)
await db.commit()
doc = await _get_user_doc(doc_id, user_id, db)
return _doc_with_categories(doc)
@router.delete("/{doc_id}", status_code=204) @router.delete("/{doc_id}", status_code=204)
async def delete_document( async def delete_document(
doc_id: str, doc_id: str,
@@ -37,3 +37,7 @@ class DocumentStatusOut(BaseModel):
class DocumentTypeUpdate(BaseModel): class DocumentTypeUpdate(BaseModel):
document_type: str document_type: str
class TagsUpdate(BaseModel):
tags: list[str]
+12
View File
@@ -136,6 +136,18 @@ export const downloadDocument = async (id: string, filename: string) => {
URL.revokeObjectURL(url); 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) => export const assignCategory = (docId: string, catId: string) =>
api.post(`/documents/${docId}/categories/${catId}`); api.post(`/documents/${docId}/categories/${catId}`);
+118 -11
View File
@@ -6,11 +6,13 @@ import {
uploadDocument, uploadDocument,
deleteDocument, deleteDocument,
downloadDocument, downloadDocument,
viewDocument,
getDocumentStatus, getDocumentStatus,
listCategories, listCategories,
createCategory, createCategory,
assignCategory, assignCategory,
removeCategory, removeCategory,
updateDocumentTags,
type DocumentOut, type DocumentOut,
type CategoryOut, type CategoryOut,
} from "../api/client"; } from "../api/client";
@@ -87,6 +89,110 @@ function SuggestionChip({ name, existing, onAccept, onDismiss }: SuggestionChipP
// ── Document row ──────────────────────────────────────────────────────────── // ── 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({ function DocumentRow({
doc, doc,
categories, categories,
@@ -192,6 +298,12 @@ function DocumentRow({
<span style={{ fontSize: 12, color: "#999" }}> <span style={{ fontSize: 12, color: "#999" }}>
{(doc.file_size / 1024).toFixed(0)} KB {(doc.file_size / 1024).toFixed(0)} KB
</span> </span>
<button
onClick={(e) => { e.stopPropagation(); viewDocument(doc.id); }}
style={{ fontSize: 12, cursor: "pointer" }}
>
View
</button>
<button <button
onClick={(e) => { e.stopPropagation(); downloadDocument(doc.id, doc.filename); }} onClick={(e) => { e.stopPropagation(); downloadDocument(doc.id, doc.filename); }}
style={{ fontSize: 12, cursor: "pointer" }} style={{ fontSize: 12, cursor: "pointer" }}
@@ -213,17 +325,12 @@ function DocumentRow({
{expanded && ( {expanded && (
<div style={{ padding: "0 16px 16px", borderTop: "1px solid #eee" }}> <div style={{ padding: "0 16px 16px", borderTop: "1px solid #eee" }}>
{/* Tags */} {/* Tags — always shown, editable */}
{tags.length > 0 && ( <TagEditor
<div style={{ marginTop: 10 }}> docId={doc.id}
<strong>Tags:</strong>{" "} initialTags={tags}
{tags.map((t) => ( onSaved={() => qc.invalidateQueries({ queryKey: ["documents"] })}
<span key={t} style={{ fontSize: 12, background: "#eee", borderRadius: 3, padding: "2px 6px", marginRight: 4 }}> />
{t}
</span>
))}
</div>
)}
{/* Extracted fields (excluding internal-only keys) */} {/* Extracted fields (excluding internal-only keys) */}
{extractedData && ( {extractedData && (