aditya0103's picture
v2: SEC 10-K pipeline - schema + section chunker + EDGAR downloader
c6cfb1f
Raw
History Blame Contribute Delete
9.72 kB
/**
* Dropzone — a large paper-textured zone that accepts PDF and images.
*
* States:
* idle : hairline border, "Drop a document" copy, sample buttons visible
* over : border deepens, corner marks pulse in
* selected : filename appears, "extract" primary CTA lights up
* busy : dashed border animates, ghost typewriter under filename
*/
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>
);
}