Import source: cshare-app/src/pages/UploadPage
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { UploadIcon, CopyIcon, CheckIcon, XIcon, TrashIcon } from '../components/Icons';
|
||||
import { formatFileSize, getFileIcon, getFileIconColor } from '../data/mockData';
|
||||
import { useToast } from '../components/ToastContext';
|
||||
import { uploadFile } from '../lib/api';
|
||||
|
||||
export default function UploadPage() {
|
||||
const navigate = useNavigate();
|
||||
const [files, setFiles] = useState([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [sharedLinks, setSharedLinks] = useState({});
|
||||
const [copiedId, setCopiedId] = useState(null);
|
||||
const dropRef = useRef(null);
|
||||
const addToast = useToast();
|
||||
|
||||
const addFiles = useCallback((fileList) => {
|
||||
const newFiles = fileList.map((f) => ({
|
||||
id: 'f' + Date.now() + Math.random().toString(36).slice(2, 6),
|
||||
rawFile: f,
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
type: f.type || 'application/octet-stream',
|
||||
progress: 0,
|
||||
status: 'uploading',
|
||||
}));
|
||||
setFiles((prev) => [...prev, ...newFiles]);
|
||||
|
||||
newFiles.forEach((file) => {
|
||||
uploadFile(file.rawFile, {
|
||||
onProgress: (progress) => {
|
||||
setFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === file.id ? { ...f, progress: Math.min(progress, 99) } : f
|
||||
)
|
||||
);
|
||||
},
|
||||
})
|
||||
.then((payload) => {
|
||||
setFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === file.id ? { ...f, progress: 100, status: 'done', apiFile: payload.data } : f
|
||||
)
|
||||
);
|
||||
setSharedLinks((prev) => ({ ...prev, [file.id]: payload.data.link }));
|
||||
addToast(`${file.name} uploaded`, 'success');
|
||||
})
|
||||
.catch((error) => {
|
||||
setFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === file.id ? { ...f, progress: 0, status: 'error', error: error.message } : f
|
||||
)
|
||||
);
|
||||
addToast(error.message, 'error');
|
||||
});
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const handleDrop = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
addFiles(droppedFiles);
|
||||
}, [addFiles]);
|
||||
|
||||
const generateShareLink = (fileId) => {
|
||||
const file = files.find((item) => item.id === fileId);
|
||||
if (file?.apiFile?.link) {
|
||||
setSharedLinks((prev) => ({ ...prev, [fileId]: file.apiFile.link }));
|
||||
addToast('Link ready', 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const copyLink = (fileId) => {
|
||||
const link = sharedLinks[fileId];
|
||||
if (!link) return;
|
||||
navigator.clipboard?.writeText(link);
|
||||
setCopiedId(fileId);
|
||||
addToast('Link copied to clipboard', 'success');
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
const removeFile = (fileId) => {
|
||||
setFiles((prev) => prev.filter((f) => f.id !== fileId));
|
||||
setSharedLinks((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[fileId];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileInput = (e) => {
|
||||
const fileList = Array.from(e.target.files || []);
|
||||
if (fileList.length) addFiles(fileList);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-enter" style={{ maxWidth: '760px', margin: '0 auto', padding: '48px 24px' }}>
|
||||
<div style={{ marginBottom: '40px' }}>
|
||||
<h1 className="heading-lg" style={{ marginBottom: '12px' }}>Upload files</h1>
|
||||
<p className="text-secondary">Drop your files below and share them with expiring links.</p>
|
||||
</div>
|
||||
|
||||
{/* Drop Zone — animated dashed border */}
|
||||
<div
|
||||
ref={dropRef}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => document.getElementById('file-input')?.click()}
|
||||
className={`drop-zone-animated ${isDragging ? 'dragging' : ''}`}
|
||||
style={{
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
background: isDragging ? 'var(--accent-muted)' : 'var(--bg-elevated)',
|
||||
padding: 'clamp(48px, 6vh, 80px) 24px',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileInput}
|
||||
/>
|
||||
|
||||
{/* Ambient glow behind upload icon when dragging */}
|
||||
{isDragging && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '200px',
|
||||
height: '200px',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(232,168,56,0.15) 0%, transparent 70%)',
|
||||
pointerEvents: 'none',
|
||||
animation: 'pulseGlow 1.5s ease infinite',
|
||||
}} />
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
margin: '0 auto 20px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--accent-muted)',
|
||||
border: '1px solid var(--accent-border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--accent)',
|
||||
transition: 'all var(--transition-base)',
|
||||
transform: isDragging ? 'scale(1.15)' : 'scale(1)',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}>
|
||||
<UploadIcon size={28} style={{
|
||||
transition: 'transform var(--transition-base)',
|
||||
transform: isDragging ? 'translateY(-4px)' : 'translateY(0)',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<p style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
fontWeight: 600,
|
||||
fontSize: '1.125rem',
|
||||
marginBottom: '8px',
|
||||
color: isDragging ? 'var(--accent)' : 'var(--text-primary)',
|
||||
transition: 'color var(--transition-fast)',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}>
|
||||
{isDragging ? 'Drop your files here' : 'Drag files here to upload'}
|
||||
</p>
|
||||
<p className="text-muted" style={{ fontSize: '0.875rem', position: 'relative', zIndex: 1 }}>
|
||||
or click to browse — up to 500MB per file
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
{files.length > 0 && (
|
||||
<div style={{ marginTop: '32px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
}}>
|
||||
<h3 className="heading-sm">
|
||||
{files.filter((f) => f.status === 'done').length} of {files.length} uploaded
|
||||
</h3>
|
||||
{files.every((f) => f.status === 'done') && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => {
|
||||
files.forEach((f) => {
|
||||
if (!sharedLinks[f.id]) generateShareLink(f.id);
|
||||
});
|
||||
}}
|
||||
style={{ animation: 'scaleIn 0.3s ease' }}
|
||||
>
|
||||
Share All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{files.map((file, index) => (
|
||||
<div
|
||||
key={file.id}
|
||||
style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
border: `1px solid ${file.status === 'done' && sharedLinks[file.id] ? 'var(--accent-border)' : 'var(--border-subtle)'}`,
|
||||
borderLeft: `3px solid ${getFileIconColor(file.type)}`,
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
padding: '16px 20px',
|
||||
animation: `fadeInUp 0.4s ${index * 0.05}s ease both`,
|
||||
transition: 'border-color var(--transition-fast), box-shadow var(--transition-fast)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.boxShadow = 'var(--shadow-md)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
{/* File icon */}
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--bg-surface)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'var(--font-heading)',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.625rem',
|
||||
letterSpacing: '0.05em',
|
||||
color: getFileIconColor(file.type),
|
||||
flexShrink: 0,
|
||||
border: '1px solid var(--border-subtle)',
|
||||
}}>
|
||||
{getFileIcon(file.type)}
|
||||
</div>
|
||||
|
||||
{/* File info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9375rem',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{file.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginTop: '2px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{formatFileSize(file.size)}
|
||||
{file.status === 'done' && (
|
||||
<span style={{
|
||||
color: 'var(--success)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
animation: 'scaleIn 0.3s ease',
|
||||
}}>
|
||||
<CheckIcon size={12} />
|
||||
Complete
|
||||
</span>
|
||||
)}
|
||||
{file.status === 'error' && (
|
||||
<span style={{ color: 'var(--danger)' }}>
|
||||
{file.error || 'Upload failed'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
|
||||
{file.status === 'done' && sharedLinks[file.id] && (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => copyLink(file.id)}
|
||||
style={{ fontFamily: 'var(--font-body)', fontSize: '0.75rem' }}
|
||||
>
|
||||
{copiedId === file.id ? <CheckIcon size={14} style={{ color: 'var(--success)' }} /> : <CopyIcon size={14} />}
|
||||
{copiedId === file.id ? ' Copied' : ' Copy Link'}
|
||||
</button>
|
||||
)}
|
||||
{file.status === 'done' && !sharedLinks[file.id] && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => generateShareLink(file.id)}
|
||||
style={{ animation: 'scaleIn 0.3s ease' }}
|
||||
>
|
||||
Share
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-ghost btn-icon btn-sm"
|
||||
onClick={() => removeFile(file.id)}
|
||||
style={{
|
||||
color: 'var(--danger)',
|
||||
background: 'rgba(232, 72, 72, 0.08)',
|
||||
border: '1px solid rgba(232, 72, 72, 0.15)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(232, 72, 72, 0.15)';
|
||||
e.currentTarget.style.borderColor = 'rgba(232, 72, 72, 0.35)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(232, 72, 72, 0.08)';
|
||||
e.currentTarget.style.borderColor = 'rgba(232, 72, 72, 0.15)';
|
||||
}}
|
||||
title="Remove from queue"
|
||||
>
|
||||
<TrashIcon size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar — with shimmer */}
|
||||
{file.status === 'uploading' && (
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<div style={{
|
||||
height: '4px',
|
||||
borderRadius: '2px',
|
||||
background: 'var(--bg-surface)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div
|
||||
className="shimmer-progress"
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${file.progress}%`,
|
||||
borderRadius: '2px',
|
||||
background: 'linear-gradient(90deg, var(--accent), var(--accent-hover))',
|
||||
transition: 'width 0.3s ease',
|
||||
boxShadow: '0 0 8px rgba(74, 144, 255, 0.3)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--text-muted)',
|
||||
marginTop: '4px',
|
||||
textAlign: 'right',
|
||||
}}>
|
||||
{Math.round(file.progress)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Share link display */}
|
||||
{sharedLinks[file.id] && (
|
||||
<div style={{ animation: 'fadeIn 0.35s ease' }}>
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
padding: '10px 14px',
|
||||
background: 'var(--bg-surface)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '0.8125rem',
|
||||
color: 'var(--accent)',
|
||||
wordBreak: 'break-all',
|
||||
}}>
|
||||
{sharedLinks[file.id]}
|
||||
</div>
|
||||
{/* One-Click Quick Share Buttons */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
marginTop: '8px',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<span style={{ fontSize: '0.625rem', color: 'var(--text-muted)', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', marginRight: '4px' }}>
|
||||
Quick Share:
|
||||
</span>
|
||||
{/* Slack */}
|
||||
<a
|
||||
href={`slack://open?message=${encodeURIComponent('Check out this shared file on CShare: ' + sharedLinks[file.id])}`}
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ padding: '3px 8px', fontSize: '0.6875rem', height: '24px', borderRadius: '100px', background: 'var(--bg-surface)', border: '1px solid var(--border-subtle)' }}
|
||||
>
|
||||
Slack
|
||||
</a>
|
||||
{/* WhatsApp */}
|
||||
<a
|
||||
href={`https://api.whatsapp.com/send?text=${encodeURIComponent('Check out this shared file on CShare: ' + sharedLinks[file.id])}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ padding: '3px 8px', fontSize: '0.6875rem', height: '24px', borderRadius: '100px', background: 'var(--bg-surface)', border: '1px solid var(--border-subtle)' }}
|
||||
>
|
||||
WhatsApp
|
||||
</a>
|
||||
{/* Telegram */}
|
||||
<a
|
||||
href={`https://t.me/share/url?url=${encodeURIComponent(sharedLinks[file.id])}&text=${encodeURIComponent('Check out this shared file on CShare!')}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ padding: '3px 8px', fontSize: '0.6875rem', height: '24px', borderRadius: '100px', background: 'var(--bg-surface)', border: '1px solid var(--border-subtle)' }}
|
||||
>
|
||||
Telegram
|
||||
</a>
|
||||
{/* Email */}
|
||||
<a
|
||||
href={`mailto:?subject=${encodeURIComponent('File shared via CShare')}&body=${encodeURIComponent('Here is the link to the shared file on CShare: ' + sharedLinks[file.id])}`}
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ padding: '3px 8px', fontSize: '0.6875rem', height: '24px', borderRadius: '100px', background: 'var(--bg-surface)', border: '1px solid var(--border-subtle)' }}
|
||||
>
|
||||
Email
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview link — go to file view */}
|
||||
<div style={{ marginTop: '48px', textAlign: 'center' }}>
|
||||
<p className="text-muted" style={{ fontSize: '0.875rem', marginBottom: '12px' }}>
|
||||
Want to see what recipients see?
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => navigate('/preview')}
|
||||
>
|
||||
View sample shared file
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user