Import source: cshare-app/src/pages/DashboardPage
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,717 @@
|
|||||||
|
import { useState, useMemo, useEffect, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { CopyIcon, CheckIcon, TrashIcon, EyeIcon, ChartIcon, ClockIcon, FileIcon, DownloadIcon } from '../components/Icons';
|
||||||
|
import { formatFileSize, formatDateRelative, getFileIcon, getFileIconColor } from '../data/mockData';
|
||||||
|
import { useToast } from '../components/ToastContext';
|
||||||
|
import { deleteFile as deleteRemoteFile, getDashboardStats, listFiles, getDashboardActivity, getDashboardStorage, downloadUrl } from '../lib/api';
|
||||||
|
import { SkeletonStatCards, SkeletonFileRows } from '../components/SkeletonLoader';
|
||||||
|
import { StorageBar } from '../components/StorageBar';
|
||||||
|
import { ActivityFeed } from '../components/ActivityFeed';
|
||||||
|
import FileDetailSlideOver from '../components/FileDetailSlideOver';
|
||||||
|
|
||||||
|
function AnimatedNumber({ value, duration = 1200 }) {
|
||||||
|
const [display, setDisplay] = useState(0);
|
||||||
|
const prevValue = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const start = prevValue.current;
|
||||||
|
const end = typeof value === 'number' ? value : 0;
|
||||||
|
if (start === end) { setDisplay(end); return; }
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
const animate = (now) => {
|
||||||
|
const elapsed = now - startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
setDisplay(Math.round(start + (end - start) * eased));
|
||||||
|
if (progress < 1) requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
prevValue.current = end;
|
||||||
|
return () => {};
|
||||||
|
}, [value, duration]);
|
||||||
|
|
||||||
|
return <>{typeof value === 'string' ? value : display.toLocaleString()}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [files, setFiles] = useState([]);
|
||||||
|
const [copiedId, setCopiedId] = useState(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState(null);
|
||||||
|
const [selectedFile, setSelectedFile] = useState(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
const [sortBy, setSortBy] = useState('date');
|
||||||
|
const [filterType, setFilterType] = useState('all');
|
||||||
|
const [selectedIds, setSelectedIds] = useState(new Set());
|
||||||
|
const [activities, setActivities] = useState([]);
|
||||||
|
const [storageBreakdownReal, setStorageBreakdownReal] = useState(null);
|
||||||
|
const [showBatchDeleteConfirm, setShowBatchDeleteConfirm] = useState(false);
|
||||||
|
const addToast = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedIds.size === 0) {
|
||||||
|
setShowBatchDeleteConfirm(false);
|
||||||
|
}
|
||||||
|
}, [selectedIds.size]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
Promise.all([
|
||||||
|
listFiles(),
|
||||||
|
getDashboardStats(),
|
||||||
|
getDashboardActivity(),
|
||||||
|
getDashboardStorage(),
|
||||||
|
])
|
||||||
|
.then(([remoteFiles, remoteStats, remoteActivity, remoteStorage]) => {
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
const CATEGORY_MAP = {
|
||||||
|
document: { label: 'Documents', color: '#E84848' },
|
||||||
|
image: { label: 'Images', color: '#38C87A' },
|
||||||
|
video: { label: 'Videos', color: '#F5A623' },
|
||||||
|
audio: { label: 'Audio', color: '#9B59B6' },
|
||||||
|
archive: { label: 'Archives', color: '#4A90FF' },
|
||||||
|
other: { label: 'Other', color: '#9494A0' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const byType = {
|
||||||
|
'Documents': { size: 0, color: '#E84848' },
|
||||||
|
'Archives': { size: 0, color: '#4A90FF' },
|
||||||
|
'Images': { size: 0, color: '#38C87A' },
|
||||||
|
'Videos': { size: 0, color: '#F5A623' },
|
||||||
|
'Audio': { size: 0, color: '#9B59B6' },
|
||||||
|
'Other': { size: 0, color: '#9494A0' },
|
||||||
|
};
|
||||||
|
|
||||||
|
let used = 0;
|
||||||
|
if (remoteStorage?.breakdown) {
|
||||||
|
remoteStorage.breakdown.forEach((item) => {
|
||||||
|
const config = CATEGORY_MAP[item.category] || CATEGORY_MAP.other;
|
||||||
|
byType[config.label] = {
|
||||||
|
size: item.sizeBytes,
|
||||||
|
color: config.color,
|
||||||
|
};
|
||||||
|
used += item.sizeBytes;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedStorage = {
|
||||||
|
used,
|
||||||
|
total: 536870912, // 512 MB
|
||||||
|
byType,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mappedActivities = (remoteActivity || []).map((act) => ({
|
||||||
|
...act,
|
||||||
|
at: act.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setFiles(remoteFiles);
|
||||||
|
setStats(remoteStats);
|
||||||
|
setActivities(mappedActivities);
|
||||||
|
setStorageBreakdownReal(mappedStorage);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
if (active) addToast('Could not load files. Check your connection.', 'error');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
const filteredFiles = useMemo(() => {
|
||||||
|
let result = files;
|
||||||
|
|
||||||
|
// Search
|
||||||
|
if (searchQuery) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
result = result.filter(
|
||||||
|
(f) => f.name.toLowerCase().includes(q) || f.uploader.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type filter
|
||||||
|
if (filterType !== 'all') {
|
||||||
|
result = result.filter((f) => {
|
||||||
|
if (filterType === 'image') return f.type.startsWith('image/');
|
||||||
|
if (filterType === 'document') return f.type === 'application/pdf' || f.type.startsWith('text/');
|
||||||
|
if (filterType === 'archive') return f.type === 'application/zip';
|
||||||
|
return f.type === 'application/octet-stream';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
result = [...result].sort((a, b) => {
|
||||||
|
if (sortBy === 'name') return a.name.localeCompare(b.name);
|
||||||
|
if (sortBy === 'size') return b.size - a.size;
|
||||||
|
if (sortBy === 'views') return b.views - a.views;
|
||||||
|
return new Date(b.uploadedAt) - new Date(a.uploadedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [files, searchQuery, filterType, sortBy]);
|
||||||
|
|
||||||
|
const totalViews = stats?.totalViews ?? files.reduce((sum, f) => sum + f.views, 0);
|
||||||
|
const totalDownloads = stats?.totalDownloads ?? files.reduce((sum, f) => sum + f.downloads, 0);
|
||||||
|
|
||||||
|
const copyLink = (file) => {
|
||||||
|
navigator.clipboard?.writeText(file.link);
|
||||||
|
setCopiedId(file.id);
|
||||||
|
addToast('Link copied to clipboard', 'success');
|
||||||
|
setTimeout(() => setCopiedId(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFile = async (fileId) => {
|
||||||
|
const previous = files;
|
||||||
|
setFiles((prev) => prev.filter((f) => f.id !== fileId));
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
try {
|
||||||
|
await deleteRemoteFile(fileId);
|
||||||
|
addToast('File deleted', 'info');
|
||||||
|
} catch (error) {
|
||||||
|
setFiles(previous);
|
||||||
|
addToast(error.message, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelect = (fileId) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(fileId)) next.delete(fileId);
|
||||||
|
else next.add(fileId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
if (selectedIds.size === filteredFiles.length) {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedIds(new Set(filteredFiles.map((f) => f.id)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const batchDelete = async () => {
|
||||||
|
const ids = [...selectedIds];
|
||||||
|
const previous = files;
|
||||||
|
setFiles((prev) => prev.filter((f) => !selectedIds.has(f.id)));
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
try {
|
||||||
|
await Promise.all(ids.map((id) => deleteRemoteFile(id)));
|
||||||
|
addToast(`${ids.length} file${ids.length > 1 ? 's' : ''} deleted`, 'info');
|
||||||
|
} catch {
|
||||||
|
setFiles(previous);
|
||||||
|
addToast('Some files could not be deleted', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const batchCopyLinks = () => {
|
||||||
|
const links = filteredFiles
|
||||||
|
.filter((f) => selectedIds.has(f.id))
|
||||||
|
.map((f) => f.link)
|
||||||
|
.join('\n');
|
||||||
|
navigator.clipboard?.writeText(links);
|
||||||
|
addToast(`${selectedIds.size} link${selectedIds.size > 1 ? 's' : ''} copied`, 'success');
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
const batchDownload = () => {
|
||||||
|
const selectedFiles = files.filter((f) => selectedIds.has(f.id));
|
||||||
|
selectedFiles.forEach((file, index) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = downloadUrl(file.linkId || file.id);
|
||||||
|
link.setAttribute('download', file.name);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}, index * 400);
|
||||||
|
});
|
||||||
|
addToast(`Initiating download for ${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`, 'success');
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-enter" style={{ maxWidth: '1120px', margin: '0 auto', padding: '48px 24px' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: '40px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '16px',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<h1 className="heading-lg" style={{ marginBottom: '8px' }}>Dashboard</h1>
|
||||||
|
<p className="text-secondary">Manage your shared files and track engagement.</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={() => navigate('/upload')}>
|
||||||
|
<FileIcon size={16} />
|
||||||
|
Upload New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats — with animated counters */}
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ marginBottom: '40px' }}>
|
||||||
|
<SkeletonStatCards count={3} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||||
|
gap: '16px',
|
||||||
|
marginBottom: '40px',
|
||||||
|
}}>
|
||||||
|
{[
|
||||||
|
{ label: 'Total Files', value: files.length, icon: <FileIcon size={20} />, color: 'var(--accent)', trend: '+2 this week' },
|
||||||
|
{ label: 'Total Views', value: totalViews, icon: <EyeIcon size={20} />, color: 'var(--accent)', trend: '+18 today' },
|
||||||
|
{ label: 'Total Downloads', value: totalDownloads, icon: <ChartIcon size={20} />, color: 'var(--success)', trend: '+5 today' },
|
||||||
|
].map((stat, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="card glow-card"
|
||||||
|
style={{
|
||||||
|
padding: '20px 24px',
|
||||||
|
animation: `fadeInUp 0.4s ${0.1 * i}s ease both`,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Subtle gradient top edge */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, right: 0, height: '2px',
|
||||||
|
background: `linear-gradient(90deg, transparent, ${stat.color}, transparent)`,
|
||||||
|
opacity: 0.5,
|
||||||
|
}} />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||||
|
<span style={{ fontSize: '0.6875rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.08em', fontFamily: 'var(--font-heading)', fontWeight: 600 }}>
|
||||||
|
{stat.label}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
color: stat.color, width: '32px', height: '32px',
|
||||||
|
borderRadius: 'var(--radius-sm)', background: `${stat.color}18`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{stat.icon}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 700, fontSize: '2.25rem', letterSpacing: '-0.04em', lineHeight: 1, marginBottom: '8px' }}>
|
||||||
|
<AnimatedNumber value={stat.value} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: stat.color, fontFamily: 'var(--font-body)', fontWeight: 500, opacity: 0.8 }}>
|
||||||
|
↑ {stat.trend}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Storage + Activity sidebar */}
|
||||||
|
{!loading && (
|
||||||
|
<div
|
||||||
|
className="dashboard-sidebar-grid"
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: '16px',
|
||||||
|
marginBottom: '32px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StorageBar
|
||||||
|
used={storageBreakdownReal?.used ?? 0}
|
||||||
|
total={storageBreakdownReal?.total ?? 536870912}
|
||||||
|
byType={storageBreakdownReal?.byType ?? {}}
|
||||||
|
/>
|
||||||
|
<ActivityFeed activities={activities} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search & Filter */}
|
||||||
|
{/* Section label */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '16px' }}>
|
||||||
|
<span style={{ color: 'var(--accent)', fontFamily: 'var(--font-heading)', fontWeight: 700, fontSize: '0.875rem' }}>//</span>
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.75rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>Your Files</span>
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: '0.75rem', color: 'var(--text-muted)', fontFamily: 'var(--font-heading)' }}>
|
||||||
|
{loading ? 'Loading...' : `${filteredFiles.length} of ${files.length}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1, position: 'relative', minWidth: '200px' }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search files..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
style={{ paddingLeft: '40px' }}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '14px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort dropdown */}
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px', fontSize: '0.8125rem', fontFamily: 'var(--font-body)',
|
||||||
|
background: 'var(--bg-surface)', color: 'var(--text-secondary)',
|
||||||
|
border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)',
|
||||||
|
outline: 'none', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="date">Newest first</option>
|
||||||
|
<option value="name">Name A-Z</option>
|
||||||
|
<option value="size">Largest first</option>
|
||||||
|
<option value="views">Most views</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter chips */}
|
||||||
|
<div className="filter-chips" style={{ display: 'flex', gap: '6px', marginBottom: '20px', flexWrap: 'wrap' }}>
|
||||||
|
{[
|
||||||
|
{ key: 'all', label: 'All' },
|
||||||
|
{ key: 'image', label: 'Images' },
|
||||||
|
{ key: 'document', label: 'Documents' },
|
||||||
|
{ key: 'archive', label: 'Archives' },
|
||||||
|
{ key: 'design', label: 'Design' },
|
||||||
|
].map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setFilterType(key)}
|
||||||
|
style={{
|
||||||
|
padding: '5px 14px', fontSize: '0.75rem', fontFamily: 'var(--font-body)', fontWeight: 500,
|
||||||
|
background: filterType === key ? 'var(--accent-muted)' : 'var(--bg-surface)',
|
||||||
|
color: filterType === key ? 'var(--accent)' : 'var(--text-muted)',
|
||||||
|
border: `1px solid ${filterType === key ? 'var(--accent-border)' : 'var(--border-subtle)'}`,
|
||||||
|
borderRadius: '100px', cursor: 'pointer',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sleek, Glass-morphic Floating Bulk Actions Bar */}
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: selectedIds.size > 0 ? '24px' : '-100px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
background: 'rgba(22, 22, 26, 0.85)',
|
||||||
|
backdropFilter: 'blur(12px)',
|
||||||
|
WebkitBackdropFilter: 'blur(12px)',
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(74, 144, 255, 0.08)',
|
||||||
|
padding: '12px 24px',
|
||||||
|
borderRadius: '100px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '16px',
|
||||||
|
zIndex: 500,
|
||||||
|
width: 'max-content',
|
||||||
|
maxWidth: 'calc(100% - 32px)',
|
||||||
|
opacity: selectedIds.size > 0 ? 1 : 0,
|
||||||
|
pointerEvents: selectedIds.size > 0 ? 'auto' : 'none',
|
||||||
|
transition: 'bottom 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--accent-muted)',
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
borderRadius: '100px',
|
||||||
|
padding: '4px 12px',
|
||||||
|
fontFamily: 'var(--font-heading)',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{selectedIds.size} Selected
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={batchCopyLinks} style={{ fontSize: '0.75rem', height: '32px' }}>
|
||||||
|
<CopyIcon size={12} /> Copy All Links
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={batchDownload} style={{ fontSize: '0.75rem', height: '32px' }}>
|
||||||
|
<DownloadIcon size={12} /> Download Selected
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showBatchDeleteConfirm ? (
|
||||||
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', background: 'var(--danger-muted)', border: '1px solid rgba(232, 72, 72, 0.3)', borderRadius: '100px', padding: '2px 8px' }}>
|
||||||
|
<span style={{ fontSize: '0.6875rem', color: 'var(--danger)', fontWeight: 600, paddingRight: '4px' }}>Confirm?</span>
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={batchDelete} style={{ fontSize: '0.6875rem', height: '24px', padding: '0 8px', minWidth: '40px' }}>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setShowBatchDeleteConfirm(false)} style={{ fontSize: '0.6875rem', height: '24px', padding: '0 8px', minWidth: '40px' }}>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => setShowBatchDeleteConfirm(true)} style={{ fontSize: '0.75rem', height: '32px', borderRadius: '100px' }}>
|
||||||
|
<TrashIcon size={12} /> Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ width: '1px', height: '16px', background: 'var(--border-subtle)', margin: '0 4px' }} />
|
||||||
|
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setSelectedIds(new Set())} style={{ fontSize: '0.75rem', height: '32px', color: 'var(--text-muted)' }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Cards (mobile-friendly) */}
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="file-table-desktop"><SkeletonFileRows count={4} /></div>
|
||||||
|
<div className="file-table-mobile" style={{ display: 'none', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="card" style={{ padding: '20px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||||
|
<div className="skeleton" style={{ width: '44px', height: '44px', borderRadius: 'var(--radius-md)' }} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div className="skeleton" style={{ width: '65%', height: '14px', marginBottom: '6px' }} />
|
||||||
|
<div className="skeleton" style={{ width: '40%', height: '10px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="skeleton" style={{ width: '100%', height: '10px', marginBottom: '8px' }} />
|
||||||
|
<div className="skeleton" style={{ width: '80%', height: '10px' }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="file-table-desktop" style={{
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Table header */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '36px 1fr 100px 120px 80px 80px 120px',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '14px 20px',
|
||||||
|
borderBottom: '1px solid var(--border-subtle)',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
fontFamily: 'var(--font-heading)',
|
||||||
|
fontWeight: 600,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.size === filteredFiles.length && filteredFiles.length > 0}
|
||||||
|
onChange={toggleSelectAll}
|
||||||
|
style={{ accentColor: 'var(--accent)', cursor: 'pointer', width: '16px', height: '16px' }}
|
||||||
|
/>
|
||||||
|
<span>File</span>
|
||||||
|
<span>Size</span>
|
||||||
|
<span>Expiry</span>
|
||||||
|
<span>Views</span>
|
||||||
|
<span>DL</span>
|
||||||
|
<span>Actions</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredFiles.length === 0 && (
|
||||||
|
<div style={{ padding: '64px 20px', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
|
<FileIcon size={36} style={{ marginBottom: '16px', opacity: 0.3 }} />
|
||||||
|
<p style={{ fontFamily: 'var(--font-heading)', fontWeight: 600, marginBottom: '8px', color: 'var(--text-secondary)' }}>
|
||||||
|
{loading ? 'Loading your files…' : searchQuery ? 'No files match your search' : 'No files yet'}
|
||||||
|
</p>
|
||||||
|
{!loading && !searchQuery && (
|
||||||
|
<button className="btn btn-primary btn-sm" style={{ marginTop: '12px' }} onClick={() => navigate('/upload')}>
|
||||||
|
Upload your first file
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredFiles.map((file, i) => (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '36px 1fr 100px 120px 80px 80px 120px',
|
||||||
|
gap: '12px',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '16px 20px',
|
||||||
|
borderBottom: '1px solid var(--border-subtle)',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
animation: `fadeIn 0.3s ${0.05 * i}s ease both`,
|
||||||
|
cursor: 'default',
|
||||||
|
background: selectedIds.has(file.id) ? 'var(--accent-muted)' : 'transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'var(--bg-hover)';
|
||||||
|
e.currentTarget.style.boxShadow = 'inset 3px 0 0 var(--accent)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = selectedIds.has(file.id) ? 'var(--accent-muted)' : 'transparent';
|
||||||
|
e.currentTarget.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.has(file.id)}
|
||||||
|
onChange={() => toggleSelect(file.id)}
|
||||||
|
style={{ accentColor: 'var(--accent)', cursor: 'pointer', width: '16px', height: '16px' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
width: '36px', height: '36px', borderRadius: 'var(--radius-sm)',
|
||||||
|
background: 'var(--bg-surface)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontFamily: 'var(--font-heading)', fontWeight: 700, fontSize: '0.5625rem',
|
||||||
|
letterSpacing: '0.05em', color: getFileIconColor(file.type), flexShrink: 0,
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
}}>
|
||||||
|
{getFileIcon(file.type)}
|
||||||
|
</div>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.875rem',
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||||
|
}}>
|
||||||
|
{file.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>by {file.uploader}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>{formatFileSize(file.size)}</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.8125rem', color: formatDateRelative(file.expiresAt) === 'Expired' ? 'var(--danger)' : 'var(--accent)',
|
||||||
|
fontFamily: 'var(--font-heading)', fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
<ClockIcon size={12} style={{ marginRight: '4px', verticalAlign: '-1px' }} />
|
||||||
|
{formatDateRelative(file.expiresAt)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>{file.views}</span>
|
||||||
|
<span style={{ fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>{file.downloads}</span>
|
||||||
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
|
<button className="btn btn-ghost btn-icon btn-sm" onClick={() => copyLink(file)} title="Copy link">
|
||||||
|
{copiedId === file.id ? <CheckIcon size={14} style={{ color: 'var(--success)' }} /> : <CopyIcon size={14} />}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost btn-icon btn-sm" onClick={() => setSelectedFile(file)} title="View details">
|
||||||
|
<ChartIcon size={14} />
|
||||||
|
</button>
|
||||||
|
{deleteConfirm === file.id ? (
|
||||||
|
<div style={{ display: 'flex', gap: '4px', animation: 'fadeIn 0.2s ease' }}>
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => deleteFile(file.id)} style={{ fontSize: '0.6875rem', padding: '4px 8px' }}>Yes</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)} style={{ fontSize: '0.6875rem', padding: '4px 8px' }}>No</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-ghost btn-icon btn-sm" onClick={() => setDeleteConfirm(file.id)} title="Delete" style={{ color: 'var(--danger)' }}>
|
||||||
|
<TrashIcon size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile card layout */}
|
||||||
|
<div className="file-table-mobile" style={{ display: 'none', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
{filteredFiles.length === 0 && (
|
||||||
|
<div style={{ padding: '64px 20px', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
|
<FileIcon size={36} style={{ marginBottom: '16px', opacity: 0.3 }} />
|
||||||
|
<p style={{ fontFamily: 'var(--font-heading)', fontWeight: 600, marginBottom: '8px', color: 'var(--text-secondary)' }}>
|
||||||
|
{loading ? 'Loading your files…' : searchQuery ? 'No files match your search' : 'No files yet'}
|
||||||
|
</p>
|
||||||
|
{!loading && !searchQuery && (
|
||||||
|
<button className="btn btn-primary btn-sm" style={{ marginTop: '12px' }} onClick={() => navigate('/upload')}>
|
||||||
|
Upload your first file
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filteredFiles.map((file, i) => (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
className="card"
|
||||||
|
style={{ padding: '20px', animation: `fadeIn 0.3s ${0.05 * i}s ease both` }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '44px', height: '44px', borderRadius: 'var(--radius-md)',
|
||||||
|
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>
|
||||||
|
<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)' }}>by {file.uploader}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '8px', marginBottom: '16px' }}>
|
||||||
|
<div><div style={{ fontSize: '0.6875rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Size</div><div style={{ fontSize: '0.875rem', fontFamily: 'var(--font-heading)', fontWeight: 500 }}>{formatFileSize(file.size)}</div></div>
|
||||||
|
<div><div style={{ fontSize: '0.6875rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Views</div><div style={{ fontSize: '0.875rem', fontFamily: 'var(--font-heading)', fontWeight: 500 }}>{file.views}</div></div>
|
||||||
|
<div><div style={{ fontSize: '0.6875rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Expiry</div><div style={{ fontSize: '0.875rem', fontFamily: 'var(--font-heading)', fontWeight: 500, color: 'var(--accent)' }}>{formatDateRelative(file.expiresAt)}</div></div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => copyLink(file)} style={{ flex: 1 }}>
|
||||||
|
{copiedId === file.id ? <CheckIcon size={14} /> : <CopyIcon size={14} />} {copiedId === file.id ? 'Copied' : 'Copy Link'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setSelectedFile(file)}>
|
||||||
|
<ChartIcon size={14} /> Details
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" style={{ color: 'var(--danger)' }} onClick={() => setDeleteConfirm(deleteConfirm === file.id ? null : file.id)}>
|
||||||
|
<TrashIcon size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File detail slide-over */}
|
||||||
|
<FileDetailSlideOver
|
||||||
|
file={selectedFile}
|
||||||
|
onClose={() => setSelectedFile(null)}
|
||||||
|
onDelete={(fileId) => {
|
||||||
|
deleteFile(fileId);
|
||||||
|
setSelectedFile(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user