| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { motion, AnimatePresence } from "motion/react"; |
| import { useCallback, useRef, useState } from "react"; |
|
|
| import type { DocType } from "@/types"; |
| import { SAMPLE_DOCS, loadSampleAsFile, type SampleDoc } from "@/lib/samples"; |
|
|
| const ACCEPT = "application/pdf,image/png,image/jpeg,image/webp,image/tiff,image/bmp,text/plain,text/html,.txt,.htm,.html"; |
|
|
| interface Props { |
| file: File | null; |
| docType: DocType; |
| setFile: (f: File | null) => void; |
| setDocType: (d: DocType) => void; |
| onExtract: () => void; |
| onSample: (sample: SampleDoc) => void; |
| busy: boolean; |
| } |
|
|
| export function Dropzone({ |
| file, |
| docType, |
| setFile, |
| setDocType, |
| onExtract, |
| onSample, |
| busy, |
| }: Props) { |
| const inputRef = useRef<HTMLInputElement>(null); |
| const [over, setOver] = useState(false); |
|
|
| const onFiles = useCallback( |
| (files: FileList | null) => { |
| if (!files || !files[0]) return; |
| setFile(files[0]); |
| }, |
| [setFile] |
| ); |
|
|
| const onDrop = (e: React.DragEvent) => { |
| e.preventDefault(); |
| setOver(false); |
| onFiles(e.dataTransfer.files); |
| }; |
|
|
| const pickSample = async (sample: SampleDoc) => { |
| try { |
| const f = await loadSampleAsFile(sample); |
| setFile(f); |
| setDocType(sample.docType); |
| onSample(sample); |
| } catch (err) { |
| console.warn("Sample load failed", err); |
| } |
| }; |
|
|
| return ( |
| <div className="flex flex-col gap-6"> |
| {/* --- The dropzone card ------------------------------------------- */} |
| <div |
| onDragOver={(e) => { |
| e.preventDefault(); |
| setOver(true); |
| }} |
| onDragLeave={() => setOver(false)} |
| onDrop={onDrop} |
| data-cursor="focus" |
| className="relative overflow-hidden rounded-[6px] border border-[var(--rule)] bg-[var(--surface)] transition-colors duration-500 ease-editorial" |
| style={{ |
| borderColor: over ? "var(--ink)" : undefined, |
| }} |
| > |
| {/* Corner tick marks — appear when dragging over */} |
| <Corners active={over} /> |
| |
| <div className="flex min-h-[280px] flex-col items-center justify-center gap-4 px-8 py-14 text-center"> |
| <p className="eyebrow">Upload</p> |
| <h2 className="font-display text-[42px] leading-none text-[var(--ink-strong)]"> |
| {file ? "Ready to extract" : "Drop a document"} |
| </h2> |
| <AnimatePresence mode="wait"> |
| {file ? ( |
| <motion.p |
| key="fname" |
| initial={{ opacity: 0, y: 8 }} |
| animate={{ opacity: 1, y: 0 }} |
| exit={{ opacity: 0, y: -8 }} |
| className="font-mono text-[13px] text-[var(--ink-soft)]" |
| > |
| {file.name} |
| <span className="ml-2 text-[var(--ink-mute)]"> |
| · {(file.size / 1024).toFixed(1)} KB |
| </span> |
| </motion.p> |
| ) : ( |
| <motion.p |
| key="prompt" |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| exit={{ opacity: 0 }} |
| className="max-w-[380px] text-[14px] leading-[1.55] text-[var(--ink-soft)]" |
| > |
| PDF or image. Up to 10 MB. We keep nothing — every extraction is |
| stateless. |
| </motion.p> |
| )} |
| </AnimatePresence> |
| |
| <button |
| type="button" |
| onClick={() => inputRef.current?.click()} |
| className="mt-3 text-[13px] font-medium tracking-wide text-[var(--ink)] underline decoration-[var(--rule)] decoration-1 underline-offset-8 transition-colors hover:decoration-[var(--ink)]" |
| > |
| {file ? "Choose a different file" : "or browse for a file"} |
| </button> |
| |
| <input |
| ref={inputRef} |
| type="file" |
| accept={ACCEPT} |
| className="hidden" |
| onChange={(e) => onFiles(e.target.files)} |
| /> |
| </div> |
| |
| {/* Progress bar during extraction */} |
| <AnimatePresence> |
| {busy && ( |
| <motion.div |
| className="absolute inset-x-0 bottom-0 h-[2px] bg-[var(--accent)]" |
| initial={{ scaleX: 0, transformOrigin: "left" }} |
| animate={{ |
| scaleX: [0, 0.7, 0.85, 0.95], |
| transition: { duration: 6, ease: "easeOut" }, |
| }} |
| exit={{ scaleX: 1, opacity: 0, transition: { duration: 0.4 } }} |
| /> |
| )} |
| </AnimatePresence> |
| </div> |
| |
| {/* --- Options row ------------------------------------------------- */} |
| <div className="relative z-10 flex flex-wrap items-center justify-between gap-6 border-t border-[var(--rule)] pt-6"> |
| <DocTypePicker value={docType} onChange={setDocType} /> |
| |
| <button |
| type="button" |
| disabled={!file || busy} |
| onClick={onExtract} |
| data-cursor="focus" |
| style={{ |
| backgroundColor: !file || busy ? "var(--surface-2)" : "var(--accent)", |
| color: !file || busy ? "var(--ink-mute)" : "#ffffff", |
| borderColor: !file || busy ? "var(--rule)" : "var(--accent)", |
| cursor: !file || busy ? "not-allowed" : "pointer", |
| boxShadow: |
| !file || busy |
| ? "none" |
| : "0 1px 0 rgba(0,0,0,0.08), 0 10px 26px -14px rgba(197,58,44,0.55)", |
| }} |
| className="group relative z-10 inline-flex items-center gap-3 rounded-full border px-6 py-3 text-[14px] font-semibold tracking-wide transition-all duration-300 ease-editorial hover:-translate-y-0.5" |
| > |
| {busy ? "Extracting…" : "Extract"} |
| {!busy && <Arrow />} |
| </button> |
| </div> |
| |
| {/* --- Sample buttons --------------------------------------------- */} |
| <div className="flex flex-col gap-2"> |
| <p className="eyebrow">Or try a sample</p> |
| <div className="flex flex-wrap gap-2"> |
| {SAMPLE_DOCS.map((s) => ( |
| <button |
| key={s.id} |
| type="button" |
| disabled={busy} |
| onClick={() => pickSample(s)} |
| className="rounded-full border border-[var(--rule)] bg-[var(--surface)] px-4 py-1.5 text-[12px] tracking-wide text-[var(--ink-soft)] transition-colors duration-300 hover:border-[var(--ink)] hover:text-[var(--ink)] disabled:opacity-50" |
| > |
| {s.label} <span className="text-[var(--ink-mute)]">↗</span> |
| </button> |
| ))} |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
|
|
| function DocTypePicker({ |
| value, |
| onChange, |
| }: { |
| value: DocType; |
| onChange: (v: DocType) => void; |
| }) { |
| const opts: { key: DocType; label: string }[] = [ |
| { key: "receipt", label: "Receipt" }, |
| { key: "invoice", label: "Invoice" }, |
| { key: "filing", label: "10-K" }, |
| ]; |
| return ( |
| <div className="relative z-10 flex items-center gap-2"> |
| <span className="eyebrow mr-2">Type</span> |
| {opts.map((o) => { |
| const active = value === o.key; |
| return ( |
| <button |
| key={o.key} |
| type="button" |
| aria-pressed={active} |
| onClick={() => onChange(o.key)} |
| data-cursor="focus" |
| style={{ |
| backgroundColor: active ? "var(--ink-strong)" : "transparent", |
| color: active ? "var(--surface)" : "var(--ink-soft)", |
| borderColor: active ? "var(--ink-strong)" : "var(--rule)", |
| cursor: "pointer", |
| }} |
| className="relative z-10 rounded-full border px-4 py-1.5 text-[12px] font-medium tracking-wide transition-colors duration-300 hover:border-[var(--ink)] hover:text-[var(--ink)]" |
| > |
| {o.label} |
| </button> |
| ); |
| })} |
| </div> |
| ); |
| } |
|
|
| function Corners({ active }: { active: boolean }) { |
| const stroke = "var(--ink)"; |
| const s = 24; |
| const off = 16; |
| const style = { transition: "opacity 400ms cubic-bezier(0.16,1,0.3,1)" }; |
| return ( |
| <div |
| aria-hidden |
| className="pointer-events-none absolute inset-0" |
| style={{ opacity: active ? 1 : 0, ...style }} |
| > |
| {/* Four L-shaped corner marks */} |
| {[ |
| { top: off, left: off, path: `M0 ${s} L0 0 L${s} 0` }, |
| { top: off, right: off, path: `M${-s} 0 L0 0 L0 ${s}` }, |
| { bottom: off, left: off, path: `M0 ${-s} L0 0 L${s} 0` }, |
| { bottom: off, right: off, path: `M${-s} 0 L0 0 L0 ${-s}` }, |
| ].map((c, i) => ( |
| <svg |
| key={i} |
| width={s} |
| height={s} |
| viewBox={`${c.left !== undefined ? 0 : -s} ${ |
| c.top !== undefined ? 0 : -s |
| } ${s} ${s}`} |
| className="absolute" |
| style={{ |
| top: c.top, |
| left: c.left, |
| right: c.right, |
| bottom: c.bottom, |
| }} |
| > |
| <path d={c.path} stroke={stroke} strokeWidth="1.4" fill="none" /> |
| </svg> |
| ))} |
| </div> |
| ); |
| } |
|
|
| function Arrow() { |
| return ( |
| <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden> |
| <path |
| d="M3 8h10m-4-4 4 4-4 4" |
| stroke="currentColor" |
| strokeWidth="1.4" |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| /> |
| </svg> |
| ); |
| } |
|
|