Spaces:
Running
Running
feat: add document rename support
Browse files
backend/app/routes/documents.py
CHANGED
|
@@ -20,7 +20,14 @@ from sqlalchemy.orm import Session
|
|
| 20 |
|
| 21 |
from app.database import get_db
|
| 22 |
from app.models import User, Document
|
| 23 |
-
from app.schemas import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
from app.auth import get_current_user
|
| 25 |
from app.config import get_settings
|
| 26 |
from app.rag.chunker import chunk_document, get_page_count
|
|
@@ -550,6 +557,34 @@ def list_documents(
|
|
| 550 |
)
|
| 551 |
|
| 552 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
@router.get("/{document_id}", response_model=DocumentResponse)
|
| 554 |
def get_document(
|
| 555 |
document_id: str,
|
|
|
|
| 20 |
|
| 21 |
from app.database import get_db
|
| 22 |
from app.models import User, Document
|
| 23 |
+
from app.schemas import (
|
| 24 |
+
DocumentResponse,
|
| 25 |
+
DocumentListResponse,
|
| 26 |
+
DocumentStatusResponse,
|
| 27 |
+
DocumentRename,
|
| 28 |
+
ChunkSettings,
|
| 29 |
+
UploadUrl,
|
| 30 |
+
)
|
| 31 |
from app.auth import get_current_user
|
| 32 |
from app.config import get_settings
|
| 33 |
from app.rag.chunker import chunk_document, get_page_count
|
|
|
|
| 557 |
)
|
| 558 |
|
| 559 |
|
| 560 |
+
@router.patch("/{document_id}", response_model=DocumentResponse)
|
| 561 |
+
def rename_document(
|
| 562 |
+
document_id: str,
|
| 563 |
+
rename: DocumentRename,
|
| 564 |
+
user: User = Depends(get_current_user),
|
| 565 |
+
db: Session = Depends(get_db),
|
| 566 |
+
):
|
| 567 |
+
"""
|
| 568 |
+
Rename an uploaded document without changing its stored file or vector data.
|
| 569 |
+
"""
|
| 570 |
+
doc = db.query(Document).filter(
|
| 571 |
+
Document.id == document_id,
|
| 572 |
+
Document.is_deleted.is_(False),
|
| 573 |
+
).first()
|
| 574 |
+
|
| 575 |
+
if not doc:
|
| 576 |
+
raise HTTPException(status_code=404, detail="Document not found")
|
| 577 |
+
|
| 578 |
+
if str(doc.user_id) != str(user.id):
|
| 579 |
+
raise HTTPException(status_code=403, detail="You do not have permission to rename this document")
|
| 580 |
+
|
| 581 |
+
doc.original_name = rename.name
|
| 582 |
+
db.commit()
|
| 583 |
+
db.refresh(doc)
|
| 584 |
+
|
| 585 |
+
return DocumentResponse.model_validate(doc)
|
| 586 |
+
|
| 587 |
+
|
| 588 |
@router.get("/{document_id}", response_model=DocumentResponse)
|
| 589 |
def get_document(
|
| 590 |
document_id: str,
|
backend/app/schemas.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
Pydantic schemas for API request/response validation.
|
| 3 |
"""
|
| 4 |
-
from pydantic import BaseModel, EmailStr, Field
|
| 5 |
from typing import Optional, List
|
| 6 |
from datetime import datetime
|
| 7 |
from app.models import UserRole
|
|
@@ -110,6 +110,18 @@ class DocumentResponse(BaseModel):
|
|
| 110 |
from_attributes = True
|
| 111 |
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
class DocumentStatusResponse(BaseModel):
|
| 114 |
id: str
|
| 115 |
status: str
|
|
|
|
| 1 |
"""
|
| 2 |
Pydantic schemas for API request/response validation.
|
| 3 |
"""
|
| 4 |
+
from pydantic import BaseModel, EmailStr, Field, field_validator
|
| 5 |
from typing import Optional, List
|
| 6 |
from datetime import datetime
|
| 7 |
from app.models import UserRole
|
|
|
|
| 110 |
from_attributes = True
|
| 111 |
|
| 112 |
|
| 113 |
+
class DocumentRename(BaseModel):
|
| 114 |
+
name: str = Field(..., min_length=1, max_length=255)
|
| 115 |
+
|
| 116 |
+
@field_validator("name")
|
| 117 |
+
@classmethod
|
| 118 |
+
def validate_name(cls, value: str) -> str:
|
| 119 |
+
stripped = value.strip()
|
| 120 |
+
if not stripped:
|
| 121 |
+
raise ValueError("Document name cannot be empty")
|
| 122 |
+
return stripped
|
| 123 |
+
|
| 124 |
+
|
| 125 |
class DocumentStatusResponse(BaseModel):
|
| 126 |
id: str
|
| 127 |
status: str
|
backend/tests/test_documents.py
CHANGED
|
@@ -40,6 +40,66 @@ def test_upload_rejects_unsupported_extension_before_deep_validation(client, aut
|
|
| 40 |
assert "not supported" in response.json()["detail"]
|
| 41 |
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
def test_ingest_document_builds_and_saves_graph(db_session, monkeypatch, tmp_path, user):
|
| 44 |
document = Document(
|
| 45 |
user_id=user.id,
|
|
|
|
| 40 |
assert "not supported" in response.json()["detail"]
|
| 41 |
|
| 42 |
|
| 43 |
+
def test_rename_document_updates_original_name(client, auth_headers, ready_document, db_session):
|
| 44 |
+
response = client.patch(
|
| 45 |
+
f"/api/v1/documents/{ready_document.id}",
|
| 46 |
+
headers=auth_headers,
|
| 47 |
+
json={"name": " renamed-report.pdf "},
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
assert response.status_code == 200
|
| 51 |
+
payload = response.json()
|
| 52 |
+
assert payload["id"] == ready_document.id
|
| 53 |
+
assert payload["original_name"] == "renamed-report.pdf"
|
| 54 |
+
|
| 55 |
+
db_session.refresh(ready_document)
|
| 56 |
+
assert ready_document.original_name == "renamed-report.pdf"
|
| 57 |
+
assert ready_document.filename == "ready.txt"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_rename_document_rejects_empty_name(client, auth_headers, ready_document):
|
| 61 |
+
response = client.patch(
|
| 62 |
+
f"/api/v1/documents/{ready_document.id}",
|
| 63 |
+
headers=auth_headers,
|
| 64 |
+
json={"name": " "},
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
assert response.status_code == 422
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_rename_document_returns_404_for_missing_document(client, auth_headers):
|
| 71 |
+
response = client.patch(
|
| 72 |
+
"/api/v1/documents/00000000-0000-0000-0000-000000000000",
|
| 73 |
+
headers=auth_headers,
|
| 74 |
+
json={"name": "missing.pdf"},
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
assert response.status_code == 404
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_rename_document_returns_403_for_other_users_document(client, auth_headers, db_session, other_user):
|
| 81 |
+
other_document = Document(
|
| 82 |
+
user_id=other_user.id,
|
| 83 |
+
filename="other.txt",
|
| 84 |
+
original_name="other.txt",
|
| 85 |
+
file_size=64,
|
| 86 |
+
status="ready",
|
| 87 |
+
)
|
| 88 |
+
db_session.add(other_document)
|
| 89 |
+
db_session.commit()
|
| 90 |
+
db_session.refresh(other_document)
|
| 91 |
+
|
| 92 |
+
response = client.patch(
|
| 93 |
+
f"/api/v1/documents/{other_document.id}",
|
| 94 |
+
headers=auth_headers,
|
| 95 |
+
json={"name": "renamed.txt"},
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
assert response.status_code == 403
|
| 99 |
+
db_session.refresh(other_document)
|
| 100 |
+
assert other_document.original_name == "other.txt"
|
| 101 |
+
|
| 102 |
+
|
| 103 |
def test_ingest_document_builds_and_saves_graph(db_session, monkeypatch, tmp_path, user):
|
| 104 |
document = Document(
|
| 105 |
user_id=user.id,
|
frontend/src/app/dashboard/page.tsx
CHANGED
|
@@ -68,6 +68,13 @@ export default function DashboardPage() {
|
|
| 68 |
const [connectionError, setConnectionError] = useState("");
|
| 69 |
const [documentsLoading, setDocumentsLoading] = useState(true);
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
// Auth guard
|
| 72 |
useEffect(() => {
|
| 73 |
if (!loading && !user) router.replace("/login");
|
|
@@ -162,6 +169,7 @@ export default function DashboardPage() {
|
|
| 162 |
setPdfPage(1);
|
| 163 |
}}
|
| 164 |
onDocumentsChange={loadDocuments}
|
|
|
|
| 165 |
/>
|
| 166 |
);
|
| 167 |
|
|
|
|
| 68 |
const [connectionError, setConnectionError] = useState("");
|
| 69 |
const [documentsLoading, setDocumentsLoading] = useState(true);
|
| 70 |
|
| 71 |
+
const handleDocumentRenamed = useCallback((renamedDocument: DocInfo) => {
|
| 72 |
+
setDocuments((current) =>
|
| 73 |
+
current.map((document) => (document.id === renamedDocument.id ? renamedDocument : document))
|
| 74 |
+
);
|
| 75 |
+
setActiveDoc((current) => (current?.id === renamedDocument.id ? renamedDocument : current));
|
| 76 |
+
}, []);
|
| 77 |
+
|
| 78 |
// Auth guard
|
| 79 |
useEffect(() => {
|
| 80 |
if (!loading && !user) router.replace("/login");
|
|
|
|
| 169 |
setPdfPage(1);
|
| 170 |
}}
|
| 171 |
onDocumentsChange={loadDocuments}
|
| 172 |
+
onDocumentRenamed={handleDocumentRenamed}
|
| 173 |
/>
|
| 174 |
);
|
| 175 |
|
frontend/src/components/document/DocumentSidebar.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { api } from "@/lib/api";
|
|
| 7 |
import { ScrollArea } from "@/components/ui/scroll-area";
|
| 8 |
import { Skeleton } from "@/components/ui/skeleton";
|
| 9 |
import { Button } from "@/components/ui/button";
|
|
|
|
| 10 |
import { Badge } from "@/components/ui/badge";
|
| 11 |
import { Progress } from "@/components/ui/progress";
|
| 12 |
import {
|
|
@@ -23,6 +24,7 @@ interface Props {
|
|
| 23 |
loading?: boolean;
|
| 24 |
onSelectDoc: (doc: DocInfo) => void;
|
| 25 |
onDocumentsChange: () => void;
|
|
|
|
| 26 |
}
|
| 27 |
|
| 28 |
function DocumentListSkeleton() {
|
|
@@ -47,13 +49,23 @@ function DocumentListSkeleton() {
|
|
| 47 |
);
|
| 48 |
}
|
| 49 |
|
| 50 |
-
export default function DocumentSidebar({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
const { t } = useTranslation();
|
| 52 |
const [uploading, setUploading] = useState(false);
|
| 53 |
const [uploadProgress, setUploadProgress] = useState(0);
|
| 54 |
const [uploadError, setUploadError] = useState("");
|
| 55 |
const [deleting, setDeleting] = useState<string | null>(null);
|
| 56 |
const [settingsDoc, setSettingsDoc] = useState<DocInfo | null>(null);
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
const onDrop = useCallback(
|
| 59 |
(acceptedFiles: File[]) => {
|
|
@@ -119,6 +131,66 @@ export default function DocumentSidebar({ documents = [], activeDoc, loading = f
|
|
| 119 |
setSettingsDoc(doc);
|
| 120 |
};
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
const statusIcon = (status: string) => {
|
| 123 |
switch (status) {
|
| 124 |
case "ready":
|
|
@@ -196,22 +268,45 @@ export default function DocumentSidebar({ documents = [], activeDoc, loading = f
|
|
| 196 |
</div>
|
| 197 |
) : (
|
| 198 |
<div className="space-y-1 pb-3">
|
| 199 |
-
{documents.map((doc) =>
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
{
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
<p className="text-xs text-muted-foreground mt-1">
|
| 216 |
{doc.summary || "📄 No summary available"}
|
| 217 |
</p>
|
|
@@ -242,8 +337,8 @@ export default function DocumentSidebar({ documents = [], activeDoc, loading = f
|
|
| 242 |
</Badge>
|
| 243 |
)}
|
| 244 |
</div>
|
| 245 |
-
|
| 246 |
-
|
| 247 |
{/* Action buttons (Settings and Delete) are only visible on hover and when the document is ready. The settings button is disabled if the document is not ready, and the delete button shows a loader when the document is being deleted. */}
|
| 248 |
<Button
|
| 249 |
variant="ghost"
|
|
@@ -267,10 +362,11 @@ export default function DocumentSidebar({ documents = [], activeDoc, loading = f
|
|
| 267 |
<Trash2 className="w-3 h-3 text-destructive" />
|
| 268 |
)}
|
| 269 |
</Button>
|
|
|
|
| 270 |
</div>
|
| 271 |
</div>
|
| 272 |
-
|
| 273 |
-
)
|
| 274 |
</div>
|
| 275 |
)}
|
| 276 |
</ScrollArea>
|
|
|
|
| 7 |
import { ScrollArea } from "@/components/ui/scroll-area";
|
| 8 |
import { Skeleton } from "@/components/ui/skeleton";
|
| 9 |
import { Button } from "@/components/ui/button";
|
| 10 |
+
import { Input } from "@/components/ui/input";
|
| 11 |
import { Badge } from "@/components/ui/badge";
|
| 12 |
import { Progress } from "@/components/ui/progress";
|
| 13 |
import {
|
|
|
|
| 24 |
loading?: boolean;
|
| 25 |
onSelectDoc: (doc: DocInfo) => void;
|
| 26 |
onDocumentsChange: () => void;
|
| 27 |
+
onDocumentRenamed: (doc: DocInfo) => void;
|
| 28 |
}
|
| 29 |
|
| 30 |
function DocumentListSkeleton() {
|
|
|
|
| 49 |
);
|
| 50 |
}
|
| 51 |
|
| 52 |
+
export default function DocumentSidebar({
|
| 53 |
+
documents = [],
|
| 54 |
+
activeDoc,
|
| 55 |
+
loading = false,
|
| 56 |
+
onSelectDoc,
|
| 57 |
+
onDocumentsChange,
|
| 58 |
+
onDocumentRenamed,
|
| 59 |
+
}: Props) {
|
| 60 |
const { t } = useTranslation();
|
| 61 |
const [uploading, setUploading] = useState(false);
|
| 62 |
const [uploadProgress, setUploadProgress] = useState(0);
|
| 63 |
const [uploadError, setUploadError] = useState("");
|
| 64 |
const [deleting, setDeleting] = useState<string | null>(null);
|
| 65 |
const [settingsDoc, setSettingsDoc] = useState<DocInfo | null>(null);
|
| 66 |
+
const [editingDocId, setEditingDocId] = useState<string | null>(null);
|
| 67 |
+
const [draftName, setDraftName] = useState("");
|
| 68 |
+
const [renamingDocId, setRenamingDocId] = useState<string | null>(null);
|
| 69 |
|
| 70 |
const onDrop = useCallback(
|
| 71 |
(acceptedFiles: File[]) => {
|
|
|
|
| 131 |
setSettingsDoc(doc);
|
| 132 |
};
|
| 133 |
|
| 134 |
+
const startRename = (doc: DocInfo, e?: React.MouseEvent) => {
|
| 135 |
+
e?.stopPropagation();
|
| 136 |
+
setEditingDocId(doc.id);
|
| 137 |
+
setDraftName(doc.original_name);
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
const cancelRename = () => {
|
| 141 |
+
setEditingDocId(null);
|
| 142 |
+
setDraftName("");
|
| 143 |
+
};
|
| 144 |
+
|
| 145 |
+
const submitRename = async (doc: DocInfo) => {
|
| 146 |
+
const nextName = draftName.trim();
|
| 147 |
+
|
| 148 |
+
if (!nextName) {
|
| 149 |
+
toast.error("Document name cannot be empty");
|
| 150 |
+
return;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
if (nextName === doc.original_name) {
|
| 154 |
+
cancelRename();
|
| 155 |
+
return;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
const optimisticDoc = { ...doc, original_name: nextName };
|
| 159 |
+
onDocumentRenamed(optimisticDoc);
|
| 160 |
+
setRenamingDocId(doc.id);
|
| 161 |
+
|
| 162 |
+
try {
|
| 163 |
+
const updatedDoc = await api.renameDocument<DocInfo>(doc.id, nextName);
|
| 164 |
+
onDocumentRenamed(updatedDoc);
|
| 165 |
+
cancelRename();
|
| 166 |
+
toast.success("Document renamed");
|
| 167 |
+
} catch (err) {
|
| 168 |
+
onDocumentRenamed(doc);
|
| 169 |
+
const message = err instanceof Error ? err.message : "Rename failed";
|
| 170 |
+
toast.error(message);
|
| 171 |
+
} finally {
|
| 172 |
+
setRenamingDocId(null);
|
| 173 |
+
}
|
| 174 |
+
};
|
| 175 |
+
|
| 176 |
+
const handleRenameKeyDown = (doc: DocInfo, e: React.KeyboardEvent<HTMLInputElement>) => {
|
| 177 |
+
if (e.key === "Enter") {
|
| 178 |
+
e.preventDefault();
|
| 179 |
+
void submitRename(doc);
|
| 180 |
+
} else if (e.key === "Escape") {
|
| 181 |
+
e.preventDefault();
|
| 182 |
+
cancelRename();
|
| 183 |
+
}
|
| 184 |
+
};
|
| 185 |
+
|
| 186 |
+
const handleRowKeyDown = (doc: DocInfo, e: React.KeyboardEvent<HTMLDivElement>) => {
|
| 187 |
+
if (editingDocId === doc.id || doc.status !== "ready") return;
|
| 188 |
+
if (e.key === "Enter" || e.key === " ") {
|
| 189 |
+
e.preventDefault();
|
| 190 |
+
onSelectDoc(doc);
|
| 191 |
+
}
|
| 192 |
+
};
|
| 193 |
+
|
| 194 |
const statusIcon = (status: string) => {
|
| 195 |
switch (status) {
|
| 196 |
case "ready":
|
|
|
|
| 268 |
</div>
|
| 269 |
) : (
|
| 270 |
<div className="space-y-1 pb-3">
|
| 271 |
+
{documents.map((doc) => {
|
| 272 |
+
const isEditing = editingDocId === doc.id;
|
| 273 |
+
const isRenaming = renamingDocId === doc.id;
|
| 274 |
+
|
| 275 |
+
return (
|
| 276 |
+
<div
|
| 277 |
+
key={doc.id}
|
| 278 |
+
role="button"
|
| 279 |
+
tabIndex={doc.status === "ready" ? 0 : -1}
|
| 280 |
+
onClick={() => doc.status === "ready" && !isEditing && onSelectDoc(doc)}
|
| 281 |
+
onKeyDown={(e) => handleRowKeyDown(doc, e)}
|
| 282 |
+
className={`w-full text-left p-2.5 rounded-lg transition-all duration-200 group
|
| 283 |
+
${activeDoc?.id === doc.id
|
| 284 |
+
? "bg-primary/15 border border-primary/30"
|
| 285 |
+
: "hover:bg-sidebar-accent border border-transparent"}
|
| 286 |
+
${doc.status !== "ready" ? "opacity-60 cursor-default" : "cursor-pointer"}`}
|
| 287 |
+
>
|
| 288 |
+
<div className="flex items-start gap-2.5">
|
| 289 |
+
{statusIcon(doc.status)}
|
| 290 |
+
<div className="flex-1 min-w-0">
|
| 291 |
+
{isEditing ? (
|
| 292 |
+
<Input
|
| 293 |
+
value={draftName}
|
| 294 |
+
onChange={(e) => setDraftName(e.target.value)}
|
| 295 |
+
onClick={(e) => e.stopPropagation()}
|
| 296 |
+
onKeyDown={(e) => handleRenameKeyDown(doc, e)}
|
| 297 |
+
disabled={isRenaming}
|
| 298 |
+
autoFocus
|
| 299 |
+
className="h-7 px-2 text-sm font-medium"
|
| 300 |
+
/>
|
| 301 |
+
) : (
|
| 302 |
+
<p
|
| 303 |
+
className="text-sm font-medium truncate leading-tight"
|
| 304 |
+
onDoubleClick={(e) => startRename(doc, e)}
|
| 305 |
+
title="Double-click to rename"
|
| 306 |
+
>
|
| 307 |
+
{doc.original_name}
|
| 308 |
+
</p>
|
| 309 |
+
)}
|
| 310 |
<p className="text-xs text-muted-foreground mt-1">
|
| 311 |
{doc.summary || "📄 No summary available"}
|
| 312 |
</p>
|
|
|
|
| 337 |
</Badge>
|
| 338 |
)}
|
| 339 |
</div>
|
| 340 |
+
</div>
|
| 341 |
+
<div className="flex items-center gap-1 shrink-0">
|
| 342 |
{/* Action buttons (Settings and Delete) are only visible on hover and when the document is ready. The settings button is disabled if the document is not ready, and the delete button shows a loader when the document is being deleted. */}
|
| 343 |
<Button
|
| 344 |
variant="ghost"
|
|
|
|
| 362 |
<Trash2 className="w-3 h-3 text-destructive" />
|
| 363 |
)}
|
| 364 |
</Button>
|
| 365 |
+
</div>
|
| 366 |
</div>
|
| 367 |
</div>
|
| 368 |
+
);
|
| 369 |
+
})}
|
| 370 |
</div>
|
| 371 |
)}
|
| 372 |
</ScrollArea>
|
frontend/src/lib/api.ts
CHANGED
|
@@ -224,6 +224,33 @@ class ApiClient {
|
|
| 224 |
return res.json();
|
| 225 |
}
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
async postForm<T>(path: string, formData: FormData, options?: FetchOptions): Promise<T> {
|
| 228 |
const token = options?.token || this.getToken();
|
| 229 |
const headers: HeadersInit = {};
|
|
|
|
| 224 |
return res.json();
|
| 225 |
}
|
| 226 |
|
| 227 |
+
async patch<T>(path: string, body?: unknown, options?: FetchOptions): Promise<T> {
|
| 228 |
+
const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
|
| 229 |
+
method: "PATCH",
|
| 230 |
+
headers: this.getHeaders(options?.token),
|
| 231 |
+
body: body ? JSON.stringify(body) : undefined,
|
| 232 |
+
...options,
|
| 233 |
+
});
|
| 234 |
+
|
| 235 |
+
// Auto-refresh on 401
|
| 236 |
+
if (res.status === 401 && !options?._skipRefresh) {
|
| 237 |
+
const newToken = await this.tryRefreshToken();
|
| 238 |
+
if (newToken) {
|
| 239 |
+
return this.patch<T>(path, body, { ...options, token: newToken, _skipRefresh: true });
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
if (!res.ok) {
|
| 244 |
+
throw new Error(await this.getErrorMessage(res, res.statusText || "Request failed"));
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
return res.json();
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
async renameDocument<T>(documentId: string, name: string): Promise<T> {
|
| 251 |
+
return this.patch<T>(`/api/v1/documents/${documentId}`, { name });
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
async postForm<T>(path: string, formData: FormData, options?: FetchOptions): Promise<T> {
|
| 255 |
const token = options?.token || this.getToken();
|
| 256 |
const headers: HeadersInit = {};
|