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