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