Files
sencho/frontend/src/components/files/FileUploadDropzone.tsx
T
Anso ea002cd9a0 feat(stack-files): drag-and-drop upload zone (#1207)
* feat(stack-files): drag-and-drop upload zone

The Files-tab dropzone was a click-only button. Drag-and-drop is the
standard file-manager affordance and the audit doc named its absence
as a known gap.

The same dropzone now accepts file drops, gates the affordance on
canEdit, and reuses the existing handleFile pipeline so all the
existing rules apply: 25 MB cap, server-side path validation,
multi-file drops rejected up front with a clear toast (the upload
route only handles one file per request). dragenter+dragover light
the zone in the brand color and swap the label to "Drop to upload";
dragleave honours bubbling from child nodes so the highlight does not
flicker when the cursor crosses an inner icon.

Drag events without a Files payload (text selection, image drag from
another page) are explicitly ignored so the zone does not light up
for non-file content.

* test(stack-files): widen upload-zone E2E selector to match new label

The drag-drop work renamed the dropzone label from 'Upload file' to
'Upload or drop file' (and 'Drop to upload' during hover). The E2E
test in stack-files.spec.ts filtered on the literal /upload file/i,
which no longer matches the new label and the test timed out at the
visibility assertion.

The hidden file input's aria-label='Upload file' was deliberately
preserved, so every other locator in the spec (input[aria-label],
getByLabel) keeps working. Only the role='button' filter needed the
update.

Widen the regex to /upload/i so future copy edits do not re-break
this assertion.
2026-05-24 23:54:35 -04:00

142 lines
4.3 KiB
TypeScript

import { useRef, useState } from 'react';
import { UploadCloud } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ConfirmModal } from '@/components/ui/modal';
import { toast } from '@/components/ui/toast-store';
import { uploadStackFile, UploadConflictError } from '@/lib/stackFilesApi';
const MAX_BYTES = 25 * 1024 * 1024; // 25 MB
interface FileUploadDropzoneProps {
stackName: string;
currentDir: string;
canEdit: boolean;
onUploaded: () => void;
}
export function FileUploadDropzone({
stackName,
currentDir,
canEdit,
onUploaded,
}: FileUploadDropzoneProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [isOver, setIsOver] = useState(false);
const [conflict, setConflict] = useState<File | null>(null);
if (!canEdit) return null;
const runUpload = async (file: File, overwrite: boolean): Promise<void> => {
const loadingId = toast.loading(`Uploading ${file.name}...`);
try {
await uploadStackFile(stackName, currentDir, file, { overwrite });
toast.success(overwrite ? 'Replaced.' : 'Uploaded.');
onUploaded();
} catch (e: unknown) {
if (e instanceof UploadConflictError) {
setConflict(file);
return;
}
toast.error(e instanceof Error ? e.message : 'Upload failed.');
} finally {
toast.dismiss(loadingId);
}
};
const handleFile = (file: File) => {
if (file.size > MAX_BYTES) {
toast.error('File exceeds 25 MB.');
return;
}
void runUpload(file, false);
};
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
const file = ev.target.files?.[0];
if (file) handleFile(file);
ev.target.value = '';
};
const handleDragOver = (ev: React.DragEvent<HTMLDivElement>) => {
if (!ev.dataTransfer.types.includes('Files')) return;
ev.preventDefault();
ev.dataTransfer.dropEffect = 'copy';
if (!isOver) setIsOver(true);
};
const handleDragLeave = (ev: React.DragEvent<HTMLDivElement>) => {
// Ignore leave events that bubble from child elements still within the
// dropzone (relatedTarget is contained by currentTarget); only react when
// the cursor truly leaves the zone.
const next = ev.relatedTarget;
if (next instanceof Node && ev.currentTarget.contains(next)) return;
setIsOver(false);
};
const handleDrop = (ev: React.DragEvent<HTMLDivElement>) => {
ev.preventDefault();
setIsOver(false);
const files = ev.dataTransfer.files;
if (!files || files.length === 0) return;
if (files.length > 1) {
toast.error('Drop one file at a time.');
return;
}
void handleFile(files[0]);
};
return (
<>
<input
ref={inputRef}
type="file"
className="sr-only"
onChange={handleChange}
aria-label="Upload file"
/>
<div
role="button"
tabIndex={0}
className={cn(
'border border-dashed rounded-md p-2 text-xs flex items-center gap-2 cursor-pointer transition-colors',
isOver
? 'border-brand bg-brand/10 text-brand'
: 'border-border text-muted-foreground hover:border-brand/50',
)}
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
inputRef.current?.click();
}
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<UploadCloud className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
{isOver ? 'Drop to upload' : 'Upload or drop file'}
</div>
<ConfirmModal
open={conflict !== null}
onOpenChange={(next) => { if (!next) setConflict(null); }}
onCancel={() => setConflict(null)}
kicker="FILES · REPLACE EXISTING"
title="Replace existing file?"
description={conflict ? `${conflict.name} already exists in this folder.` : ''}
confirmLabel="Replace"
onConfirm={() => {
const file = conflict;
setConflict(null);
if (file) void runUpload(file, true);
}}
>
<p className="text-sm text-muted-foreground">
{conflict?.name ?? 'The file'} already exists. Replacing it overwrites the current contents and cannot be undone.
</p>
</ConfirmModal>
</>
);
}