live update

This commit is contained in:
2026-08-27 11:26:04 +05:30
parent 3545819be8
commit 7248e11e65
10 changed files with 404 additions and 23 deletions

View File

@@ -0,0 +1,235 @@
import { useRef, useState, type DragEvent } from 'react';
import { FileSpreadsheet, Trash2, Upload } from 'lucide-react';
/**
* The workbook dropzone.
*
* Astryx's `FileInput` in dropzone mode is a white slab with a dashed rule and
* a bare arrow — correct, and completely mute about what it wants or whether it
* is even available. This one says all three things: what it takes, whether it
* can take it right now, and what it is holding.
*
* Three states, and each looks different at a glance rather than on reading:
*
* - **Waiting** — a tinted panel with a brand-lit icon and an explicit
* "Choose file" affordance, because a dropzone that only accepts a drag is
* unusable to anyone on a laptop with the file already in a dialog.
* - **Dragging** — the brand colour comes up and the panel lifts. A dropzone
* that does not visibly react is one people drop next to.
* - **Filled** — the file itself, with its size and a way to swap it. The old
* control kept saying "Drop an .xlsx here" after you already had.
*
* It stays a real `<input type="file">` under the surface, so the keyboard, the
* screen reader and the OS file dialog all behave as they should — the div is
* decoration over an input, not a replacement for one.
*/
export interface SheetDropzoneProps {
file: File | null;
onFile: (file: File | null) => void;
/** Comma-separated extensions, passed straight to the input. */
accept?: string;
/** Why the control is unavailable. Present means disabled. */
blockedReason?: string;
}
const ACCEPTED = ['.xlsx', '.xls', '.csv'];
export function SheetDropzone({
file,
onFile,
accept = ACCEPTED.join(','),
blockedReason,
}: SheetDropzoneProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const isDisabled = Boolean(blockedReason);
function open() {
if (!isDisabled) inputRef.current?.click();
}
function handleDrop(event: DragEvent<HTMLDivElement>) {
event.preventDefault();
setIsDragging(false);
if (isDisabled) return;
const dropped = event.dataTransfer.files?.[0];
if (dropped) onFile(dropped);
}
/* ── Filled ───────────────────────────────────────────────────────────── */
if (file) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 14,
padding: '14px 16px',
borderRadius: 14,
border: '1px solid var(--color-line)',
background: 'var(--color-surface-subtle)',
}}
>
<span
style={{
display: 'grid',
placeItems: 'center',
width: 40,
height: 40,
flex: 'none',
borderRadius: 11,
background: 'color-mix(in oklab, var(--color-brand) 12%, transparent)',
color: 'var(--color-brand)',
}}
>
<FileSpreadsheet size={19} />
</span>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--color-ink-1)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{file.name}
</div>
<div style={{ fontSize: 12.5, color: 'var(--color-ink-3)', marginTop: 2 }}>
{formatSize(file.size)}
</div>
</div>
<button type="button" onClick={open} style={ghostButtonStyle}>
Replace
</button>
<button
type="button"
onClick={() => onFile(null)}
aria-label="Remove file"
style={{ ...ghostButtonStyle, padding: '7px 9px' }}
>
<Trash2 size={14} />
</button>
<input
ref={inputRef}
type="file"
accept={accept}
onChange={(event) => onFile(event.target.files?.[0] ?? null)}
style={{ display: 'none' }}
/>
</div>
);
}
/* ── Waiting / dragging ───────────────────────────────────────────────── */
return (
<div
onClick={open}
onDragOver={(event) => {
event.preventDefault();
if (!isDisabled) setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
role="button"
tabIndex={isDisabled ? -1 : 0}
aria-disabled={isDisabled}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
open();
}
}}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 8,
padding: '16px 20px',
borderRadius: 13,
border: `1.5px dashed ${
isDragging ? 'var(--color-brand)' : 'color-mix(in oklab, var(--color-brand) 26%, var(--color-line))'
}`,
background: isDragging
? 'color-mix(in oklab, var(--color-brand) 8%, transparent)'
: 'color-mix(in oklab, var(--color-brand) 3%, transparent)',
cursor: isDisabled ? 'not-allowed' : 'pointer',
opacity: isDisabled ? 0.55 : 1,
textAlign: 'center',
outline: 'none',
transition: 'background .16s ease, border-color .16s ease, transform .16s ease',
transform: isDragging ? 'scale(1.004)' : 'none',
}}
>
<span
style={{
display: 'grid',
placeItems: 'center',
width: 34,
height: 34,
borderRadius: 10,
background: isDragging
? 'var(--color-brand)'
: 'color-mix(in oklab, var(--color-brand) 11%, transparent)',
color: isDragging ? '#fff' : 'var(--color-brand)',
transition: 'background .16s ease, color .16s ease',
}}
>
<Upload size={16} />
</span>
<div>
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--color-ink-1)' }}>
{isDisabled
? blockedReason
: isDragging
? 'Drop it here'
: 'Drop your workbook, or click to browse'}
</div>
<div style={{ fontSize: 11.5, color: 'var(--color-ink-3)', marginTop: 2 }}>
{ACCEPTED.join(' · ')}
</div>
</div>
<input
ref={inputRef}
type="file"
accept={accept}
disabled={isDisabled}
onChange={(event) => onFile(event.target.files?.[0] ?? null)}
style={{ display: 'none' }}
/>
</div>
);
}
const ghostButtonStyle: React.CSSProperties = {
flex: 'none',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '7px 12px',
borderRadius: 9,
border: '1px solid var(--color-line)',
background: 'var(--color-surface)',
color: 'var(--color-ink-2)',
fontSize: 12.5,
fontWeight: 600,
fontFamily: 'inherit',
cursor: 'pointer',
};
/** kB up to a megabyte, then MB. Nobody needs "1420800 bytes". */
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${Math.round(kb)} kB`;
return `${(kb / 1024).toFixed(1)} MB`;
}