Add AI-suggested editable document title
AI now returns a short descriptive title per document (e.g. "ACME Corp
Invoice April 2026"). Title is stored in a new documents.title column
(migration 0002), shown in the row header instead of the raw filename,
and editable inline via PATCH /documents/{id}/title. Filename is shown
as a subtitle when a title exists.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -86,6 +86,7 @@ export interface DocumentOut {
|
||||
id: string;
|
||||
user_id: string;
|
||||
filename: string;
|
||||
title: string | null;
|
||||
file_size: number;
|
||||
status: DocumentStatus;
|
||||
document_type: string | null;
|
||||
@@ -148,6 +149,9 @@ export const viewDocument = async (id: string): Promise<void> => {
|
||||
export const updateDocumentTags = (id: string, tags: string[]) =>
|
||||
api.patch<DocumentOut>(`/documents/${id}/tags`, { tags }).then((r) => r.data);
|
||||
|
||||
export const updateDocumentTitle = (id: string, title: string) =>
|
||||
api.patch<DocumentOut>(`/documents/${id}/title`, { title }).then((r) => r.data);
|
||||
|
||||
export const assignCategory = (docId: string, catId: string) =>
|
||||
api.post(`/documents/${docId}/categories/${catId}`);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
assignCategory,
|
||||
removeCategory,
|
||||
updateDocumentTags,
|
||||
updateDocumentTitle,
|
||||
type DocumentOut,
|
||||
type CategoryOut,
|
||||
} from "../api/client";
|
||||
@@ -89,6 +90,73 @@ function SuggestionChip({ name, existing, onAccept, onDismiss }: SuggestionChipP
|
||||
|
||||
// ── Document row ────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Inline title editor ─────────────────────────────────────────────────────
|
||||
|
||||
function InlineTitleEditor({
|
||||
docId,
|
||||
currentTitle,
|
||||
filename,
|
||||
onSaved,
|
||||
}: {
|
||||
docId: string;
|
||||
currentTitle: string | null;
|
||||
filename: string;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState(currentTitle ?? "");
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (t: string) => updateDocumentTitle(docId, t),
|
||||
onSuccess: () => { onSaved(); setEditing(false); },
|
||||
});
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontWeight: 500 }}>
|
||||
{currentTitle ?? <span style={{ color: "#aaa", fontStyle: "italic" }}>{filename}</span>}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setValue(currentTitle ?? ""); setEditing(true); }}
|
||||
style={{ fontSize: 11, cursor: "pointer", color: "#888", background: "none", border: "1px solid #ddd", borderRadius: 3, padding: "0 5px" }}
|
||||
title="Edit title"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span onClick={(e) => e.stopPropagation()} style={{ display: "inline-flex", alignItems: "center", gap: 6, flex: 1 }}>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveMut.mutate(value);
|
||||
if (e.key === "Escape") { setEditing(false); setValue(currentTitle ?? ""); }
|
||||
}}
|
||||
style={{ fontSize: 14, padding: "2px 6px", border: "1px solid #888", borderRadius: 3, width: 280 }}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveMut.mutate(value)}
|
||||
disabled={saveMut.isPending}
|
||||
style={{ fontSize: 12, cursor: "pointer", padding: "2px 8px", background: "#222", color: "#fff", border: "none", borderRadius: 3 }}
|
||||
>
|
||||
{saveMut.isPending ? "…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditing(false); setValue(currentTitle ?? ""); }}
|
||||
style={{ fontSize: 12, cursor: "pointer", padding: "2px 6px", border: "1px solid #ccc", borderRadius: 3, background: "none" }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tag editor ──────────────────────────────────────────────────────────────
|
||||
|
||||
function TagEditor({
|
||||
@@ -290,7 +358,19 @@ function DocumentRow({
|
||||
style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 16px", cursor: "pointer" }}
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
>
|
||||
<span style={{ flex: 1, fontWeight: 500 }}>{doc.filename}</span>
|
||||
<span style={{ flex: 1 }}>
|
||||
<InlineTitleEditor
|
||||
docId={doc.id}
|
||||
currentTitle={doc.title}
|
||||
filename={doc.filename}
|
||||
onSaved={() => qc.invalidateQueries({ queryKey: ["documents"] })}
|
||||
/>
|
||||
{doc.title && (
|
||||
<span style={{ display: "block", fontSize: 11, color: "#999", marginTop: 1 }}>
|
||||
{doc.filename}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<StatusBadge status={doc.status} />
|
||||
{doc.document_type && (
|
||||
<span style={{ fontSize: 12, color: "#555" }}>{doc.document_type}</span>
|
||||
|
||||
Reference in New Issue
Block a user