Import source: cshare-app/src/components/FileDetailSlideOver

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:14:04 +03:30
parent 2eb61e1adc
commit f89c99901b
@@ -0,0 +1,418 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { CopyIcon, CheckIcon, TrashIcon, EyeIcon, ClockIcon, DownloadIcon, XIcon } from './Icons';
import { formatFileSize, formatDate, formatDateRelative, getFileIcon, getFileIconColor } from '../data/mockData';
import { useToast } from './ToastContext';
import { getFileAnalytics } from '../lib/api';
const MOCK_CHART_DATA = [
{ day: 'Mon', views: 12, downloads: 3 },
{ day: 'Tue', views: 18, downloads: 5 },
{ day: 'Wed', views: 8, downloads: 2 },
{ day: 'Thu', views: 22, downloads: 7 },
{ day: 'Fri', views: 15, downloads: 4 },
{ day: 'Sat', views: 6, downloads: 1 },
{ day: 'Sun', views: 10, downloads: 3 },
];
function MiniChart({ data }) {
const [hoveredIdx, setHoveredIdx] = useState(null);
const width = 360;
const height = 100;
const padding = 15;
if (!data || data.length === 0) return null;
const maxVal = Math.max(...data.map(d => Math.max(d.views, d.downloads)), 1);
const chartWidth = width - padding * 2;
const chartHeight = height - padding * 2;
// Coordinates for points
const points = data.map((d, i) => {
const x = padding + (i / (data.length - 1)) * chartWidth;
const yViews = padding + chartHeight - (d.views / maxVal) * chartHeight;
const yDownloads = padding + chartHeight - (d.downloads / maxVal) * chartHeight;
return { x, yViews, yDownloads, ...d };
});
// Construct smooth lines or simple linear svg paths
const viewsLine = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.yViews}`).join(' ');
const downloadsLine = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.yDownloads}`).join(' ');
// Construct area paths (fill under the line)
const viewsArea = points.length > 0
? `${viewsLine} L ${points[points.length - 1].x} ${height - padding} L ${points[0].x} ${height - padding} Z`
: '';
const downloadsArea = points.length > 0
? `${downloadsLine} L ${points[points.length - 1].x} ${height - padding} L ${points[0].x} ${height - padding} Z`
: '';
return (
<div style={{ position: 'relative', width: '100%' }}>
<div style={{ position: 'relative' }}>
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} style={{ overflow: 'visible' }}>
<defs>
<linearGradient id="viewsGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--accent)" stopOpacity="0.25" />
<stop offset="100%" stopColor="var(--accent)" stopOpacity="0.0" />
</linearGradient>
<linearGradient id="downloadsGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--success)" stopOpacity="0.25" />
<stop offset="100%" stopColor="var(--success)" stopOpacity="0.0" />
</linearGradient>
</defs>
{/* Grid lines */}
<line x1={padding} y1={padding} x2={width - padding} y2={padding} stroke="var(--border-subtle)" strokeDasharray="3,3" />
<line x1={padding} y1={padding + chartHeight / 2} x2={width - padding} y2={padding + chartHeight / 2} stroke="var(--border-subtle)" strokeDasharray="3,3" />
<line x1={padding} y1={height - padding} x2={width - padding} y2={height - padding} stroke="var(--border)" />
{/* Area under curves */}
{viewsArea && <path d={viewsArea} fill="url(#viewsGrad)" />}
{downloadsArea && <path d={downloadsArea} fill="url(#downloadsGrad)" />}
{/* Lines */}
{viewsLine && <path d={viewsLine} fill="none" stroke="var(--accent)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />}
{downloadsLine && <path d={downloadsLine} fill="none" stroke="var(--success)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />}
{/* Interactive vertical hover guide */}
{hoveredIdx !== null && (
<line
x1={points[hoveredIdx].x}
y1={padding}
x2={points[hoveredIdx].x}
y2={height - padding}
stroke="var(--accent-border)"
strokeWidth="1"
strokeDasharray="2,2"
/>
)}
{/* Points & Interactive Hover Area */}
{points.map((p, i) => (
<g key={i} onMouseEnter={() => setHoveredIdx(i)} onMouseLeave={() => setHoveredIdx(null)}>
{/* Highlight circles */}
{hoveredIdx === i && (
<>
<circle cx={p.x} cy={p.yViews} r="5" fill="var(--bg-elevated)" stroke="var(--accent)" strokeWidth="2" />
<circle cx={p.x} cy={p.yDownloads} r="5" fill="var(--bg-elevated)" stroke="var(--success)" strokeWidth="2" />
</>
)}
{/* Invisible touch/hover target */}
<rect
x={p.x - 15}
y={0}
width={30}
height={height}
fill="transparent"
style={{ cursor: 'pointer' }}
/>
</g>
))}
</svg>
{/* Hover Tooltip */}
{hoveredIdx !== null && (
<div style={{
position: 'absolute',
left: `${(points[hoveredIdx].x / width) * 100}%`,
top: '-50px',
transform: 'translateX(-50%)',
background: 'var(--bg-surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
padding: '6px 10px',
pointerEvents: 'none',
zIndex: 10,
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
display: 'flex',
flexDirection: 'column',
gap: '2px',
animation: 'fadeIn 0.15s ease',
}}>
<span style={{ fontSize: '0.625rem', color: 'var(--text-muted)', fontWeight: 600 }}>{points[hoveredIdx].day}</span>
<span style={{ fontSize: '0.6875rem', color: 'var(--accent)', fontWeight: 500 }}>Views: <strong>{points[hoveredIdx].views}</strong></span>
<span style={{ fontSize: '0.6875rem', color: 'var(--success)', fontWeight: 500 }}>Downloads: <strong>{points[hoveredIdx].downloads}</strong></span>
</div>
)}
</div>
{/* Legend & X Axis */}
<div style={{ display: 'flex', justifyContent: 'space-between', padding: `0 ${padding}px`, marginTop: '8px' }}>
{data.map((d, i) => (
<span key={i} style={{ fontSize: '0.625rem', color: 'var(--text-muted)', fontFamily: 'var(--font-heading)' }}>
{d.day}
</span>
))}
</div>
<div style={{ display: 'flex', gap: '16px', marginTop: '12px', justifyContent: 'center' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.6875rem', color: 'var(--text-muted)' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: 'var(--accent)' }} /> Views
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '0.6875rem', color: 'var(--text-muted)' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: 'var(--success)' }} /> Downloads
</span>
</div>
</div>
);
}
export default function FileDetailSlideOver({ file, onClose, onDelete }) {
const panelRef = useRef(null);
const [copied, setCopied] = useState(false);
const addToast = useToast();
const [analytics, setAnalytics] = useState(null);
const [analyticsLoading, setAnalyticsLoading] = useState(true);
useEffect(() => {
if (!file?.id) return;
setAnalyticsLoading(true);
getFileAnalytics(file.id)
.then((data) => {
setAnalytics(data);
})
.catch((err) => {
console.error(err);
})
.finally(() => {
setAnalyticsLoading(false);
});
}, [file?.id]);
const daysOfWeek = useMemo(() => ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], []);
const chartData = useMemo(() => {
if (!analytics?.dailyBreakdown) {
return MOCK_CHART_DATA;
}
return analytics.dailyBreakdown.slice(-7).map((item) => {
const d = new Date(item.date + 'T00:00:00');
return {
day: daysOfWeek[d.getDay()],
views: item.views,
downloads: item.downloads,
};
});
}, [analytics, daysOfWeek]);
useEffect(() => {
const handleKey = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handleKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKey);
document.body.style.overflow = '';
};
}, [onClose]);
if (!file) return null;
const copyLink = () => {
navigator.clipboard?.writeText(file.link);
setCopied(true);
addToast('Link copied to clipboard', 'success');
setTimeout(() => setCopied(false), 2000);
};
const daysUntilExpiry = Math.ceil((new Date(file.expiresAt) - new Date()) / (1000 * 60 * 60 * 24));
const isExpiringSoon = daysUntilExpiry <= 3 && daysUntilExpiry > 0;
return (
<div
style={{ position: 'fixed', inset: 0, zIndex: 200, display: 'flex', justifyContent: 'flex-end' }}
onClick={onClose}
>
{/* Backdrop */}
<div style={{
position: 'absolute', inset: 0,
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
animation: 'fadeIn 0.2s ease',
}} />
{/* Panel */}
<div
ref={panelRef}
className="card file-detail-panel"
style={{
position: 'relative', zIndex: 1,
width: '100%', maxWidth: '440px',
borderRadius: 0,
borderLeft: '1px solid var(--border)',
borderRight: 'none', borderTop: 'none', borderBottom: 'none',
background: 'var(--bg-elevated)',
overflowY: 'auto',
animation: 'slideInRight 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
display: 'flex', flexDirection: 'column',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Top accent */}
<div style={{
position: 'sticky', top: 0, zIndex: 2,
background: 'var(--bg-elevated)',
borderBottom: '1px solid var(--border-subtle)',
}}>
<div style={{ height: '3px', background: 'linear-gradient(90deg, var(--accent), var(--accent-hover))' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 24px' }}>
<h3 className="heading-sm" style={{
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
maxWidth: 'calc(100% - 40px)',
}}>
{file.name}
</h3>
<button className="btn btn-ghost btn-icon btn-sm" onClick={onClose}>
<XIcon size={16} />
</button>
</div>
</div>
{/* Content */}
<div style={{ padding: '24px', display: 'flex', flexDirection: 'column', gap: '24px', flex: 1 }}>
{/* File type badge */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{
width: '48px', height: '48px', borderRadius: 'var(--radius-md)',
background: `${getFileIconColor(file.type)}14`,
border: `1px solid ${getFileIconColor(file.type)}30`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'var(--font-heading)', fontWeight: 700, fontSize: '0.75rem',
color: getFileIconColor(file.type),
}}>
{getFileIcon(file.type)}
</div>
<div>
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.9375rem' }}>
{file.type.split('/')[1]?.toUpperCase() || 'FILE'} File
</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)' }}>
{formatFileSize(file.size)}
</div>
</div>
</div>
{/* Expiry badge */}
<div style={{
padding: '12px 16px', borderRadius: 'var(--radius-md)',
background: isExpiringSoon ? 'var(--danger-muted)' : 'var(--accent-muted)',
border: `1px solid ${isExpiringSoon ? 'rgba(232,72,72,0.25)' : 'var(--accent-border)'}`,
display: 'flex', alignItems: 'center', gap: '10px',
}}>
<ClockIcon size={16} style={{ color: isExpiringSoon ? 'var(--danger)' : 'var(--accent)' }} />
<div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '2px' }}>Expires</div>
<div style={{
fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.875rem',
color: isExpiringSoon ? 'var(--danger)' : 'var(--accent)',
}}>
{formatDateRelative(file.expiresAt)} — {formatDate(file.expiresAt)}
</div>
</div>
</div>
{/* Stats row */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div className="card" style={{ padding: '16px', textAlign: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px', marginBottom: '8px' }}>
<EyeIcon size={14} style={{ color: 'var(--accent)' }} />
<span style={{ fontSize: '0.6875rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Views</span>
</div>
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 700, fontSize: '1.5rem', letterSpacing: '-0.03em' }}>{file.views}</div>
</div>
<div className="card" style={{ padding: '16px', textAlign: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px', marginBottom: '8px' }}>
<DownloadIcon size={14} style={{ color: 'var(--success)' }} />
<span style={{ fontSize: '0.6875rem', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Downloads</span>
</div>
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 700, fontSize: '1.5rem', letterSpacing: '-0.03em' }}>{file.downloads}</div>
</div>
</div>
{/* Mini chart */}
<div>
<div style={{
fontSize: '0.6875rem', color: 'var(--text-muted)', textTransform: 'uppercase',
letterSpacing: '0.06em', fontFamily: 'var(--font-heading)', fontWeight: 600,
marginBottom: '12px',
}}>
Last 7 Days
</div>
<div style={{
padding: '16px', background: 'var(--bg-surface)', borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-subtle)',
}}>
<MiniChart data={chartData} />
</div>
</div>
{/* Share link */}
<div>
<div style={{
fontSize: '0.6875rem', color: 'var(--text-muted)', textTransform: 'uppercase',
letterSpacing: '0.06em', fontFamily: 'var(--font-heading)', fontWeight: 600,
marginBottom: '8px',
}}>
Share Link
</div>
<div style={{
display: 'flex', gap: '8px', alignItems: 'center',
}}>
<div style={{
flex: 1, padding: '10px 14px', background: 'var(--bg-surface)',
borderRadius: 'var(--radius-md)', border: '1px solid var(--border-subtle)',
fontSize: '0.8125rem', color: 'var(--accent)',
fontFamily: 'var(--font-mono, monospace)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{file.link}
</div>
<button
className="btn btn-primary btn-sm"
onClick={copyLink}
style={{ flexShrink: 0, minWidth: '90px' }}
>
{copied ? <><CheckIcon size={14} /> Copied!</> : <><CopyIcon size={14} /> Copy</>}
</button>
</div>
</div>
{/* Details grid */}
<div style={{
padding: '16px', background: 'var(--bg-surface)', borderRadius: 'var(--radius-md)',
border: '1px solid var(--border-subtle)',
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px',
}}>
<div>
<div style={{ fontSize: '0.6875rem', color: 'var(--text-muted)', marginBottom: '4px' }}>Uploaded</div>
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 500, fontSize: '0.8125rem' }}>{formatDate(file.uploadedAt)}</div>
</div>
<div>
<div style={{ fontSize: '0.6875rem', color: 'var(--text-muted)', marginBottom: '4px' }}>By</div>
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 500, fontSize: '0.8125rem' }}>{file.uploader}</div>
</div>
<div>
<div style={{ fontSize: '0.6875rem', color: 'var(--text-muted)', marginBottom: '4px' }}>Type</div>
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 500, fontSize: '0.8125rem' }}>{file.type.split('/')[1]?.toUpperCase() || 'FILE'}</div>
</div>
<div>
<div style={{ fontSize: '0.6875rem', color: 'var(--text-muted)', marginBottom: '4px' }}>Size</div>
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 500, fontSize: '0.8125rem' }}>{formatFileSize(file.size)}</div>
</div>
</div>
</div>
{/* Footer actions */}
<div style={{
position: 'sticky', bottom: 0, padding: '16px 24px',
borderTop: '1px solid var(--border-subtle)', background: 'var(--bg-elevated)',
display: 'flex', gap: '8px',
}}>
<button className="btn btn-secondary" onClick={copyLink} style={{ flex: 1 }}>
<CopyIcon size={14} /> Copy Link
</button>
<button className="btn btn-danger" onClick={() => onDelete(file.id)}>
<TrashIcon size={14} /> Delete
</button>
</div>
</div>
</div>
);
}