feat: add file/image upload support with drag-and-drop
- Upload API at /api/uploads with 50MB limit, MIME type validation, and path traversal protection - Serve uploaded files at /api/uploads/[filename] with immutable caching - FileUpload component with drag-and-drop, progress, and preview - IMAGE notes show uploaded image preview in detail view - FILE notes show download button in detail view - Docker volume for persistent upload storage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f7fa7bc7a1
commit
a065388bbf
|
|
@ -8,6 +8,8 @@ services:
|
||||||
- NEXT_PUBLIC_RSPACE_URL=${NEXT_PUBLIC_RSPACE_URL:-https://rspace.online}
|
- NEXT_PUBLIC_RSPACE_URL=${NEXT_PUBLIC_RSPACE_URL:-https://rspace.online}
|
||||||
- RSPACE_INTERNAL_URL=${RSPACE_INTERNAL_URL:-http://rspace-online:3000}
|
- RSPACE_INTERNAL_URL=${RSPACE_INTERNAL_URL:-http://rspace-online:3000}
|
||||||
- NEXT_PUBLIC_ENCRYPTID_SERVER_URL=${NEXT_PUBLIC_ENCRYPTID_SERVER_URL:-https://encryptid.jeffemmett.com}
|
- NEXT_PUBLIC_ENCRYPTID_SERVER_URL=${NEXT_PUBLIC_ENCRYPTID_SERVER_URL:-https://encryptid.jeffemmett.com}
|
||||||
|
volumes:
|
||||||
|
- uploads_data:/app/uploads
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.rnotes.rule=Host(`rnotes.online`) || Host(`www.rnotes.online`)"
|
- "traefik.http.routers.rnotes.rule=Host(`rnotes.online`) || Host(`www.rnotes.online`)"
|
||||||
|
|
@ -62,3 +64,4 @@ networks:
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
uploads_data:
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export async function POST(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { title, content, type, url, language, tags } = body;
|
const { title, content, type, url, language, tags, fileUrl, mimeType, fileSize } = body;
|
||||||
|
|
||||||
if (!title?.trim()) {
|
if (!title?.trim()) {
|
||||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||||
|
|
@ -60,6 +60,9 @@ export async function POST(
|
||||||
type: type || 'NOTE',
|
type: type || 'NOTE',
|
||||||
url: url || null,
|
url: url || null,
|
||||||
language: language || null,
|
language: language || null,
|
||||||
|
fileUrl: fileUrl || null,
|
||||||
|
mimeType: mimeType || null,
|
||||||
|
fileSize: fileSize || null,
|
||||||
tags: {
|
tags: {
|
||||||
create: tagRecords.map((tag) => ({
|
create: tagRecords.map((tag) => ({
|
||||||
tagId: tag.id,
|
tagId: tag.id,
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export async function GET(request: NextRequest) {
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { title, content, type, notebookId, url, language, tags } = body;
|
const { title, content, type, notebookId, url, language, tags, fileUrl, mimeType, fileSize } = body;
|
||||||
|
|
||||||
if (!title?.trim()) {
|
if (!title?.trim()) {
|
||||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
|
||||||
|
|
@ -71,6 +71,9 @@ export async function POST(request: NextRequest) {
|
||||||
notebookId: notebookId || null,
|
notebookId: notebookId || null,
|
||||||
url: url || null,
|
url: url || null,
|
||||||
language: language || null,
|
language: language || null,
|
||||||
|
fileUrl: fileUrl || null,
|
||||||
|
mimeType: mimeType || null,
|
||||||
|
fileSize: fileSize || null,
|
||||||
tags: {
|
tags: {
|
||||||
create: tagRecords.map((tag) => ({
|
create: tagRecords.map((tag) => ({
|
||||||
tagId: tag.id,
|
tagId: tag.id,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads';
|
||||||
|
|
||||||
|
const MIME_TYPES: Record<string, string> = {
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
'.txt': 'text/plain',
|
||||||
|
'.md': 'text/markdown',
|
||||||
|
'.csv': 'text/csv',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.xml': 'application/xml',
|
||||||
|
'.zip': 'application/zip',
|
||||||
|
'.gz': 'application/gzip',
|
||||||
|
'.js': 'text/javascript',
|
||||||
|
'.ts': 'text/typescript',
|
||||||
|
'.html': 'text/html',
|
||||||
|
'.css': 'text/css',
|
||||||
|
'.py': 'text/x-python',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: { filename: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const filename = params.filename;
|
||||||
|
|
||||||
|
// Validate filename (no path traversal)
|
||||||
|
if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
|
||||||
|
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(UPLOAD_DIR, filename);
|
||||||
|
|
||||||
|
// Validate resolved path stays within UPLOAD_DIR
|
||||||
|
const resolvedPath = path.resolve(filePath);
|
||||||
|
if (!resolvedPath.startsWith(path.resolve(UPLOAD_DIR))) {
|
||||||
|
return NextResponse.json({ error: 'Invalid filename' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await readFile(filePath);
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
|
return new NextResponse(data, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||||
|
'Content-Length': data.length.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Serve file error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to serve file' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { writeFile, mkdir } from 'fs/promises';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads';
|
||||||
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||||
|
|
||||||
|
const ALLOWED_MIME_TYPES = new Set([
|
||||||
|
// Images
|
||||||
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'image/avif',
|
||||||
|
// Documents
|
||||||
|
'application/pdf', 'text/plain', 'text/markdown', 'text/csv',
|
||||||
|
'application/json', 'application/xml',
|
||||||
|
// Archives
|
||||||
|
'application/zip', 'application/gzip',
|
||||||
|
// Code
|
||||||
|
'text/javascript', 'text/typescript', 'text/html', 'text/css',
|
||||||
|
'application/x-python-code', 'text/x-python',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
return name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File | null;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return NextResponse.json({ error: 'File too large (max 50MB)' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `File type "${file.type}" not allowed` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure upload directory exists
|
||||||
|
if (!existsSync(UPLOAD_DIR)) {
|
||||||
|
await mkdir(UPLOAD_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
const ext = path.extname(file.name) || '';
|
||||||
|
const safeName = sanitizeFilename(path.basename(file.name, ext));
|
||||||
|
const uniqueName = `${nanoid(12)}_${safeName}${ext}`;
|
||||||
|
const filePath = path.join(UPLOAD_DIR, uniqueName);
|
||||||
|
|
||||||
|
// Validate the resolved path is within UPLOAD_DIR
|
||||||
|
const resolvedPath = path.resolve(filePath);
|
||||||
|
if (!resolvedPath.startsWith(path.resolve(UPLOAD_DIR))) {
|
||||||
|
return NextResponse.json({ error: 'Invalid file path' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
const bytes = await file.arrayBuffer();
|
||||||
|
await writeFile(filePath, Buffer.from(bytes));
|
||||||
|
|
||||||
|
const fileUrl = `/api/uploads/${uniqueName}`;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
url: fileUrl,
|
||||||
|
filename: uniqueName,
|
||||||
|
originalName: file.name,
|
||||||
|
size: file.size,
|
||||||
|
mimeType: file.type,
|
||||||
|
}, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,9 @@ interface NoteData {
|
||||||
type: string;
|
type: string;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
language: string | null;
|
language: string | null;
|
||||||
|
fileUrl: string | null;
|
||||||
|
mimeType: string | null;
|
||||||
|
fileSize: number | null;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
canvasShapeId: string | null;
|
canvasShapeId: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
@ -212,6 +215,35 @@ export default function NoteDetailPage() {
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Uploaded file/image */}
|
||||||
|
{note.fileUrl && note.type === 'IMAGE' && (
|
||||||
|
<div className="mb-6 rounded-lg overflow-hidden border border-slate-700">
|
||||||
|
<img
|
||||||
|
src={note.fileUrl}
|
||||||
|
alt={note.title}
|
||||||
|
className="max-w-full max-h-[600px] object-contain mx-auto bg-slate-900"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{note.fileUrl && note.type === 'FILE' && (
|
||||||
|
<div className="mb-6 flex items-center gap-3 p-4 bg-slate-800/50 border border-slate-700 rounded-lg">
|
||||||
|
<svg className="w-8 h-8 text-slate-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-white truncate">{note.fileUrl.split('/').pop()}</p>
|
||||||
|
{note.mimeType && <p className="text-xs text-slate-500">{note.mimeType}{note.fileSize ? ` · ${(note.fileSize / 1024).toFixed(1)} KB` : ''}</p>}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={note.fileUrl}
|
||||||
|
download
|
||||||
|
className="px-3 py-1.5 text-sm bg-slate-700 hover:bg-slate-600 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,15 @@ import { Suspense, useState, useEffect } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { NoteEditor } from '@/components/NoteEditor';
|
import { NoteEditor } from '@/components/NoteEditor';
|
||||||
|
import { FileUpload } from '@/components/FileUpload';
|
||||||
|
|
||||||
const NOTE_TYPES = [
|
const NOTE_TYPES = [
|
||||||
{ value: 'NOTE', label: 'Note', desc: 'Rich text note' },
|
{ value: 'NOTE', label: 'Note', desc: 'Rich text note' },
|
||||||
{ value: 'CLIP', label: 'Clip', desc: 'Web clipping' },
|
{ value: 'CLIP', label: 'Clip', desc: 'Web clipping' },
|
||||||
{ value: 'BOOKMARK', label: 'Bookmark', desc: 'Save a URL' },
|
{ value: 'BOOKMARK', label: 'Bookmark', desc: 'Save a URL' },
|
||||||
{ value: 'CODE', label: 'Code', desc: 'Code snippet' },
|
{ value: 'CODE', label: 'Code', desc: 'Code snippet' },
|
||||||
{ value: 'IMAGE', label: 'Image', desc: 'Image URL' },
|
{ value: 'IMAGE', label: 'Image', desc: 'Upload image' },
|
||||||
{ value: 'FILE', label: 'File', desc: 'File reference' },
|
{ value: 'FILE', label: 'File', desc: 'Upload file' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface NotebookOption {
|
interface NotebookOption {
|
||||||
|
|
@ -45,6 +46,9 @@ function NewNoteForm() {
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [language, setLanguage] = useState('');
|
const [language, setLanguage] = useState('');
|
||||||
const [tags, setTags] = useState('');
|
const [tags, setTags] = useState('');
|
||||||
|
const [fileUrl, setFileUrl] = useState('');
|
||||||
|
const [mimeType, setMimeType] = useState('');
|
||||||
|
const [fileSize, setFileSize] = useState(0);
|
||||||
const [notebookId, setNotebookId] = useState(preselectedNotebook || '');
|
const [notebookId, setNotebookId] = useState(preselectedNotebook || '');
|
||||||
const [notebooks, setNotebooks] = useState<NotebookOption[]>([]);
|
const [notebooks, setNotebooks] = useState<NotebookOption[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
@ -71,6 +75,9 @@ function NewNoteForm() {
|
||||||
if (notebookId) body.notebookId = notebookId;
|
if (notebookId) body.notebookId = notebookId;
|
||||||
if (url) body.url = url;
|
if (url) body.url = url;
|
||||||
if (language) body.language = language;
|
if (language) body.language = language;
|
||||||
|
if (fileUrl) body.fileUrl = fileUrl;
|
||||||
|
if (mimeType) body.mimeType = mimeType;
|
||||||
|
if (fileSize) body.fileSize = fileSize;
|
||||||
|
|
||||||
const endpoint = notebookId
|
const endpoint = notebookId
|
||||||
? `/api/notebooks/${notebookId}/notes`
|
? `/api/notebooks/${notebookId}/notes`
|
||||||
|
|
@ -93,7 +100,8 @@ function NewNoteForm() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showUrl = ['CLIP', 'BOOKMARK', 'IMAGE', 'FILE'].includes(type);
|
const showUrl = ['CLIP', 'BOOKMARK'].includes(type);
|
||||||
|
const showUpload = ['IMAGE', 'FILE'].includes(type);
|
||||||
const showLanguage = type === 'CODE';
|
const showLanguage = type === 'CODE';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -162,6 +170,53 @@ function NewNoteForm() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* File upload */}
|
||||||
|
{showUpload && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||||
|
{type === 'IMAGE' ? 'Upload Image' : 'Upload File'}
|
||||||
|
</label>
|
||||||
|
{fileUrl ? (
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-slate-800/50 border border-slate-700 rounded-lg">
|
||||||
|
{type === 'IMAGE' && (
|
||||||
|
<img src={fileUrl} alt="Preview" className="w-16 h-16 object-cover rounded" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-white truncate">{fileUrl.split('/').pop()}</p>
|
||||||
|
<p className="text-xs text-slate-500">{mimeType} · {(fileSize / 1024).toFixed(1)} KB</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setFileUrl(''); setMimeType(''); setFileSize(0); }}
|
||||||
|
className="text-slate-400 hover:text-red-400 text-sm"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FileUpload
|
||||||
|
accept={type === 'IMAGE' ? 'image/*' : undefined}
|
||||||
|
onUpload={(result) => {
|
||||||
|
setFileUrl(result.url);
|
||||||
|
setMimeType(result.mimeType);
|
||||||
|
setFileSize(result.size);
|
||||||
|
if (!title) setTitle(result.originalName);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="mt-2">
|
||||||
|
<label className="block text-xs text-slate-500 mb-1">Or paste a URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
className="w-full px-3 py-2 bg-slate-800/50 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:border-amber-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Language field */}
|
{/* Language field */}
|
||||||
{showLanguage && (
|
{showLanguage && (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface UploadResult {
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
originalName: string;
|
||||||
|
size: number;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileUploadProps {
|
||||||
|
onUpload: (result: UploadResult) => void;
|
||||||
|
accept?: string;
|
||||||
|
maxSize?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileUpload({ onUpload, accept, maxSize = 50 * 1024 * 1024, className }: FileUploadProps) {
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleUpload = useCallback(async (file: File) => {
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
setError(`File too large (max ${Math.round(maxSize / 1024 / 1024)}MB)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const res = await fetch('/api/uploads', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || 'Upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: UploadResult = await res.json();
|
||||||
|
onUpload(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Upload failed');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}, [maxSize, onUpload]);
|
||||||
|
|
||||||
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) handleUpload(file);
|
||||||
|
}, [handleUpload]);
|
||||||
|
|
||||||
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleUpload(file);
|
||||||
|
}, [handleUpload]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||||
|
dragOver
|
||||||
|
? 'border-amber-500 bg-amber-500/10'
|
||||||
|
: 'border-slate-700 hover:border-slate-600 bg-slate-800/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
{uploading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 text-slate-400">
|
||||||
|
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
Uploading...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-slate-400">
|
||||||
|
<svg className="w-8 h-8 mx-auto mb-2 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm">Drop a file here or click to browse</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Max {Math.round(maxSize / 1024 / 1024)}MB</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-400 text-sm mt-2">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue