Import source: cshare-app/src/pages/FileViewPage
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,576 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { DownloadIcon, CopyIcon, CheckIcon, ClockIcon, ShieldIcon } from '../components/Icons';
|
||||||
|
import { sharedFile, formatFileSize, formatDate, getFileIcon, getFileIconColor } from '../data/mockData';
|
||||||
|
import { useToast } from '../components/ToastContext';
|
||||||
|
import { downloadUrl, previewUrl, resolveLink } from '../lib/api';
|
||||||
|
|
||||||
|
function useCountdown(targetDate) {
|
||||||
|
const [timeLeft, setTimeLeft] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => {
|
||||||
|
const diff = new Date(targetDate) - new Date();
|
||||||
|
if (diff <= 0) { setTimeLeft('Expired'); return; }
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
const hours = Math.floor((diff / (1000 * 60 * 60)) % 24);
|
||||||
|
const mins = Math.floor((diff / (1000 * 60)) % 60);
|
||||||
|
const secs = Math.floor((diff / 1000) % 60);
|
||||||
|
if (days > 0) {
|
||||||
|
setTimeLeft(`${days}d ${hours}h ${mins}m ${secs}s`);
|
||||||
|
} else {
|
||||||
|
setTimeLeft(`${hours}h ${mins}m ${secs}s`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
const id = setInterval(update, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [targetDate]);
|
||||||
|
|
||||||
|
return timeLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilePreview({ file, linkId }) {
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [textError, setTextError] = useState('');
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [rotation, setRotation] = useState(0);
|
||||||
|
const [copiedText, setCopiedText] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const addToast = useToast();
|
||||||
|
const preview = file.preview;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!linkId || preview?.kind !== 'text') return;
|
||||||
|
let active = true;
|
||||||
|
fetch(previewUrl(linkId))
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) throw new Error('Preview unavailable');
|
||||||
|
return response.text();
|
||||||
|
})
|
||||||
|
.then((body) => {
|
||||||
|
if (active) setText(body.slice(0, 120_000));
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (active) setTextError(err.message);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [linkId, preview?.kind]);
|
||||||
|
|
||||||
|
const copyTextContent = () => {
|
||||||
|
navigator.clipboard?.writeText(text);
|
||||||
|
setCopiedText(true);
|
||||||
|
addToast('Preview text copied to clipboard', 'success');
|
||||||
|
setTimeout(() => setCopiedText(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!linkId || !preview?.available) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '14px 16px',
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
marginBottom: '20px',
|
||||||
|
}}>
|
||||||
|
Preview is available for small images, PDFs, text, audio, and video files.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = previewUrl(linkId);
|
||||||
|
const shellStyle = {
|
||||||
|
marginBottom: '20px',
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (preview.kind === 'image') {
|
||||||
|
return (
|
||||||
|
<div style={{ ...shellStyle, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
borderBottom: '1px solid var(--border-subtle)'
|
||||||
|
}}>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setZoom(z => Math.max(0.5, z - 0.25))} style={{ padding: '4px 8px', fontSize: '0.75rem' }}>Zoom -</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => { setZoom(1); setRotation(0); }} style={{ padding: '4px 8px', fontSize: '0.75rem' }}>Reset</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setZoom(z => Math.min(3, z + 0.25))} style={{ padding: '4px 8px', fontSize: '0.75rem' }}>Zoom +</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => setRotation(r => (r + 90) % 360)} style={{ padding: '4px 8px', fontSize: '0.75rem' }}>Rotate ↻</button>
|
||||||
|
</div>
|
||||||
|
{/* Canvas container */}
|
||||||
|
<div style={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
maxHeight: '520px',
|
||||||
|
height: '420px',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
background: '#09090b',
|
||||||
|
position: 'relative',
|
||||||
|
}}>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={file.name}
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
transform: `scale(${zoom}) rotate(${rotation}deg)`,
|
||||||
|
transition: 'transform 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Zoom badge */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '12px',
|
||||||
|
right: '12px',
|
||||||
|
background: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '3px 8px',
|
||||||
|
fontSize: '0.6875rem',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
}}>
|
||||||
|
{Math.round(zoom * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview.kind === 'video') {
|
||||||
|
return (
|
||||||
|
<div style={{ ...shellStyle, position: 'relative' }}>
|
||||||
|
<video controls preload="metadata" src={src} style={{ display: 'block', width: '100%', maxHeight: '520px', background: '#000' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview.kind === 'audio') {
|
||||||
|
return (
|
||||||
|
<div style={{ ...shellStyle, padding: '18px', background: 'var(--bg-surface)' }}>
|
||||||
|
<audio controls preload="metadata" src={src} style={{ display: 'block', width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview.kind === 'pdf') {
|
||||||
|
return (
|
||||||
|
<div style={{ ...shellStyle, height: '520px' }}>
|
||||||
|
<iframe src={src} title={`Preview of ${file.name}`} style={{ border: 0, width: '100%', height: '100%' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview.kind === 'text') {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const filteredLines = searchTerm
|
||||||
|
? lines.map((line, idx) => ({ line, idx })).filter(item => item.line.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||||
|
: lines.map((line, idx) => ({ line, idx }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ ...shellStyle, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
borderBottom: '1px solid var(--border-subtle)',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
placeholder="Search text..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
maxWidth: '180px',
|
||||||
|
height: '28px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={copyTextContent} style={{ padding: '4px 8px', fontSize: '0.75rem', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<CopyIcon size={12} /> {copiedText ? 'Copied!' : 'Copy Text'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Text Area */}
|
||||||
|
<div style={{
|
||||||
|
maxHeight: '420px',
|
||||||
|
overflow: 'auto',
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
display: 'flex',
|
||||||
|
fontFamily: 'var(--font-mono, monospace)',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}>
|
||||||
|
{textError ? (
|
||||||
|
<div style={{ padding: '16px', color: 'var(--danger)' }}>{textError}</div>
|
||||||
|
) : !text ? (
|
||||||
|
<div style={{ padding: '16px', color: 'var(--text-muted)' }}>Loading preview...</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', width: '100%' }}>
|
||||||
|
{/* Line Numbers gutter */}
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 12px',
|
||||||
|
borderRight: '1px solid var(--border-subtle)',
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
textAlign: 'right',
|
||||||
|
userSelect: 'none',
|
||||||
|
minWidth: '40px',
|
||||||
|
}}>
|
||||||
|
{filteredLines.map((item) => (
|
||||||
|
<div key={item.idx}>{item.idx + 1}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Lines Content */}
|
||||||
|
<pre style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: '16px',
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
}}>
|
||||||
|
{filteredLines.map((item) => (
|
||||||
|
<div key={item.idx} style={{
|
||||||
|
background: searchTerm && item.line.toLowerCase().includes(searchTerm.toLowerCase()) ? 'rgba(74,144,255,0.15)' : 'transparent',
|
||||||
|
}}>
|
||||||
|
{item.line || ' '}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileViewPage() {
|
||||||
|
const { linkId } = useParams();
|
||||||
|
const [file, setFile] = useState(sharedFile);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||||
|
const addToast = useToast();
|
||||||
|
const countdown = useCountdown(file.expiresAt);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!linkId) return;
|
||||||
|
let active = true;
|
||||||
|
resolveLink(linkId)
|
||||||
|
.then((remoteFile) => {
|
||||||
|
if (!active) return;
|
||||||
|
setFile(remoteFile);
|
||||||
|
setError('');
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!active) return;
|
||||||
|
setError(err.message);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [linkId]);
|
||||||
|
|
||||||
|
const copyLink = () => {
|
||||||
|
navigator.clipboard?.writeText(file.link);
|
||||||
|
setCopied(true);
|
||||||
|
addToast('Link copied to clipboard', 'success');
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (linkId) {
|
||||||
|
setDownloading(true);
|
||||||
|
setDownloadProgress(100);
|
||||||
|
window.location.href = downloadUrl(linkId);
|
||||||
|
setTimeout(() => {
|
||||||
|
setDownloading(false);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
}, 1200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloading(true);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
|
||||||
|
let progress = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
progress += Math.random() * 20 + 10;
|
||||||
|
if (progress >= 100) {
|
||||||
|
progress = 100;
|
||||||
|
clearInterval(interval);
|
||||||
|
setDownloadProgress(100);
|
||||||
|
setTimeout(() => {
|
||||||
|
setDownloading(false);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
addToast('Download complete — this is a demo', 'success');
|
||||||
|
}, 400);
|
||||||
|
} else {
|
||||||
|
setDownloadProgress(Math.min(progress, 99));
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-enter" style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '24px',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Animated background orbs */}
|
||||||
|
<div className="orb orb-1" style={{ top: '10%', left: '-5%', opacity: 0.4 }} />
|
||||||
|
<div className="orb orb-3" style={{ bottom: '10%', right: '-5%', opacity: 0.3 }} />
|
||||||
|
|
||||||
|
<div style={{ maxWidth: file.preview?.available ? '840px' : '480px', width: '100%', position: 'relative', zIndex: 1 }}>
|
||||||
|
{/* Header badge */}
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '32px', animation: 'fadeIn 0.4s ease' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '56px',
|
||||||
|
height: '56px',
|
||||||
|
margin: '0 auto 16px',
|
||||||
|
borderRadius: '16px',
|
||||||
|
background: 'var(--accent-muted)',
|
||||||
|
border: '1px solid var(--accent-border)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
animation: 'pulseGlow 3s ease-in-out infinite',
|
||||||
|
}}>
|
||||||
|
<ShieldIcon size={24} />
|
||||||
|
</div>
|
||||||
|
<h1 className="heading-md" style={{ marginBottom: '8px' }}>
|
||||||
|
Shared with you
|
||||||
|
</h1>
|
||||||
|
<p className="text-secondary" style={{ fontSize: '0.9375rem' }}>
|
||||||
|
{error || 'Someone shared this file via CShare. Download it before the link expires.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File card with glow */}
|
||||||
|
<div
|
||||||
|
className="card glow-card"
|
||||||
|
style={{
|
||||||
|
padding: '28px',
|
||||||
|
animation: 'scaleIn 0.4s 0.1s ease both',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Accent bar with glow */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '3px',
|
||||||
|
background: 'linear-gradient(90deg, var(--accent), var(--accent-hover), var(--accent))',
|
||||||
|
backgroundSize: '200% 100%',
|
||||||
|
animation: 'gradientMove 3s ease infinite',
|
||||||
|
boxShadow: '0 2px 12px rgba(74, 144, 255, 0.3)',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* File icon + name */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '16px', marginBottom: '24px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '56px',
|
||||||
|
height: '56px',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontFamily: 'var(--font-heading)',
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
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 }}>
|
||||||
|
<h3 style={{
|
||||||
|
fontFamily: 'var(--font-heading)',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '1.0625rem',
|
||||||
|
marginBottom: '4px',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}>
|
||||||
|
{file.name}
|
||||||
|
</h3>
|
||||||
|
<div style={{ fontSize: '0.8125rem', color: 'var(--text-muted)' }}>
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata grid */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: '16px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
padding: '16px',
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Uploaded by
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 500, fontSize: '0.9375rem' }}>
|
||||||
|
{file.uploader}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Uploaded on
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 500, fontSize: '0.9375rem' }}>
|
||||||
|
{formatDate(file.uploadedAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Expires
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'var(--font-heading)',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.9375rem',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
}}>
|
||||||
|
<ClockIcon size={14} />
|
||||||
|
{countdown}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
Views
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 500, fontSize: '0.9375rem' }}>
|
||||||
|
{file.views}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilePreview file={file} linkId={linkId} />
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-lg"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={downloading || Boolean(error)}
|
||||||
|
style={{ width: '100%', position: 'relative', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
{downloading ? (
|
||||||
|
<>
|
||||||
|
<span style={{
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
border: '2px solid var(--bg-primary)',
|
||||||
|
borderTopColor: 'transparent',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 0.6s linear infinite',
|
||||||
|
display: 'inline-block',
|
||||||
|
}} />
|
||||||
|
{downloadProgress >= 100 ? 'Complete!' : `${Math.round(downloadProgress)}%`}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DownloadIcon size={18} />
|
||||||
|
Download File
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Download progress bar */}
|
||||||
|
{downloading && (
|
||||||
|
<div style={{
|
||||||
|
height: '3px',
|
||||||
|
borderRadius: '2px',
|
||||||
|
background: 'var(--bg-surface)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
animation: 'fadeIn 0.2s ease',
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
className="shimmer-progress"
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${downloadProgress}%`,
|
||||||
|
borderRadius: '2px',
|
||||||
|
background: 'linear-gradient(90deg, var(--accent), var(--accent-hover))',
|
||||||
|
transition: 'width 0.2s ease',
|
||||||
|
boxShadow: '0 0 8px rgba(74, 144, 255, 0.3)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={copyLink}
|
||||||
|
style={{ width: '100%', transition: 'all var(--transition-base)' }}
|
||||||
|
>
|
||||||
|
{copied ? <CheckIcon size={16} style={{ color: 'var(--success)' }} /> : <CopyIcon size={16} />}
|
||||||
|
{copied ? 'Link Copied' : 'Copy Share Link'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trust footer */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: '24px',
|
||||||
|
textAlign: 'center',
|
||||||
|
animation: 'fadeIn 0.4s 0.3s ease both',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
<ShieldIcon size={14} />
|
||||||
|
End-to-end encrypted and auto-expiring
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user