diff --git a/app/(main)/text-to-speech/page.tsx b/app/(main)/text-to-speech/page.tsx index e68f9766..ed774b9a 100644 --- a/app/(main)/text-to-speech/page.tsx +++ b/app/(main)/text-to-speech/page.tsx @@ -15,8 +15,7 @@ import { useToast } from "@/app/hooks/useToast"; import { useAuth } from "@/app/lib/context/AuthContext"; import { useApp } from "@/app/lib/context/AppContext"; import { apiFetch } from "@/app/lib/apiClient"; -import DatasetsTab from "@/app/components/text-to-speech/DatasetsTab"; -import EvaluationsTab from "@/app/components/text-to-speech/EvaluationsTab"; +import { DatasetsTab, EvaluationsTab } from "@/app/components/text-to-speech"; import { TTSTab, TextSample, diff --git a/app/api/document/[document_id]/preview/route.ts b/app/api/document/[document_id]/preview/route.ts new file mode 100644 index 00000000..84e7ead0 --- /dev/null +++ b/app/api/document/[document_id]/preview/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; +import { DocumentDetailEnvelope } from "@/app/lib/types/document"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ document_id: string }> }, +) { + const { document_id } = await params; + try { + const { data } = await apiClient( + request, + `/api/v1/documents/${document_id}?include_url=true`, + ); + const detail = (data as DocumentDetailEnvelope) || {}; + const signedUrl = detail.data?.signed_url || detail.signed_url; + if (!signedUrl) { + return NextResponse.json( + { error: "Document has no signed URL" }, + { status: 404 }, + ); + } + + const upstream = await fetch(signedUrl); + if (!upstream.ok) { + return NextResponse.json( + { error: `Failed to fetch document (status ${upstream.status})` }, + { status: upstream.status }, + ); + } + + const contentType = + upstream.headers.get("Content-Type") || "application/octet-stream"; + return new Response(upstream.body, { + status: 200, + headers: { + "Content-Type": contentType, + "Cache-Control": "private, max-age=300", + }, + }); + } catch (error: unknown) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +} diff --git a/app/components/analytics/BreakdownPanel.tsx b/app/components/analytics/BreakdownPanel.tsx index 1a8d0f3a..8f694d88 100644 --- a/app/components/analytics/BreakdownPanel.tsx +++ b/app/components/analytics/BreakdownPanel.tsx @@ -138,7 +138,7 @@ export default function BreakdownPanel({ {groupHeader}-wise {metricLabel} -
+
diff --git a/app/components/evaluations/DatasetsTab.tsx b/app/components/evaluations/DatasetsTab.tsx index cb2ec9bc..676e0300 100644 --- a/app/components/evaluations/DatasetsTab.tsx +++ b/app/components/evaluations/DatasetsTab.tsx @@ -8,6 +8,7 @@ import { DatabaseIcon, PlusIcon } from "@/app/components/icons"; import { Button, Modal } from "@/app/components/ui"; import { useToast } from "@/app/hooks/useToast"; import { DatasetListSkeleton } from "@/app/components"; +import { parseCsvRow } from "@/app/lib/utils/csv"; import DatasetCard from "./DatasetCard"; import CreateDatasetForm from "./CreateDatasetForm"; import ViewDatasetModal from "./ViewDatasetModal"; @@ -34,29 +35,6 @@ export interface DatasetsTabProps { toast: ReturnType; } -const parseCsvRow = (line: string): string[] => { - const result: string[] = []; - let current = ""; - let inQuotes = false; - for (let i = 0; i < line.length; i++) { - if (line[i] === '"') { - if (inQuotes && line[i + 1] === '"') { - current += '"'; - i++; - } else { - inQuotes = !inQuotes; - } - } else if (line[i] === "," && !inQuotes) { - result.push(current.trim()); - current = ""; - } else { - current += line[i]; - } - } - result.push(current.trim()); - return result; -}; - export default function DatasetsTab({ leftPanelWidth, datasetName, diff --git a/app/components/knowledge-base/CollectionDetail.tsx b/app/components/knowledge-base/CollectionDetail.tsx index 4ad6190c..4c42fb6c 100644 --- a/app/components/knowledge-base/CollectionDetail.tsx +++ b/app/components/knowledge-base/CollectionDetail.tsx @@ -125,7 +125,7 @@ export default function CollectionDetail({ {documents.length > 0 && (
+ + + {data.headers.map((h, i) => ( + + ))} + + + + {data.rows.map((row, ri) => ( + + {data.headers.map((_, ci) => ( + + ))} + + ))} + +
+ {h || `Column ${i + 1}`} +
+ {row[ci] ?? ""} +
+
+ ); +} diff --git a/app/components/knowledge-base/DocumentPreviewModal.tsx b/app/components/knowledge-base/DocumentPreviewModal.tsx index 3fea31ec..c9d92e51 100644 --- a/app/components/knowledge-base/DocumentPreviewModal.tsx +++ b/app/components/knowledge-base/DocumentPreviewModal.tsx @@ -1,18 +1,27 @@ "use client"; -import { useEffect, useState } from "react"; -import { Loader, Modal } from "@/app/components/ui"; +import { useEffect, useMemo, useState } from "react"; +import { Button, Loader, Modal } from "@/app/components/ui"; +import { DownloadIcon } from "@/app/components/icons"; import { formatDate } from "@/app/components/utils"; -import { Document } from "@/app/lib/types/document"; +import { + Document, + DocumentPreviewKind, + DocumentPreviewModalProps, + DocumentSidebarProps, + PreviewPaneProps, +} from "@/app/lib/types/document"; +import { getDocumentPreviewSource } from "@/app/lib/utils/documentPreview"; +import CsvPreview from "@/app/components/knowledge-base/CsvPreview"; -interface DocumentPreviewModalProps { - open: boolean; - onClose: () => void; - documents: Document[]; - previewDoc: Document | null; - isLoading?: boolean; - onSelectDocument: (doc: Document) => void; -} +// Native iframes are likely to silently fail (cancelled download, bad MIME); +const IFRAME_TIMEOUT_MS: Record = { + native: 12000, + office: 25000, + google: 25000, + csv: 0, + unsupported: 0, +}; export default function DocumentPreviewModal({ open, @@ -23,16 +32,33 @@ export default function DocumentPreviewModal({ onSelectDocument, }: DocumentPreviewModalProps) { const [isFrameLoading, setIsFrameLoading] = useState(false); + const [frameTimedOut, setFrameTimedOut] = useState(false); + + const previewSource = useMemo( + () => getDocumentPreviewSource(previewDoc), + [previewDoc], + ); + const { kind, url: previewUrl } = previewSource; + const usesIframe = + kind === "native" || kind === "office" || kind === "google"; useEffect(() => { - if (!previewDoc?.signed_url) return; + setFrameTimedOut(false); + if (!usesIframe || !previewUrl) { + setIsFrameLoading(false); + return; + } setIsFrameLoading(true); - // Fallback: iframe `onLoad` doesn't fire reliably when the browser - const timer = setTimeout(() => setIsFrameLoading(false), 6000); + const timer = setTimeout(() => { + setIsFrameLoading(false); + setFrameTimedOut(true); + }, IFRAME_TIMEOUT_MS[kind]); return () => clearTimeout(timer); - }, [previewDoc?.id, previewDoc?.signed_url]); + }, [previewDoc?.id, previewUrl, usesIframe, kind]); + + const renderIframe = usesIframe && !!previewUrl && !frameTimedOut; + const showLoader = (isLoading || isFrameLoading) && usesIframe; - const showLoader = isLoading || isFrameLoading; return (
-
- {documents.map((doc) => { - const isSelected = previewDoc?.id === doc.id; - return ( - - ); - })} + +
+ + setIsFrameLoading(false)} + />
+
+ + ); +} -
- {previewDoc?.signed_url ? ( -