Skip to content

Commit d55efef

Browse files
authored
Merge pull request #199 from scriptnovaa/feature/issue-66-drag-drop-file-upload
feat: add drag-and-drop file upload (#66)
2 parents 0e3ea80 + 341d7f2 commit d55efef

4 files changed

Lines changed: 284 additions & 0 deletions

File tree

frontend/src/App.jsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { TransactionHistory } from './components/TransactionHistory';
2121
import { FeeDisplay } from './components/FeeDisplay';
2222
import { logError } from './utils/errorLogger';
2323
import { ImportAccountForm } from './components/ImportAccountForm';
24+
import { FileUpload } from './components/FileUpload';
2425
import { useTheme } from './contexts/ThemeContext';
2526
import { useAppState, useAppDispatch, A } from './store/index.js';
2627

@@ -492,6 +493,15 @@ function App() {
492493
</AnimatePresence>
493494
</motion.section>
494495

496+
{/* File Upload */}
497+
<motion.div className="section" variants={v.fadeSlide}>
498+
<h3>File Upload</h3>
499+
<FileUpload />
500+
</motion.div>
501+
502+
</motion.div>
503+
)}
504+
</AnimatePresence>
495505
<AnimatePresence>
496506
{account && (
497507
<motion.div variants={v.stagger} initial="hidden" animate="visible" exit="exit">
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { useRef, useState } from 'react';
2+
import { motion, AnimatePresence } from 'framer-motion';
3+
import { useFileUpload } from '../hooks/useFileUpload';
4+
5+
const STATUS_ICON = { pending: '⏳', uploading: '⬆️', done: '✅', error: '❌' };
6+
7+
function FilePreview({ entry, onRemove }) {
8+
return (
9+
<motion.div
10+
className="fu-file-item"
11+
initial={{ opacity: 0, y: 8 }}
12+
animate={{ opacity: 1, y: 0 }}
13+
exit={{ opacity: 0, scale: 0.9 }}
14+
layout
15+
>
16+
{entry.preview
17+
? <img src={entry.preview} alt={entry.file.name} className="fu-thumb" />
18+
: <span className="fu-file-icon">📄</span>
19+
}
20+
<div className="fu-file-info">
21+
<span className="fu-file-name" title={entry.file.name}>{entry.file.name}</span>
22+
<span className="fu-file-size">{(entry.file.size / 1024).toFixed(1)} KB</span>
23+
{entry.status === 'uploading' && (
24+
<div className="fu-progress-bar" role="progressbar" aria-valuenow={entry.progress} aria-valuemin={0} aria-valuemax={100}>
25+
<div className="fu-progress-fill" style={{ width: `${entry.progress}%` }} />
26+
</div>
27+
)}
28+
{entry.status === 'error' && <span className="fu-file-error">{entry.error}</span>}
29+
</div>
30+
<span className="fu-status-icon" aria-label={entry.status}>{STATUS_ICON[entry.status]}</span>
31+
<button
32+
type="button"
33+
className="fu-remove-btn"
34+
onClick={() => onRemove(entry.id)}
35+
aria-label={`Remove ${entry.file.name}`}
36+
disabled={entry.status === 'uploading'}
37+
>
38+
39+
</button>
40+
</motion.div>
41+
);
42+
}
43+
44+
export function FileUpload({ onUpload, label = 'Upload Files' }) {
45+
const inputRef = useRef(null);
46+
const [dragging, setDragging] = useState(false);
47+
const { files, errors, addFiles, removeFile, uploadAll, clearAll } = useFileUpload(onUpload);
48+
49+
const handleDrop = (e) => {
50+
e.preventDefault();
51+
setDragging(false);
52+
addFiles(e.dataTransfer.files);
53+
};
54+
55+
const handleDragOver = (e) => { e.preventDefault(); setDragging(true); };
56+
const handleDragLeave = () => setDragging(false);
57+
58+
const pendingCount = files.filter((f) => f.status === 'pending').length;
59+
const hasFiles = files.length > 0;
60+
61+
return (
62+
<div className="fu-container">
63+
<div
64+
className={`fu-dropzone${dragging ? ' fu-dropzone--active' : ''}`}
65+
onDrop={handleDrop}
66+
onDragOver={handleDragOver}
67+
onDragLeave={handleDragLeave}
68+
onClick={() => inputRef.current?.click()}
69+
role="button"
70+
tabIndex={0}
71+
aria-label="Drop files here or click to browse"
72+
onKeyDown={(e) => e.key === 'Enter' && inputRef.current?.click()}
73+
>
74+
<span className="fu-dropzone-icon">📁</span>
75+
<p className="fu-dropzone-text">Drag &amp; drop files here, or <u>browse</u></p>
76+
<p className="fu-dropzone-hint">Images, PDF, CSV, TXT · max 10MB · up to 10 files</p>
77+
<input
78+
ref={inputRef}
79+
type="file"
80+
multiple
81+
accept="image/*,.pdf,.txt,.csv"
82+
style={{ display: 'none' }}
83+
onChange={(e) => addFiles(e.target.files)}
84+
aria-hidden="true"
85+
/>
86+
</div>
87+
88+
<AnimatePresence>
89+
{errors.map((err, i) => (
90+
<motion.p key={i} className="fu-error" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
91+
{err}
92+
</motion.p>
93+
))}
94+
</AnimatePresence>
95+
96+
{hasFiles && (
97+
<div className="fu-file-list">
98+
<AnimatePresence>
99+
{files.map((entry) => (
100+
<FilePreview key={entry.id} entry={entry} onRemove={removeFile} />
101+
))}
102+
</AnimatePresence>
103+
</div>
104+
)}
105+
106+
{hasFiles && (
107+
<div className="fu-actions">
108+
{onUpload && pendingCount > 0 && (
109+
<button type="button" onClick={uploadAll}>
110+
{label} ({pendingCount})
111+
</button>
112+
)}
113+
<button type="button" className="btn-clear" onClick={clearAll} style={{ background: 'var(--muted)' }}>
114+
Clear All
115+
</button>
116+
</div>
117+
)}
118+
</div>
119+
);
120+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { useState, useCallback } from 'react';
2+
3+
const ACCEPTED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain', 'text/csv'];
4+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
5+
const MAX_FILES = 10;
6+
7+
function validateFile(file) {
8+
if (!ACCEPTED_TYPES.includes(file.type)) return `"${file.name}": unsupported file type`;
9+
if (file.size > MAX_FILE_SIZE) return `"${file.name}": exceeds 10MB limit`;
10+
return null;
11+
}
12+
13+
async function compressImage(file) {
14+
if (!file.type.startsWith('image/')) return file;
15+
return new Promise((resolve) => {
16+
const img = new Image();
17+
const url = URL.createObjectURL(file);
18+
img.onload = () => {
19+
const canvas = document.createElement('canvas');
20+
const scale = Math.min(1, 1200 / Math.max(img.width, img.height));
21+
canvas.width = img.width * scale;
22+
canvas.height = img.height * scale;
23+
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
24+
URL.revokeObjectURL(url);
25+
canvas.toBlob((blob) => resolve(new File([blob], file.name, { type: 'image/jpeg' })), 'image/jpeg', 0.85);
26+
};
27+
img.onerror = () => { URL.revokeObjectURL(url); resolve(file); };
28+
img.src = url;
29+
});
30+
}
31+
32+
export function useFileUpload(uploadFn) {
33+
const [files, setFiles] = useState([]); // { id, file, preview, status, progress, error }
34+
const [errors, setErrors] = useState([]);
35+
36+
const addFiles = useCallback((incoming) => {
37+
const list = Array.from(incoming);
38+
const validationErrors = [];
39+
const valid = [];
40+
41+
for (const file of list) {
42+
const err = validateFile(file);
43+
if (err) { validationErrors.push(err); continue; }
44+
if (files.length + valid.length >= MAX_FILES) {
45+
validationErrors.push(`Max ${MAX_FILES} files allowed`);
46+
break;
47+
}
48+
const preview = file.type.startsWith('image/') ? URL.createObjectURL(file) : null;
49+
valid.push({ id: `${Date.now()}-${Math.random()}`, file, preview, status: 'pending', progress: 0, error: null });
50+
}
51+
52+
setErrors(validationErrors);
53+
if (valid.length) setFiles((prev) => [...prev, ...valid]);
54+
}, [files.length]);
55+
56+
const removeFile = useCallback((id) => {
57+
setFiles((prev) => {
58+
const entry = prev.find((f) => f.id === id);
59+
if (entry?.preview) URL.revokeObjectURL(entry.preview);
60+
return prev.filter((f) => f.id !== id);
61+
});
62+
}, []);
63+
64+
const uploadAll = useCallback(async () => {
65+
const pending = files.filter((f) => f.status === 'pending');
66+
if (!pending.length || !uploadFn) return;
67+
68+
for (const entry of pending) {
69+
setFiles((prev) => prev.map((f) => f.id === entry.id ? { ...f, status: 'uploading', progress: 0 } : f));
70+
try {
71+
const compressed = await compressImage(entry.file);
72+
await uploadFn(compressed, (progress) => {
73+
setFiles((prev) => prev.map((f) => f.id === entry.id ? { ...f, progress } : f));
74+
});
75+
setFiles((prev) => prev.map((f) => f.id === entry.id ? { ...f, status: 'done', progress: 100 } : f));
76+
} catch (err) {
77+
setFiles((prev) => prev.map((f) => f.id === entry.id ? { ...f, status: 'error', error: err.message } : f));
78+
}
79+
}
80+
}, [files, uploadFn]);
81+
82+
const clearAll = useCallback(() => {
83+
setFiles((prev) => { prev.forEach((f) => f.preview && URL.revokeObjectURL(f.preview)); return []; });
84+
setErrors([]);
85+
}, []);
86+
87+
return { files, errors, addFiles, removeFile, uploadAll, clearAll };
88+
}

frontend/src/index.css

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,3 +733,69 @@ kbd {
733733
line-height: 1.5;
734734
margin: 2px 0;
735735
}
736+
737+
/* FileUpload component */
738+
.fu-container { display: flex; flex-direction: column; gap: 10px; }
739+
740+
.fu-dropzone {
741+
border: 2px dashed var(--border);
742+
border-radius: 8px;
743+
padding: 28px 16px;
744+
text-align: center;
745+
cursor: pointer;
746+
background: var(--surface);
747+
transition: border-color 0.2s, background 0.2s;
748+
outline: none;
749+
}
750+
.fu-dropzone:hover, .fu-dropzone:focus { border-color: var(--primary); }
751+
.fu-dropzone--active { border-color: var(--primary); background: var(--card); }
752+
753+
.fu-dropzone-icon { font-size: 2rem; display: block; margin-bottom: 8px; }
754+
.fu-dropzone-text { font-size: 0.95rem; color: var(--text); margin-bottom: 4px; }
755+
.fu-dropzone-hint { font-size: 0.8rem; color: var(--muted); }
756+
757+
.fu-error { color: var(--danger); font-size: 0.85rem; }
758+
759+
.fu-file-list { display: flex; flex-direction: column; gap: 8px; }
760+
761+
.fu-file-item {
762+
display: flex;
763+
align-items: center;
764+
gap: 10px;
765+
padding: 8px 10px;
766+
background: var(--card);
767+
border-radius: 6px;
768+
border: 1px solid var(--border);
769+
}
770+
771+
.fu-thumb { width: 44px; height: 44px; object-fit: cover; border-radius: 4px; flex-shrink: 0; }
772+
.fu-file-icon { font-size: 1.8rem; flex-shrink: 0; }
773+
774+
.fu-file-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
775+
.fu-file-name { font-size: 0.85rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
776+
.fu-file-size { font-size: 0.75rem; color: var(--muted); }
777+
.fu-file-error { font-size: 0.75rem; color: var(--danger); }
778+
779+
.fu-progress-bar { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
780+
.fu-progress-fill { height: 100%; background: var(--primary); transition: width 0.2s; }
781+
782+
.fu-status-icon { font-size: 1rem; flex-shrink: 0; }
783+
784+
.fu-remove-btn {
785+
background: none;
786+
border: none;
787+
color: var(--muted);
788+
cursor: pointer;
789+
font-size: 0.85rem;
790+
padding: 4px 6px;
791+
min-height: unset;
792+
min-width: unset;
793+
width: auto;
794+
line-height: 1;
795+
flex-shrink: 0;
796+
}
797+
.fu-remove-btn:hover { color: var(--danger); background: none; }
798+
.fu-remove-btn:disabled { opacity: 0.4; cursor: not-allowed; }
799+
800+
.fu-actions { display: flex; gap: 8px; }
801+
.fu-actions button { flex: 1; }

0 commit comments

Comments
 (0)