489 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|