515 lines
21 KiB
TypeScript
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"
|
|
>
|
|
<
|
|
</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>
|
|
);
|
|
};
|