import {useEffect, useState} from 'react' import {AlertTriangle, Download, Loader2, X} from 'lucide-react' import type {ObjectPreviewKind} from '@/lib/format' interface ObjectPreviewModalProps { kind: ObjectPreviewKind name: string src: string onClose: () => void } /** Text previews are fetched inline, so cap how much we pull and render. */ const TEXT_PREVIEW_LIMIT = 200_000 /** * Lightbox/preview drawer for storage objects. Reuses the existing object * download URL as the media source — no new backend endpoint, per the * "never invent a custom protocol" rule in CONTRIBUTING.md. Image/video/audio * are rendered via native HTML media elements; text is fetched and shown as * plain text, since there is no inline "view" mode on the download endpoint. */ export function ImagePreviewModal({kind, name, src, onClose}: ObjectPreviewModalProps) { useEffect(() => { function onKeyDown(event: KeyboardEvent) { if (event.key === 'Escape') onClose() } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [onClose]) return (
{ if (e.target === e.currentTarget) onClose() }}>
{name}
{kind === 'image' && } {kind === 'video' &&
) } function ImagePreview({src, name}: {src: string; name: string}) { const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>('loading') return ( <> {status === 'loading' && } {status === 'error' && } {name} setStatus('loaded')} onError={() => setStatus('error')} /> ) } function TextPreview({src}: {src: string}) { const [state, setState] = useState<{status: 'loading' | 'loaded' | 'error'; text: string; truncated: boolean}>({ status: 'loading', text: '', truncated: false, }) useEffect(() => { const controller = new AbortController() setState({status: 'loading', text: '', truncated: false}) readTextPreview(src, controller.signal) .then((result) => setState({status: 'loaded', ...result})) .catch((err) => { if ((err as {name?: string}).name === 'AbortError') return setState({status: 'error', text: '', truncated: false}) }) return () => controller.abort() }, [src]) if (state.status === 'loading') return if (state.status === 'error') return return (
            {state.text}
            {state.truncated && '\n\n… truncated'}
        
) } /** * Fetches only as much of the object as needed for the preview. Stops * reading (and cancels the underlying connection) once TEXT_PREVIEW_LIMIT * characters have been decoded, instead of buffering the full response — * important for large text-like objects, since res.text() would otherwise * transfer and hold the entire body in memory just to show the first slice. */ async function readTextPreview(src: string, signal: AbortSignal): Promise<{text: string; truncated: boolean}> { const res = await fetch(src, {signal}) if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`) const reader = res.body.getReader() const decoder = new TextDecoder() let text = '' let truncated: boolean try { while (text.length < TEXT_PREVIEW_LIMIT) { const {done, value} = await reader.read() if (done) break text += decoder.decode(value, {stream: true}) } if (text.length > TEXT_PREVIEW_LIMIT) { text = text.slice(0, TEXT_PREVIEW_LIMIT) // A code unit landing exactly on the cut could be the high half of a // surrogate pair (e.g. an emoji) with its low half past the boundary — // slice() doesn't know that, and leaves a lone surrogate that renders // as a replacement glyph. Drop it so the preview only ever ends on a // complete character. const lastCode = text.charCodeAt(text.length - 1) if (lastCode >= 0xd800 && lastCode <= 0xdbff) text = text.slice(0, -1) truncated = true } else { // Confirm whether more data was actually available before claiming // completeness — the loop can exit exactly at the limit. const {done} = await reader.read() truncated = !done } } finally { void reader.cancel().catch(() => {}) } return {text, truncated} } function PreviewStatus({label, isError}: {label: string; isError?: boolean}) { return (
{isError ? : } {label}
) }