Files
pdf/frontend/src/components/ProductHub.tsx
T

515 lines
21 KiB
TypeScript

import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
ASYNC_THRESHOLD_BYTES,
ENGINE_CONVERT_PAIRS,
FORMAT_META,
convertFileMaybeAsync,
downloadBlob,
fetchConvertInfo,
groupedPairs,
inferSourceFormat,
pairDescription,
pairDisplayName,
pairId,
uploadAcceptFromPairs,
type ConverterInfo,
type LayoutMode,
} from '../lib/convertService';
type ProductView = 'chooser' | 'conversion';
interface ProductHubProps {
view: ProductView;
onOpenEditor: () => void;
onOpenConversion: () => void;
onBack: () => void;
}
const FileGlyph = ({ conversion = false }: { conversion?: boolean }) => (
<div
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-[14px] ${
conversion ? 'bg-[#d9f5e8] text-[#087443]' : 'bg-[#e7edff] text-brand-primary'
}`}
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{conversion ? (
<>
<path d="M5 8h12" />
<path d="m14 5 3 3-3 3" />
<path d="M19 16H7" />
<path d="m10 13-3 3 3 3" />
</>
) : (
<>
<path d="M6 3h8l4 4v14H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" />
<path d="M14 3v5h5" />
</>
)}
</svg>
</div>
);
const ArrowIcon = () => (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 12h14" />
<path d="m13 6 6 6-6 6" />
</svg>
);
const CheckIcon = () => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m5 12 4 4L19 6" />
</svg>
);
const BrandMark = () => (
<div className="flex h-10 w-10 items-center justify-center rounded-[12px] bg-[#ff6b4a] text-white shadow-[0_8px_18px_rgba(255,107,74,0.25)]">
<svg
width="21"
height="21"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6 3h8l4 4v14H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" />
<path d="M14 3v5h5" />
<path d="M8 13h8M8 17h5" />
</svg>
</div>
);
export const ProductHub: React.FC<ProductHubProps> = ({
view,
onOpenEditor,
onOpenConversion,
onBack,
}) => {
const fileRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [converters, setConverters] = useState<ConverterInfo[]>(ENGINE_CONVERT_PAIRS);
const [selectedPairId, setSelectedPairId] = useState(pairId('pdf', 'docx'));
const [converting, setConverting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [statusNote, setStatusNote] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void fetchConvertInfo().then((info) => {
if (!cancelled) setConverters(info);
});
return () => {
cancelled = true;
};
}, []);
const sourceFormat = selectedFile ? inferSourceFormat(selectedFile) : null;
const formatGroups = useMemo(() => groupedPairs(converters), [converters]);
const uploadAccept = useMemo(() => uploadAcceptFromPairs(converters), [converters]);
const activePair = useMemo(
() => converters.find((p) => pairId(p.source, p.target) === selectedPairId) ?? converters[0],
[converters, selectedPairId]
);
const formatEnabled = (format: ConverterInfo) => {
if (!sourceFormat) return true;
return format.source === sourceFormat;
};
const formatBadge = (format: ConverterInfo) => {
const ext = FORMAT_META[format.target]?.extension ?? format.target.toUpperCase();
if (!sourceFormat) return ext;
if (formatEnabled(format)) return ext;
return `Need ${format.source.toUpperCase()}`;
};
const destinationHint = (() => {
if (!sourceFormat) {
return 'Destinations come from the conversion engine. Upload a file to see which pairs unlock.';
}
const targets = converters
.filter((p) => p.source === sourceFormat)
.map((p) => (FORMAT_META[p.target]?.label ?? p.target.toUpperCase()));
if (!targets.length) {
return `Detected ${sourceFormat.toUpperCase()}, but the engine has no registered destinations for it.`;
}
return `${sourceFormat.toUpperCase()} selected → ${targets.join(', ')}.`;
})();
const canRun =
!!selectedFile &&
!!sourceFormat &&
!!activePair &&
activePair.source === sourceFormat &&
!converting;
const handleConvert = async () => {
if (!selectedFile || !sourceFormat || !canRun || !activePair) return;
setConverting(true);
setError(null);
setStatusNote(null);
const useAsync = selectedFile.size >= ASYNC_THRESHOLD_BYTES;
if (useAsync) {
setStatusNote(
'Large file — starting async job. Word rebuild OCR on scanned PDFs can take up to 30 minutes. Keep this tab open; the PDF editor is a different, faster path.'
);
}
try {
const result = await convertFileMaybeAsync(
selectedFile,
sourceFormat,
activePair.target,
{
onProgress: (p) => {
const elapsedMin = Math.max(1, Math.round(p.elapsedMs / 60_000));
const budgetMin = Math.round(p.maxWaitMs / 60_000);
setStatusNote(
`Async job ${p.status}${elapsedMin}/${budgetMin} min (OCR + layout). Do not close this tab.`
);
},
}
);
downloadBlob(result.blob, result.filename);
const score =
result.qualityScore != null
? ` Quality score: ${(result.qualityScore * 100).toFixed(0)}%.`
: '';
const warn = result.warnings ? ` Warnings: ${result.warnings}` : '';
const asyncHint = useAsync ? ' (async job path).' : '';
setStatusNote(
`Converted ${sourceFormat}${activePair.target} with fidelity=${result.fidelity}.${asyncHint}${score}${warn}`
);
} catch (err) {
setError(err instanceof Error ? err.message : 'Conversion failed');
setStatusNote(null);
} finally {
setConverting(false);
}
};
const selectedLabel = activePair ? pairDisplayName(activePair) : 'Choose format';
if (view === 'chooser') {
return (
<main className="min-h-full overflow-auto bg-[#f5f7f9] text-[#16232d]">
<div className="mx-auto flex min-h-screen w-full max-w-[1280px] flex-col px-5 py-5 sm:px-9 lg:px-12">
<header className="flex items-center justify-between border-b border-[#dfe5e7] pb-5">
<div className="flex items-center gap-3">
<BrandMark />
<div>
<p className="text-[15px] font-extrabold tracking-tight">DocQube</p>
<p className="text-[11px] font-bold uppercase tracking-[0.18em] text-[#82909a]">
Your document desk
</p>
</div>
</div>
<div className="hidden items-center gap-6 text-[12px] font-semibold text-[#71808a] sm:flex">
<span>All workspaces</span>
<span className="h-2 w-2 rounded-full bg-[#53bd85]" /> <span>Secure & private</span>
</div>
</header>
<section className="grid flex-1 items-center gap-12 py-12 lg:grid-cols-[0.8fr_1.2fr] lg:gap-20 lg:py-16">
<div className="animate-[slideUp_400ms_ease-out]">
<p className="mb-4 text-[11px] font-extrabold uppercase tracking-[0.2em] text-[#ff6b4a]">
Good to see you
</p>
<h1 className="max-w-[500px] text-5xl font-extrabold leading-[1.02] tracking-[-0.04em] sm:text-7xl">
Make your next document <span className="text-[#ff6b4a]">better.</span>
</h1>
<p className="mt-6 max-w-[430px] text-[16px] leading-7 text-[#63727c]">
A focused space to polish PDFs, or move their contents into the tools you already use.
</p>
<div className="mt-8 flex items-center gap-3 text-[12px] font-bold text-[#63727c]">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-[#d9f5e8] text-[#087443]">
<CheckIcon />
</span>{' '}
Files stay on your device
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<button
onClick={onOpenEditor}
className="group relative flex min-h-[300px] flex-col justify-between overflow-hidden rounded-[22px] bg-[#172b3a] p-7 text-left text-white shadow-[0_20px_45px_rgba(23,43,58,0.16)] transition duration-200 hover:-translate-y-1 hover:shadow-[0_25px_55px_rgba(23,43,58,0.24)]"
>
<span className="absolute -right-10 -top-12 h-44 w-44 rounded-full border-[22px] border-white/5" />
<div className="flex h-12 w-12 items-center justify-center rounded-[14px] bg-white/10 text-[#a8d5ff]">
<FileGlyph />
</div>
<div>
<div className="flex items-center justify-between gap-3">
<h2 className="text-[22px] font-extrabold">Edit a PDF</h2>
<ArrowIcon />
</div>
<p className="mt-3 text-[13px] leading-6 text-[#b8c8d1]">
Annotate, rearrange, sign, protect, and convert your document.
</p>
<p className="mt-6 text-[10px] font-bold uppercase tracking-[0.18em] text-[#829aa8]">
Open editor
</p>
</div>
</button>
<button
onClick={onOpenConversion}
className="group flex min-h-[300px] flex-col justify-between rounded-[22px] border border-[#bfe7d1] bg-[#d9f5e8] p-7 text-left text-[#12362a] shadow-[0_20px_45px_rgba(54,137,95,0.1)] transition duration-200 hover:-translate-y-1 hover:border-[#53bd85] hover:shadow-[0_25px_55px_rgba(54,137,95,0.18)]"
>
<div className="flex h-12 w-12 items-center justify-center rounded-[14px] bg-white/70 text-[#087443]">
<FileGlyph conversion />
</div>
<div>
<div className="flex items-center justify-between gap-3">
<h2 className="text-[22px] font-extrabold">Convert a PDF</h2>
<span className="text-[#087443] transition-transform group-hover:translate-x-1">
<ArrowIcon />
</span>
</div>
<p className="mt-3 text-[13px] leading-6 text-[#477363]">
Turn pages into Word, spreadsheets, images, or text using the conversion engine.
</p>
<p className="mt-6 text-[10px] font-bold uppercase tracking-[0.18em] text-[#478c6e]">
Choose a format
</p>
</div>
</button>
</div>
</section>
<footer className="flex items-center justify-between border-t border-[#dfe5e7] pt-5 text-[11px] font-semibold text-[#82909a]">
<span>DocQube workspace</span>
<span>Built for calm, careful work</span>
</footer>
</div>
</main>
);
}
return (
<main className="min-h-full overflow-auto bg-[#f5f7f9] text-[#16232d]">
<div className="mx-auto min-h-screen w-full max-w-[1280px] px-5 py-5 sm:px-9 lg:px-12">
<header className="flex items-center justify-between border-b border-[#dfe5e7] pb-5">
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="flex h-9 w-9 items-center justify-center rounded-[10px] border border-[#d5dfe2] bg-white text-[#63727c] transition hover:border-[#ff6b4a] hover:text-[#ff6b4a]"
aria-label="Back to workspace"
>
&lt;
</button>
<div>
<p className="text-[15px] font-extrabold">Conversion desk</p>
<p className="text-[11px] font-bold uppercase tracking-[0.15em] text-[#82909a]">
Engine-linked convert desk
</p>
</div>
</div>
<button
onClick={onOpenEditor}
className="hidden items-center gap-2 rounded-[10px] border border-[#d5dfe2] bg-white px-3.5 py-2.5 text-[12px] font-bold text-[#63727c] transition hover:border-[#172b3a] hover:text-[#172b3a] sm:flex"
>
<FileGlyph /> Open editor
</button>
</header>
<section className="py-10 sm:py-14">
<div className="flex flex-col justify-between gap-5 sm:flex-row sm:items-end">
<div>
<p className="text-[11px] font-extrabold uppercase tracking-[0.2em] text-[#ff6b4a]">
Conversion desk
</p>
<h1 className="mt-3 text-4xl font-extrabold leading-none tracking-[-0.04em] sm:text-6xl">
Move it forward.
</h1>
<p className="mt-4 max-w-[520px] text-[15px] leading-7 text-[#63727c]">
Formats listed here are the pairs registered in the document conversion engine.
Office rebuilds are honest lossy/medium not ConvertAPI/Word-print perfect.
</p>
</div>
<div className="flex items-center gap-2 text-[11px] font-bold text-[#087443]">
<span className="h-2 w-2 rounded-full bg-[#53bd85]" /> {converters.length} engine pairs
</div>
</div>
</section>
<section className="grid gap-5 lg:grid-cols-[0.75fr_1.25fr]">
<div className="rounded-[22px] bg-[#172b3a] p-6 text-white shadow-[0_20px_45px_rgba(23,43,58,0.14)] sm:p-8">
<div className="flex items-start justify-between">
<div>
<p className="text-[10px] font-extrabold uppercase tracking-[0.2em] text-[#a8d5ff]">
Step 01
</p>
<h2 className="mt-3 text-2xl font-extrabold">Bring your file</h2>
</div>
<FileGlyph conversion />
</div>
<input
ref={fileRef}
type="file"
accept={uploadAccept}
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0] ?? null;
setSelectedFile(file);
setError(null);
setStatusNote(null);
if (file) {
const src = inferSourceFormat(file);
const first = converters.find((f) => f.source === src);
if (first) setSelectedPairId(pairId(first.source, first.target));
}
}}
/>
<button
onClick={() => fileRef.current?.click()}
className="mt-12 flex min-h-[145px] w-full flex-col items-center justify-center rounded-[16px] border border-dashed border-[#6b8796] bg-white/5 px-4 text-center transition hover:border-[#a8d5ff] hover:bg-white/10"
>
<span className="text-[14px] font-bold">
{selectedFile ? selectedFile.name : 'Choose a file from your device'}
</span>
<span className="mt-2 text-[11px] text-[#aebfc8]">
{selectedFile
? `Detected: ${sourceFormat ?? 'unknown'} — click to replace`
: 'PDF, DOCX, XLSX, PPTX, HTML, MD, PNG, JPEG, TIFF'}
</span>
</button>
<div className="mt-6 flex items-center gap-2 text-[11px] text-[#aebfc8]">
<CheckIcon /> Original file remains unchanged
</div>
</div>
<div className="rounded-[22px] border border-[#dce4e5] bg-white p-6 shadow-[0_16px_35px_rgba(35,55,65,0.06)] sm:p-8">
<div className="flex items-start justify-between">
<div>
<p className="text-[10px] font-extrabold uppercase tracking-[0.2em] text-[#ff6b4a]">
Step 02
</p>
<h2 className="mt-3 text-2xl font-extrabold">Choose a destination</h2>
</div>
<span className="rounded-full bg-[#fff0eb] px-3 py-1.5 text-[11px] font-extrabold text-[#d65338]">
{selectedLabel}
</span>
</div>
<p className="mt-4 text-[11px] leading-5 text-[#5f717c]">{destinationHint}</p>
<div className="mt-6 grid gap-6 sm:grid-cols-2">
{formatGroups.map((group) => (
<div key={group.label}>
<h3 className="mb-3 text-[10px] font-extrabold uppercase tracking-[0.18em] text-[#82909a]">
{group.label}
</h3>
<div className="grid gap-2">
{group.formats.map((format) => {
const enabled = formatEnabled(format);
const id = pairId(format.source, format.target);
return (
<button
key={id}
disabled={!enabled}
onClick={() => setSelectedPairId(id)}
className={`flex min-h-[55px] items-center justify-between rounded-[12px] border px-3.5 text-left transition ${
selectedPairId === id
? 'border-[#ff6b4a] bg-[#fff7f4] shadow-[0_4px_12px_rgba(255,107,74,0.1)]'
: enabled
? 'border-[#e2e8e9] bg-white hover:border-[#ffb3a2]'
: 'border-[#eef1f2] bg-[#f7f9f9]'
} ${!enabled ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span>
<span className="block text-[12px] font-extrabold">{pairDisplayName(format)}</span>
<span className="block text-[10px] text-[#82909a]">{pairDescription(format)}</span>
</span>
<span
className={`ml-2 shrink-0 text-[10px] font-extrabold uppercase tracking-[0.08em] ${
selectedPairId === id
? 'text-[#ff6b4a]'
: enabled
? 'text-[#087443]'
: 'text-[#9aa6ad]'
}`}
>
{formatBadge(format)}
</span>
</button>
);
})}
</div>
</div>
))}
</div>
{sourceFormat === 'pdf' && (
<p className="mt-6 rounded-[14px] border border-[#e7ecec] bg-[#f7f9f9] px-4 py-3 text-[11px] leading-5 text-[#63727c]">
The engine chooses the best reconstruction for each page. Word and Excel are
rebuilt from the document (lossy); PNG, JPEG and TIFF are page-accurate images.
</p>
)}
<div className="mt-8 flex flex-col gap-3 border-t border-[#e7ecec] pt-6 sm:flex-row sm:items-center sm:justify-between">
<p className="text-[11px] leading-5 text-[#82909a]">
Live pairs from GET /v1/convert/info. Large files ({Math.round(ASYNC_THRESHOLD_BYTES / (1024 * 1024))}
MB) use async jobs. Destinations that need another source type show Need DOCX/XLSX.
</p>
<button
disabled={!canRun}
onClick={() => void handleConvert()}
className="flex items-center justify-center gap-2 rounded-[10px] bg-[#ff6b4a] px-5 py-3 text-[12px] font-extrabold text-white shadow-[0_8px_18px_rgba(255,107,74,0.2)] transition enabled:hover:bg-[#e5573b] disabled:cursor-not-allowed disabled:bg-[#d7dede] disabled:shadow-none"
>
{converting ? 'Converting… keep tab open' : `Convert to ${selectedLabel}`}{' '}
<ArrowIcon />
</button>
</div>
{error ? (
<p className="mt-4 rounded-[10px] border border-[#f5c2c0] bg-[#fff5f5] px-3 py-2 text-[12px] font-semibold text-[#b42318]">
{error}
</p>
) : null}
{statusNote ? (
<p
className={`mt-4 rounded-[10px] border px-3 py-2 text-[12px] font-semibold ${
converting
? 'border-[#c5d0d6] bg-[#f4f7f8] text-[#3d4f5a]'
: 'border-[#b7e4c7] bg-[#f0fdf4] text-[#166534]'
}`}
>
{statusNote}
</p>
) : null}
</div>
</section>
</div>
</main>
);
};