nexus/ui/src/components/ConvertPanel.tsx

489 lines
17 KiB
TypeScript

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<string, string[]> = {
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<File | null>(null);
const [targetFormat, setTargetFormat] = useState<string | null>(
initialTargetFormat ? initialTargetFormat.toLowerCase() : null,
);
const [mimeError, setMimeError] = useState<ConvertMimeError | null>(null);
const [dragOver, setDragOver] = useState(false);
const [capabilities, setCapabilities] = useState<ConverterCapabilities | null>(null);
const [convertBundle, setConvertBundle] = useState<ConvertBundle | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
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<LocalJobStatus>("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 (
<div className="flex flex-col gap-6">
{/* Two-column layout: source left, target right */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* ConvertSourceZone */}
<div>
<p className="mb-2 text-sm font-medium">Source File</p>
<div
role="button"
tabIndex={0}
aria-label="Upload file — drop here or press Enter to browse"
className={dropZoneClasses()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onKeyDown={handleKeyDown}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleInputChange}
tabIndex={-1}
/>
{dragOver ? (
<p className="text-sm text-primary font-medium">Release to select</p>
) : mimeError ? (
<p className="text-sm text-destructive text-center">
File extension does not match content. Got {mimeError.actualMime}, expected{" "}
{mimeError.claimedMime}.
</p>
) : file ? (
<div className="flex flex-col items-center gap-1 text-center">
<p className="text-sm font-medium">{file.name}</p>
<p className="text-xs text-muted-foreground">{formatBytes(file.size)}</p>
<p className="font-mono text-xs text-muted-foreground">{file.type || "unknown"}</p>
</div>
) : (
<p className="text-sm text-muted-foreground text-center">{dropZoneIdleCopy()}</p>
)}
</div>
</div>
{/* ConvertTargetSelector */}
<div>
<p className="mb-2 text-sm font-medium">Target Format</p>
<div
role="radiogroup"
aria-label="Target format"
className="flex flex-col gap-4"
>
{Object.entries(FORMAT_GROUPS).map(([group, formats]) => (
<div key={group}>
<p className="mb-1.5 text-sm font-medium text-muted-foreground">{group}</p>
<div className="flex flex-wrap gap-2">
{formats.map((fmt) => (
<button
key={fmt}
type="button"
role="radio"
aria-checked={targetFormat === fmt}
aria-label={fmt.toUpperCase()}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium transition-colors",
targetFormat === fmt
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80",
)}
onClick={() => setTargetFormat(fmt)}
>
{fmt.toUpperCase()}
</button>
))}
</div>
</div>
))}
</div>
{/* AI fallback notice */}
{showAiFallbackNotice() && (
<div className="mt-3 flex items-center gap-2 rounded-md bg-secondary p-3">
<Info className="h-[14px] w-[14px] shrink-0 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
No direct converter for this pair AI bridge will be used.
</p>
</div>
)}
</div>
</div>
{/* ConvertActionBar */}
<div className="flex flex-col gap-3">
{convertBundle ? (
<Button onClick={handleDownload}>
Download {convertBundle.outputFilename}
</Button>
) : jobProgressState === "failed" ? (
<>
<p className="text-sm text-destructive">
Conversion failed {job.errorMessage ?? "Unknown error"}. Try again.
</p>
<Button
disabled={!canConvert}
onClick={() => {
setJobProgressState("idle");
void handleConvert();
}}
>
Convert File
</Button>
</>
) : isConverting ? (
<>
<Button disabled>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Converting...
</Button>
<Progress value={progressValue()} className="h-2" />
</>
) : jobProgressState === "idle" && !file && !targetFormat ? (
<div className="flex flex-col gap-1">
<p className="text-base font-semibold text-muted-foreground">No conversion yet</p>
<p className="text-sm text-muted-foreground">
Upload a file and choose a target format to convert.
</p>
<Button disabled className="mt-2 self-start">
Convert File
</Button>
</div>
) : (
<Button disabled={!canConvert} onClick={() => void handleConvert()}>
Convert File
</Button>
)}
</div>
</div>
);
}