import { useCallback, useEffect, useRef, useState } from "react"; import { Info, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { cn } from "@/lib/utils"; import { useContentJob } from "@/hooks/useContentJob"; import { getContentJobAsset } from "@/api/contentJobs"; import { submitConvertJob, getConverterCapabilities, type ConverterCapabilities, type ConvertMimeError, } from "@/api/convert"; // --------------------------------------------------------------------------- // Format groups // --------------------------------------------------------------------------- export const FORMAT_GROUPS: Record = { Images: ["png", "jpg", "svg", "webp", "gif"], "Audio/Video": ["mp3", "mp4", "wav", "ogg", "webm"], Documents: ["md", "html", "pdf", "docx"], Data: ["csv", "json", "xlsx"], }; // All formats as a flat list for validation const ALL_FORMATS = Object.values(FORMAT_GROUPS).flat(); interface ConvertBundle { type: "convert-bundle"; outputFilename: string; outputMime: string; outputBase64: string; method: "direct" | "ai-bridge"; } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function isConvertMimeError(result: object): result is ConvertMimeError { return "error" in result && "actualMime" in result; } // --------------------------------------------------------------------------- // Determine whether a format group requires a direct converter // --------------------------------------------------------------------------- function groupNeedsDirectConverter(group: string, capabilities: ConverterCapabilities): boolean { switch (group) { case "Images": return !capabilities.imageConverter; case "Audio/Video": return !capabilities.audioVideoConverter; case "Documents": return !capabilities.docConverter; case "Data": return !capabilities.dataConverter; default: return false; } } function targetGroupForFormat(format: string): string | null { for (const [group, formats] of Object.entries(FORMAT_GROUPS)) { if (formats.includes(format.toLowerCase())) return group; } return null; } // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- interface ConvertPanelProps { initialSourceFormat?: string; initialTargetFormat?: string; companyId: string; } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function ConvertPanel({ initialSourceFormat, initialTargetFormat, companyId }: ConvertPanelProps) { const [file, setFile] = useState(null); const [targetFormat, setTargetFormat] = useState( initialTargetFormat ? initialTargetFormat.toLowerCase() : null, ); const [mimeError, setMimeError] = useState(null); const [dragOver, setDragOver] = useState(false); const [capabilities, setCapabilities] = useState(null); const [convertBundle, setConvertBundle] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const fileInputRef = useRef(null); const job = useContentJob(companyId); // Normalize initialSourceFormat const normalizedSourceFormat = initialSourceFormat ? ALL_FORMATS.includes(initialSourceFormat.toLowerCase()) ? initialSourceFormat.toLowerCase() : null : null; // Fetch converter capabilities on mount useEffect(() => { getConverterCapabilities() .then(setCapabilities) .catch(() => { // Silently fall back — all formats will show AI bridge notice setCapabilities({ imageConverter: false, audioVideoConverter: false, docConverter: false, dataConverter: false, }); }); }, []); // Fetch asset bundle when job completes useEffect(() => { if (job.status === "done" && job.resultAssetId && !convertBundle) { void getContentJobAsset(companyId, job.resultAssetId).then(async (assetUrl) => { const res = await fetch(assetUrl); const text = await res.text(); try { const parsed = JSON.parse(text) as ConvertBundle; setConvertBundle(parsed); } catch { // ignore parse error — error state will show } }); } }, [job.status, job.resultAssetId, convertBundle, companyId]); // --------------------------------------------------------------------------- // File selection helpers // --------------------------------------------------------------------------- function handleFileSelect(selected: File) { setFile(selected); setMimeError(null); setConvertBundle(null); } function handleDragOver(e: React.DragEvent) { e.preventDefault(); e.stopPropagation(); setDragOver(true); } function handleDragLeave(e: React.DragEvent) { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) { setDragOver(false); } } function handleDrop(e: React.DragEvent) { e.preventDefault(); e.stopPropagation(); setDragOver(false); const dropped = e.dataTransfer.files[0]; if (dropped) handleFileSelect(dropped); } function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileInputRef.current?.click(); } } function handleInputChange(e: React.ChangeEvent) { const selected = e.target.files?.[0]; if (selected) handleFileSelect(selected); // Reset input so same file can be reselected e.target.value = ""; } // --------------------------------------------------------------------------- // Convert action // --------------------------------------------------------------------------- const handleConvert = useCallback(async () => { if (!file || !targetFormat) return; setMimeError(null); setConvertBundle(null); setIsSubmitting(true); try { const result = await submitConvertJob(companyId, file, targetFormat); if (isConvertMimeError(result)) { setMimeError(result); setIsSubmitting(false); return; } // Track job via SSE — the hook handles polling // We need to reuse the SSE pattern, but submitConvertJob already submitted. // Re-submit via the job hook SSE tracking by connecting the existing jobId. // However, useContentJob.submit() calls submitContentJob internally. // For convert, we already have the jobId — connect SSE directly. void connectJobSse(result.jobId); } catch (err) { setIsSubmitting(false); job.reset(); } }, [file, targetFormat, companyId]); // Connect SSE for an already-submitted job const connectJobSse = useCallback( async (jobId: string) => { // Directly connect SSE without re-submitting // We'll track progress state manually via EventSource setIsSubmitting(false); // Leverage the hook's internal submit by injecting: since useContentJob doesn't // expose a "track existing job" method, we call submit with a dummy type that // matches — but the convert route already dispatched the job. // Instead, manage SSE here directly (same pattern as useContentJob internals). const url = `/api/companies/${companyId}/content-jobs/${jobId}/events`; const es = new EventSource(url, { withCredentials: true }); es.addEventListener("status", (e: MessageEvent) => { const data = JSON.parse(e.data as string) as { status?: string; resultAssetId?: string | null; errorMessage?: string | null; }; if (data.status === "done" || data.status === "failed") { es.close(); if (data.status === "done" && data.resultAssetId) { void getContentJobAsset(companyId, data.resultAssetId).then(async (assetUrl) => { const res = await fetch(assetUrl); const text = await res.text(); try { const parsed = JSON.parse(text) as ConvertBundle; setConvertBundle(parsed); } catch { // ignore } }); } } // Sync progress into job state via the hook's submit? Not possible. // Track progress via local state here. setJobProgress(data.status ?? "queued"); }); es.addEventListener("error", () => { es.close(); setJobProgressState("failed"); }); }, [companyId], ); // Local SSE state (since we bypass useContentJob for convert submissions) type LocalJobStatus = "idle" | "queued" | "running" | "done" | "failed"; const [jobProgressState, setJobProgressState] = useState("idle"); function setJobProgress(status: string) { setJobProgressState(status as LocalJobStatus); } function progressValue(): number { switch (jobProgressState) { case "queued": return 5; case "running": return 50; case "done": return 100; default: return 0; } } const isConverting = isSubmitting || jobProgressState === "queued" || jobProgressState === "running"; const canConvert = file !== null && targetFormat !== null && !isConverting; // --------------------------------------------------------------------------- // AI fallback notice // --------------------------------------------------------------------------- function showAiFallbackNotice(): boolean { if (!capabilities || !targetFormat) return false; const group = targetGroupForFormat(targetFormat); if (!group) return false; return groupNeedsDirectConverter(group, capabilities); } // --------------------------------------------------------------------------- // Download handler // --------------------------------------------------------------------------- function handleDownload() { if (!convertBundle) return; const binary = atob(convertBundle.outputBase64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } const blob = new Blob([bytes], { type: convertBundle.outputMime }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = convertBundle.outputFilename; a.click(); URL.revokeObjectURL(url); } // --------------------------------------------------------------------------- // Drag-drop zone copy // --------------------------------------------------------------------------- function dropZoneIdleCopy(): string { if (normalizedSourceFormat) { return `Drop a ${normalizedSourceFormat.toUpperCase()} file here or click to browse`; } return "Drop a file here or click to browse"; } // --------------------------------------------------------------------------- // Drag-drop zone classes // --------------------------------------------------------------------------- function dropZoneClasses(): string { const base = "relative flex min-h-[120px] cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; const reducedMotionTransition = "motion-safe:transition-colors motion-safe:duration-150"; if (mimeError) { return cn(base, reducedMotionTransition, "border-destructive bg-destructive/10"); } if (dragOver) { return cn(base, "border-primary bg-accent/30"); } return cn(base, reducedMotionTransition, "border-border bg-secondary"); } // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- return (
{/* Two-column layout: source left, target right */}
{/* ConvertSourceZone */}

Source File

fileInputRef.current?.click()} > {dragOver ? (

Release to select

) : mimeError ? (

File extension does not match content. Got {mimeError.actualMime}, expected{" "} {mimeError.claimedMime}.

) : file ? (

{file.name}

{formatBytes(file.size)}

{file.type || "unknown"}

) : (

{dropZoneIdleCopy()}

)}
{/* ConvertTargetSelector */}

Target Format

{Object.entries(FORMAT_GROUPS).map(([group, formats]) => (

{group}

{formats.map((fmt) => ( ))}
))}
{/* AI fallback notice */} {showAiFallbackNotice() && (

No direct converter for this pair — AI bridge will be used.

)}
{/* ConvertActionBar */}
{convertBundle ? ( ) : jobProgressState === "failed" ? ( <>

Conversion failed — {job.errorMessage ?? "Unknown error"}. Try again.

) : isConverting ? ( <> ) : jobProgressState === "idle" && !file && !targetFormat ? (

No conversion yet

Upload a file and choose a target format to convert.

) : ( )}
); }