Spaces:
Running
Running
Merge pull request #341 from Kishalll/feature/document-rename
Browse files- backend/app/routes/documents.py +36 -1
- backend/app/schemas.py +13 -1
- backend/tests/test_documents.py +60 -0
- frontend/src/app/dashboard/page.tsx +9 -0
- frontend/src/components/document/DocumentSidebar.tsx +117 -28
- frontend/src/lib/api.ts +26 -19
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.tasks import process_document
|
|
@@ -417,6 +424,34 @@ def list_documents(
|
|
| 417 |
)
|
| 418 |
|
| 419 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
@router.get("/{document_id}", response_model=DocumentResponse)
|
| 421 |
def get_document(
|
| 422 |
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.tasks import process_document
|
|
|
|
| 424 |
)
|
| 425 |
|
| 426 |
|
| 427 |
+
@router.patch("/{document_id}", response_model=DocumentResponse)
|
| 428 |
+
def rename_document(
|
| 429 |
+
document_id: str,
|
| 430 |
+
rename: DocumentRename,
|
| 431 |
+
user: User = Depends(get_current_user),
|
| 432 |
+
db: Session = Depends(get_db),
|
| 433 |
+
):
|
| 434 |
+
"""
|
| 435 |
+
Rename an uploaded document without changing its stored file or vector data.
|
| 436 |
+
"""
|
| 437 |
+
doc = db.query(Document).filter(
|
| 438 |
+
Document.id == document_id,
|
| 439 |
+
Document.is_deleted.is_(False),
|
| 440 |
+
).first()
|
| 441 |
+
|
| 442 |
+
if not doc:
|
| 443 |
+
raise HTTPException(status_code=404, detail="Document not found")
|
| 444 |
+
|
| 445 |
+
if str(doc.user_id) != str(user.id):
|
| 446 |
+
raise HTTPException(status_code=403, detail="You do not have permission to rename this document")
|
| 447 |
+
|
| 448 |
+
doc.original_name = rename.name
|
| 449 |
+
db.commit()
|
| 450 |
+
db.refresh(doc)
|
| 451 |
+
|
| 452 |
+
return DocumentResponse.model_validate(doc)
|
| 453 |
+
|
| 454 |
+
|
| 455 |
@router.get("/{document_id}", response_model=DocumentResponse)
|
| 456 |
def get_document(
|
| 457 |
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
|
|
@@ -125,6 +125,18 @@ class DocumentResponse(BaseModel):
|
|
| 125 |
from_attributes = True
|
| 126 |
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
class DocumentStatusResponse(BaseModel):
|
| 129 |
id: str
|
| 130 |
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
|
|
|
|
| 125 |
from_attributes = True
|
| 126 |
|
| 127 |
|
| 128 |
+
class DocumentRename(BaseModel):
|
| 129 |
+
name: str = Field(..., min_length=1, max_length=255)
|
| 130 |
+
|
| 131 |
+
@field_validator("name")
|
| 132 |
+
@classmethod
|
| 133 |
+
def validate_name(cls, value: str) -> str:
|
| 134 |
+
stripped = value.strip()
|
| 135 |
+
if not stripped:
|
| 136 |
+
raise ValueError("Document name cannot be empty")
|
| 137 |
+
return stripped
|
| 138 |
+
|
| 139 |
+
|
| 140 |
class DocumentStatusResponse(BaseModel):
|
| 141 |
id: str
|
| 142 |
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
|
@@ -78,7 +78,15 @@ export default function DashboardPage() {
|
|
| 78 |
const [connectionError, setConnectionError] = useState("");
|
| 79 |
const [documentsLoading, setDocumentsLoading] = useState(true);
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
// Auth guard
|
|
|
|
| 82 |
useEffect(() => {
|
| 83 |
if (initialized && !user) router.replace("/login");
|
| 84 |
}, [user, initialized, router]);
|
|
@@ -174,6 +182,7 @@ export default function DashboardPage() {
|
|
| 174 |
setPdfPage(1);
|
| 175 |
}}
|
| 176 |
onDocumentsChange={loadDocuments}
|
|
|
|
| 177 |
/>
|
| 178 |
);
|
| 179 |
|
|
|
|
| 78 |
const [connectionError, setConnectionError] = useState("");
|
| 79 |
const [documentsLoading, setDocumentsLoading] = useState(true);
|
| 80 |
|
| 81 |
+
const handleDocumentRenamed = useCallback((renamedDocument: DocInfo) => {
|
| 82 |
+
setDocuments((current) =>
|
| 83 |
+
current.map((document) => (document.id === renamedDocument.id ? renamedDocument : document))
|
| 84 |
+
);
|
| 85 |
+
setActiveDoc((current) => (current?.id === renamedDocument.id ? renamedDocument : current));
|
| 86 |
+
}, []);
|
| 87 |
+
|
| 88 |
// Auth guard
|
| 89 |
+
|
| 90 |
useEffect(() => {
|
| 91 |
if (initialized && !user) router.replace("/login");
|
| 92 |
}, [user, initialized, router]);
|
|
|
|
| 182 |
setPdfPage(1);
|
| 183 |
}}
|
| 184 |
onDocumentsChange={loadDocuments}
|
| 185 |
+
onDocumentRenamed={handleDocumentRenamed}
|
| 186 |
/>
|
| 187 |
);
|
| 188 |
|
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,14 +131,67 @@ export default function DocumentSidebar({ documents = [], activeDoc, loading = f
|
|
| 119 |
setSettingsDoc(doc);
|
| 120 |
};
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
const handleDocumentKeyDown = (doc: DocInfo, e: React.KeyboardEvent) => {
|
| 123 |
-
if (doc.status !== "ready") return;
|
| 124 |
if (e.key === "Enter" || e.key === " ") {
|
| 125 |
e.preventDefault();
|
| 126 |
onSelectDoc(doc);
|
| 127 |
}
|
| 128 |
};
|
| 129 |
|
|
|
|
| 130 |
const statusIcon = (status: string) => {
|
| 131 |
switch (status) {
|
| 132 |
case "ready":
|
|
@@ -205,28 +270,49 @@ export default function DocumentSidebar({ documents = [], activeDoc, loading = f
|
|
| 205 |
</div>
|
| 206 |
) : (
|
| 207 |
<div className="space-y-1 pb-3">
|
| 208 |
-
{documents.map((doc) =>
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
${
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
<p className="text-xs text-muted-foreground mt-1">
|
| 231 |
{doc.summary || "📄 No summary available"}
|
| 232 |
</p>
|
|
@@ -257,8 +343,8 @@ export default function DocumentSidebar({ documents = [], activeDoc, loading = f
|
|
| 257 |
</Badge>
|
| 258 |
)}
|
| 259 |
</div>
|
| 260 |
-
|
| 261 |
-
|
| 262 |
{/* 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. */}
|
| 263 |
<Button
|
| 264 |
variant="ghost"
|
|
@@ -286,10 +372,13 @@ export default function DocumentSidebar({ documents = [], activeDoc, loading = f
|
|
| 286 |
<Trash2 className="w-3 h-3 text-destructive" />
|
| 287 |
)}
|
| 288 |
</Button>
|
|
|
|
| 289 |
</div>
|
| 290 |
</div>
|
| 291 |
-
|
| 292 |
-
)
|
|
|
|
|
|
|
| 293 |
</div>
|
| 294 |
)}
|
| 295 |
</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 handleDocumentKeyDown = (doc: DocInfo, e: React.KeyboardEvent) => {
|
| 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 |
+
|
| 195 |
const statusIcon = (status: string) => {
|
| 196 |
switch (status) {
|
| 197 |
case "ready":
|
|
|
|
| 270 |
</div>
|
| 271 |
) : (
|
| 272 |
<div className="space-y-1 pb-3">
|
| 273 |
+
{documents.map((doc) => {
|
| 274 |
+
const isEditing = editingDocId === doc.id;
|
| 275 |
+
const isRenaming = renamingDocId === doc.id;
|
| 276 |
+
|
| 277 |
+
return (
|
| 278 |
+
<div
|
| 279 |
+
key={doc.id}
|
| 280 |
+
role="button"
|
| 281 |
+
tabIndex={doc.status === "ready" ? 0 : -1}
|
| 282 |
+
aria-disabled={doc.status !== "ready"}
|
| 283 |
+
aria-current={activeDoc?.id === doc.id ? "true" : undefined}
|
| 284 |
+
aria-label={`Select document ${doc.original_name}. Status: ${doc.status}`}
|
| 285 |
+
onClick={() => doc.status === "ready" && !isEditing && onSelectDoc(doc)}
|
| 286 |
+
onKeyDown={(e) => handleDocumentKeyDown(doc, e)}
|
| 287 |
+
className={`w-full text-left p-2.5 rounded-lg transition-all duration-200 group
|
| 288 |
+
${activeDoc?.id === doc.id
|
| 289 |
+
? "bg-primary/15 border border-primary/30"
|
| 290 |
+
: "hover:bg-sidebar-accent border border-transparent"}
|
| 291 |
+
${doc.status !== "ready" ? "opacity-60 cursor-default" : "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"}`}
|
| 292 |
+
>
|
| 293 |
+
<div className="flex items-start gap-2.5">
|
| 294 |
+
{statusIcon(doc.status)}
|
| 295 |
+
<div className="flex-1 min-w-0">
|
| 296 |
+
{isEditing ? (
|
| 297 |
+
<Input
|
| 298 |
+
value={draftName}
|
| 299 |
+
onChange={(e) => setDraftName(e.target.value)}
|
| 300 |
+
onClick={(e) => e.stopPropagation()}
|
| 301 |
+
onKeyDown={(e) => handleRenameKeyDown(doc, e)}
|
| 302 |
+
disabled={isRenaming}
|
| 303 |
+
autoFocus
|
| 304 |
+
className="h-7 px-2 text-sm font-medium"
|
| 305 |
+
/>
|
| 306 |
+
) : (
|
| 307 |
+
<p
|
| 308 |
+
className="text-sm font-medium truncate leading-tight"
|
| 309 |
+
onDoubleClick={(e) => startRename(doc, e)}
|
| 310 |
+
title="Double-click to rename"
|
| 311 |
+
>
|
| 312 |
+
{doc.original_name}
|
| 313 |
+
</p>
|
| 314 |
+
)}
|
| 315 |
+
|
| 316 |
<p className="text-xs text-muted-foreground mt-1">
|
| 317 |
{doc.summary || "📄 No summary available"}
|
| 318 |
</p>
|
|
|
|
| 343 |
</Badge>
|
| 344 |
)}
|
| 345 |
</div>
|
| 346 |
+
</div>
|
| 347 |
+
<div className="flex items-center gap-1 shrink-0">
|
| 348 |
{/* 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. */}
|
| 349 |
<Button
|
| 350 |
variant="ghost"
|
|
|
|
| 372 |
<Trash2 className="w-3 h-3 text-destructive" />
|
| 373 |
)}
|
| 374 |
</Button>
|
| 375 |
+
</div>
|
| 376 |
</div>
|
| 377 |
</div>
|
| 378 |
+
);
|
| 379 |
+
})}
|
| 380 |
+
|
| 381 |
+
|
| 382 |
</div>
|
| 383 |
)}
|
| 384 |
</ScrollArea>
|
frontend/src/lib/api.ts
CHANGED
|
@@ -228,18 +228,11 @@ class ApiClient {
|
|
| 228 |
return res.json();
|
| 229 |
}
|
| 230 |
|
| 231 |
-
async
|
| 232 |
-
const token = options?.token || this.getToken();
|
| 233 |
-
const headers: HeadersInit = {};
|
| 234 |
-
if (token) {
|
| 235 |
-
headers["Authorization"] = `Bearer ${token}`;
|
| 236 |
-
}
|
| 237 |
-
// Don't set Content-Type — browser sets multipart boundary automatically
|
| 238 |
-
|
| 239 |
const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
|
| 240 |
-
method: "
|
| 241 |
-
headers,
|
| 242 |
-
body:
|
| 243 |
...options,
|
| 244 |
});
|
| 245 |
|
|
@@ -247,39 +240,53 @@ class ApiClient {
|
|
| 247 |
if (res.status === 401 && !options?._skipRefresh) {
|
| 248 |
const newToken = await this.tryRefreshToken();
|
| 249 |
if (newToken) {
|
| 250 |
-
return this.
|
| 251 |
}
|
| 252 |
}
|
| 253 |
|
| 254 |
if (!res.ok) {
|
| 255 |
-
throw new Error(await this.getErrorMessage(res, res.statusText || "
|
| 256 |
}
|
| 257 |
|
| 258 |
return res.json();
|
| 259 |
}
|
| 260 |
|
| 261 |
-
async
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
|
| 263 |
-
method: "
|
| 264 |
-
headers
|
| 265 |
-
body:
|
| 266 |
...options,
|
| 267 |
});
|
| 268 |
|
|
|
|
| 269 |
if (res.status === 401 && !options?._skipRefresh) {
|
| 270 |
const newToken = await this.tryRefreshToken();
|
| 271 |
if (newToken) {
|
| 272 |
-
return this.
|
| 273 |
}
|
| 274 |
}
|
| 275 |
|
| 276 |
if (!res.ok) {
|
| 277 |
-
throw new Error(await this.getErrorMessage(res, res.statusText || "
|
| 278 |
}
|
| 279 |
|
| 280 |
return res.json();
|
| 281 |
}
|
| 282 |
|
|
|
|
|
|
|
| 283 |
async delete<T>(path: string, options?: FetchOptions): Promise<T> {
|
| 284 |
const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
|
| 285 |
method: "DELETE",
|
|
|
|
| 228 |
return res.json();
|
| 229 |
}
|
| 230 |
|
| 231 |
+
async patch<T>(path: string, body?: unknown, options?: FetchOptions): Promise<T> {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
|
| 233 |
+
method: "PATCH",
|
| 234 |
+
headers: this.getHeaders(options?.token),
|
| 235 |
+
body: body ? JSON.stringify(body) : undefined,
|
| 236 |
...options,
|
| 237 |
});
|
| 238 |
|
|
|
|
| 240 |
if (res.status === 401 && !options?._skipRefresh) {
|
| 241 |
const newToken = await this.tryRefreshToken();
|
| 242 |
if (newToken) {
|
| 243 |
+
return this.patch<T>(path, body, { ...options, token: newToken, _skipRefresh: true });
|
| 244 |
}
|
| 245 |
}
|
| 246 |
|
| 247 |
if (!res.ok) {
|
| 248 |
+
throw new Error(await this.getErrorMessage(res, res.statusText || "Request failed"));
|
| 249 |
}
|
| 250 |
|
| 251 |
return res.json();
|
| 252 |
}
|
| 253 |
|
| 254 |
+
async renameDocument<T>(documentId: string, name: string): Promise<T> {
|
| 255 |
+
return this.patch<T>(`/api/v1/documents/${documentId}`, { name });
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
async postForm<T>(path: string, formData: FormData, options?: FetchOptions): Promise<T> {
|
| 259 |
+
const token = options?.token || this.getToken();
|
| 260 |
+
const headers: HeadersInit = {};
|
| 261 |
+
if (token) {
|
| 262 |
+
headers["Authorization"] = `Bearer ${token}`;
|
| 263 |
+
}
|
| 264 |
+
// Don't set Content-Type — browser sets multipart boundary automatically
|
| 265 |
+
|
| 266 |
const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
|
| 267 |
+
method: "POST",
|
| 268 |
+
headers,
|
| 269 |
+
body: formData,
|
| 270 |
...options,
|
| 271 |
});
|
| 272 |
|
| 273 |
+
// Auto-refresh on 401
|
| 274 |
if (res.status === 401 && !options?._skipRefresh) {
|
| 275 |
const newToken = await this.tryRefreshToken();
|
| 276 |
if (newToken) {
|
| 277 |
+
return this.postForm<T>(path, formData, { ...options, token: newToken, _skipRefresh: true });
|
| 278 |
}
|
| 279 |
}
|
| 280 |
|
| 281 |
if (!res.ok) {
|
| 282 |
+
throw new Error(await this.getErrorMessage(res, res.statusText || "Upload failed"));
|
| 283 |
}
|
| 284 |
|
| 285 |
return res.json();
|
| 286 |
}
|
| 287 |
|
| 288 |
+
|
| 289 |
+
|
| 290 |
async delete<T>(path: string, options?: FetchOptions): Promise<T> {
|
| 291 |
const res = await this.fetchWithConnectionError(`${this.baseUrl}${path}`, {
|
| 292 |
method: "DELETE",
|