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 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]
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user