diff --git a/app/(main)/document/page.tsx b/app/(main)/document/page.tsx index 168bb81..0ec6f7c 100644 --- a/app/(main)/document/page.tsx +++ b/app/(main)/document/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useAuth } from "@/app/lib/context/AuthContext"; import { useApp } from "@/app/lib/context/AppContext"; import Sidebar from "@/app/components/Sidebar"; @@ -8,21 +8,20 @@ import PageHeader from "@/app/components/PageHeader"; import { useToast } from "@/app/components/Toast"; import { usePaginatedList } from "@/app/hooks/usePaginatedList"; import { useInfiniteScroll } from "@/app/hooks/useInfiniteScroll"; -import { apiFetch } from "@/app/lib/apiClient"; +import { + apiFetch, + uploadWithProgress, + type UploadPhase, +} from "@/app/lib/apiClient"; import { DocumentListing } from "@/app/components/document/DocumentListing"; import { DocumentPreview } from "@/app/components/document/DocumentPreview"; import { UploadDocumentModal } from "@/app/components/document/UploadDocumentModal"; -import { DEFAULT_PAGE_LIMIT } from "@/app/lib/constants"; - -export interface Document { - id: string; - fname: string; - object_store_url: string; - signed_url?: string; - file_size?: number; - inserted_at?: string; - updated_at?: string; -} +import { + DEFAULT_PAGE_LIMIT, + MAX_DOCUMENT_SIZE_BYTES, + MAX_DOCUMENT_SIZE_MB, +} from "@/app/lib/constants"; +import { Document } from "@/app/lib/types/document"; export default function DocumentPage() { const toast = useToast(); @@ -34,6 +33,9 @@ export default function DocumentPage() { const [isLoadingDocument, setIsLoadingDocument] = useState(false); const [selectedFile, setSelectedFile] = useState(null); const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadPhase, setUploadPhase] = useState("uploading"); + const abortUploadRef = useRef<(() => void) | null>(null); const { activeKey: apiKey } = useAuth(); const { @@ -59,6 +61,14 @@ export default function DocumentPage() { const file = event.target.files?.[0]; if (!file) return; + if (file.size > MAX_DOCUMENT_SIZE_BYTES) { + toast.error( + `File size exceeds ${MAX_DOCUMENT_SIZE_MB} MB limit. Please select a smaller file within ${MAX_DOCUMENT_SIZE_MB} MB.`, + ); + event.target.value = ""; + return; + } + setSelectedFile(file); }; @@ -66,16 +76,24 @@ export default function DocumentPage() { if (!apiKey || !selectedFile) return; setIsUploading(true); + setUploadProgress(0); + setUploadPhase("uploading"); try { const formData = new FormData(); formData.append("src", selectedFile); - const data = await apiFetch<{ data?: { id: string } }>( + const { promise, abort } = uploadWithProgress<{ data?: { id: string } }>( "/api/document", apiKey.key, - { method: "POST", body: formData }, + formData, + (percent, phase) => { + setUploadProgress(percent); + setUploadPhase(phase); + }, ); + abortUploadRef.current = abort; + const data = await promise; if (selectedFile && data.data?.id) { const fileSizeMap = JSON.parse( localStorage.getItem("document_file_sizes") || "{}", @@ -99,6 +117,7 @@ export default function DocumentPage() { ); } finally { setIsUploading(false); + abortUploadRef.current = null; } }; @@ -198,18 +217,22 @@ export default function DocumentPage() { - {isModalOpen && ( - { - setIsModalOpen(false); - setSelectedFile(null); - }} - /> - )} + { + abortUploadRef.current?.(); + setIsModalOpen(false); + setSelectedFile(null); + setUploadProgress(0); + setUploadPhase("uploading"); + }} + /> ); } diff --git a/app/(main)/knowledge-base/page.tsx b/app/(main)/knowledge-base/page.tsx index 3467d5d..9960290 100644 --- a/app/(main)/knowledge-base/page.tsx +++ b/app/(main)/knowledge-base/page.tsx @@ -8,28 +8,7 @@ import PageHeader from "@/app/components/PageHeader"; import Modal from "@/app/components/Modal"; import { useAuth } from "@/app/lib/context/AuthContext"; import { useApp } from "@/app/lib/context/AppContext"; - -export interface Document { - id: string; - fname: string; - object_store_url: string; - signed_url?: string; - file_size?: number; - inserted_at?: string; - updated_at?: string; -} - -export interface Collection { - id: string; - name?: string; - description?: string; - knowledge_base_id?: string; - inserted_at: string; - updated_at: string; - status?: string; - job_id?: string; - documents?: Document[]; -} +import { Document, Collection } from "@/app/lib/types/document"; export default function KnowledgeBasePage() { const { sidebarCollapsed } = useApp(); @@ -919,37 +898,25 @@ export default function KnowledgeBasePage() { style={{ borderColor: colors.border }} > {/* Create Button */} -
+
- {/* Collections List */}
{isLoading && collections.length === 0 ? ( -
+
Loading knowledge bases...
) : collections.length === 0 ? ( -
+
No knowledge bases yet. Create your first one!
) : ( @@ -963,9 +930,7 @@ export default function KnowledgeBasePage() { fetchCollectionDetails(collection.id); }} className={`border rounded-lg p-3 cursor-pointer transition-colors ${ - selectedCollection?.id === collection.id - ? "ring-2 ring-offset-1" - : "" + selectedCollection?.id === collection.id ? "" : "" }`} style={{ backgroundColor: @@ -978,7 +943,7 @@ export default function KnowledgeBasePage() { : colors.border, }} > -
+
- {/* Two-pane body */} -
- {/* Left pane — doc list */} +
{ + try { + let offset = 0; + let lastProgress = 0; + + const uploadBody = new ReadableStream({ + pull(controller) { + if (offset >= totalSize) { + controller.close(); + return; + } + const end = Math.min(offset + CHUNK_SIZE, totalSize); + controller.enqueue(fileBuffer.subarray(offset, end)); + offset = end; + + const progress = Math.round((offset / totalSize) * 100); + if (progress > lastProgress) { + lastProgress = progress; + writer + .write( + encoder.encode( + JSON.stringify({ phase: "uploading", progress }) + "\n", + ), + ) + .catch(() => {}); + } + }, + }); + + const { status, data } = await apiClient(request, "/api/v1/documents/", { + method: "POST", + body: uploadBody, + headers: { "Content-Type": contentType }, + signal: request.signal, + // @ts-expect-error -- Node fetch supports duplex for streaming request bodies + duplex: "half", + }); + + await writer.write( + encoder.encode(JSON.stringify({ phase: "processing" }) + "\n"), + ); + + await writer.write( + encoder.encode(JSON.stringify({ done: true, status, data }) + "\n"), + ); + } catch (err) { + try { + await writer.write( + encoder.encode( + JSON.stringify({ + error: err instanceof Error ? err.message : String(err), + }) + "\n", + ), + ); + } catch {} + } finally { + await writer.close(); + } + })(); + + return new Response(responseStream.readable, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); } diff --git a/app/components/Modal.tsx b/app/components/Modal.tsx index 38091d4..97c8317 100644 --- a/app/components/Modal.tsx +++ b/app/components/Modal.tsx @@ -58,7 +58,7 @@ export default function Modal({ {showClose && ( diff --git a/app/components/document/DocumentListing.tsx b/app/components/document/DocumentListing.tsx index 296f0c6..0b77130 100644 --- a/app/components/document/DocumentListing.tsx +++ b/app/components/document/DocumentListing.tsx @@ -2,7 +2,7 @@ import { APIKey } from "@/app/lib/types/credentials"; import { formatDate } from "@/app/components/utils"; -import { Document } from "@/app/(main)/document/page"; +import { Document } from "@/app/lib/types/document"; import { RefreshIcon, KeyIcon, @@ -44,7 +44,7 @@ export function DocumentListing({ @@ -55,7 +55,6 @@ export function DocumentListing({ ref={scrollRef} className="flex-1 overflow-y-auto p-4 bg-[hsl(0,0%,98%)]" > - {/* Loading State */} {isLoading && documents.length === 0 ? (
@@ -107,11 +106,11 @@ export function DocumentListing({ onClick={() => onSelect(doc)} className={`border rounded-lg p-3 cursor-pointer transition-colors ${ selectedDocument?.id === doc.id - ? "ring-2 ring-offset-1 bg-[hsl(202,100%,95%)] border-[hsl(202,100%,50%)]" + ? "bg-[hsl(202,100%,95%)] border-[hsl(202,100%,50%)]" : "bg-[hsl(0,0%,100%)] border-[hsl(0,0%,85%)]" }`} > -
+
@@ -128,7 +127,7 @@ export function DocumentListing({ e.stopPropagation(); onDelete(doc.id); }} - className="p-1.5 rounded-md transition-colors shrink-0 border border-[hsl(8,86%,80%)] bg-[hsl(0,0%,100%)] text-[hsl(8,86%,40%)] hover:bg-[hsl(8,86%,95%)]" + className="p-1.5 rounded-md transition-colors shrink-0 border border-[hsl(8,86%,80%)] bg-[hsl(0,0%,100%)] text-[hsl(8,86%,40%)] hover:bg-[hsl(8,86%,95%)] cursor-pointer" title="Delete Document" > diff --git a/app/components/document/DocumentPreview.tsx b/app/components/document/DocumentPreview.tsx index fb62d00..d13f2e3 100644 --- a/app/components/document/DocumentPreview.tsx +++ b/app/components/document/DocumentPreview.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { formatDate } from "@/app/components/utils"; -import { Document } from "@/app/(main)/document/page"; +import { Document } from "@/app/lib/types/document"; import { RefreshIcon, DocumentFileIcon, @@ -18,7 +18,6 @@ interface DocumentPreviewProps { export function DocumentPreview({ document, isLoading }: DocumentPreviewProps) { const [imageLoadError, setImageLoadError] = useState(false); - // Reset error state when document changes useEffect(() => { setImageLoadError(false); }, [document?.id]); @@ -43,11 +42,23 @@ export function DocumentPreview({ document, isLoading }: DocumentPreviewProps) { return mimeTypes[ext] || "application/octet-stream"; }; + const formatFileSize = (size?: number) => { + if (!size) return "N/A"; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`; + return `${(size / (1024 * 1024)).toFixed(2)} MB`; + }; + + const isPreviewable = (filename: string) => { + const mime = getMimeType(filename); + return mime.startsWith("image/") || mime === "application/pdf"; + }; + if (isLoading) { return ( -
-
- +
+
+

Loading document...

@@ -56,13 +67,13 @@ export function DocumentPreview({ document, isLoading }: DocumentPreviewProps) { if (!document) { return ( -
-
- -

+

+
+ +

No document selected

-

+

Select a document from the list to preview

@@ -70,138 +81,120 @@ export function DocumentPreview({ document, isLoading }: DocumentPreviewProps) { ); } + const ext = getFileExtension(document.fname); + const mimeType = getMimeType(document.fname); + return ( -
-
-
-

- {document.fname} -

-
-
-
- File Type -
-
- {getFileExtension(document.fname).toUpperCase() || "Unknown"} -
-
-
-
- File Size -
-
- {document.file_size - ? document.file_size < 1024 * 1024 - ? `${Math.round(document.file_size / 1024)} KB` - : `${(document.file_size / (1024 * 1024)).toFixed(2)} MB` - : "N/A"} -
+
+
+
+
+
+ +

+ {document.fname} +

-
-
- Uploaded at -
-
- {formatDate(document.inserted_at)} -
+
+ {ext && ( + + {ext} + + )} + Size: {formatFileSize(document.file_size)} + Uploaded: {formatDate(document.inserted_at)}
+ {(document.signed_url || document.object_store_url) && + (isPreviewable(document.fname) ? ( + + Download + + ) : ( + + Download + + ))}
+
- {/* Preview Area */} -
-

- Preview -

- - {/* Info message if signed_url is not available */} +
+
{!document.signed_url && document.object_store_url && ( -
-

- Direct preview unavailable. The backend is not generating signed - URLs. Please download the file to view it. +

+

+ Direct preview unavailable. Please download the file to view it.

)} {document.signed_url ? ( - <> - {getMimeType(document.fname).startsWith("image/") ? ( +
+ {mimeType.startsWith("image/") ? ( imageLoadError ? ( -
-

- Failed to load image preview. Check console for details. -

+
+ +

Failed to load image preview

) : ( - {document.fname} { - setImageLoadError(true); - }} - /> +
+ {document.fname} setImageLoadError(true)} + /> +
) - ) : getMimeType(document.fname) === "application/pdf" ? ( + ) : mimeType === "application/pdf" ? (