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:
@@ -16,7 +16,7 @@ from app.deps import get_user_id
|
||||
from app.models.category import DocumentCategory
|
||||
from app.models.category_assignment import CategoryAssignment
|
||||
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.config_reader import load_doc_config
|
||||
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)
|
||||
|
||||
|
||||
@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)
|
||||
async def delete_document(
|
||||
doc_id: str,
|
||||
|
||||
@@ -37,3 +37,7 @@ class DocumentStatusOut(BaseModel):
|
||||
|
||||
class DocumentTypeUpdate(BaseModel):
|
||||
document_type: str
|
||||
|
||||
|
||||
class TagsUpdate(BaseModel):
|
||||
tags: list[str]
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user