Repository Public
1// Pieces shared by every preview: the viewer context (toolbar slot, swipe lock), fetching with explicit states2// (ready / still being produced / failed), and the calm panels shown instead of content.3import { formatBytes } from '@spd/shared';4import { Component, createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';5import { createPortal } from 'react-dom';6import { ApiError } from '../api.ts';7import { Glyph } from '../glyphs.tsx';8import { errorText, getLocale, useT } from '../i18n.ts';9import type { PreviewSource } from '../types.ts';10import { Button, cx, Spinner } from '../ui.tsx';1112export interface ViewerContext { toolsEl: HTMLElement | null; setSwipeLocked: (locked: boolean) => void }13export const ViewerCtx = createContext<ViewerContext>({ toolsEl: null, setSwipeLocked: () => {} });14/** Renders its children in the viewer's top bar. */15export function Tools({ children }: { children: ReactNode }) {16 const { toolsEl } = useContext(ViewerCtx);17 return toolsEl ? createPortal(children, toolsEl) : null;18}19export function ToolText({ children, ...p }: { children: ReactNode } & React.HTMLAttributes<HTMLSpanElement>) {20 return <span {...p} className="shrink-0 px-1 text-xs tabular-nums text-ink-2">{children}</span>;21}22/** A labelled on/off choice in the toolbar. */23export function ToolToggle({ active, onClick, children, testId }: { active: boolean; onClick: () => void; children: ReactNode; testId?: string }) {24 return (25 <button type="button" aria-pressed={active} onClick={onClick} data-testid={testId}26 className={cx('inline-flex h-10 shrink-0 items-center rounded-xl px-3 text-sm font-medium transition', active ? 'bg-accent-soft text-accent' : 'text-ink-2 hover:bg-hover hover:text-ink')}>27 {children}28 </button>29 );30}3132// --- fetching ---33export async function errorOf(res: Response): Promise<ApiError> {34 const body = (res.headers.get('content-type') ?? '').includes('json') ? await res.json().catch(() => null) : null;35 return new ApiError(res.status, body?.error ?? 'http_error', body?.message ?? `HTTP ${res.status}`, body ?? {});36}37const network = (e: Error): never => { if (e.name === 'AbortError') throw e; throw new ApiError(0, 'network', 'network error'); };38/** Why a URL that an <img>, <video> or pdf.js could not use fails: the API's own error when there is one. */39export async function explain(url: string): Promise<ApiError | null> {40 try {41 const res = await fetch(url, { credentials: 'same-origin', headers: { Range: 'bytes=0-0' } });42 if (res.ok || res.status === 416) { void res.body?.cancel().catch(() => {}); return null; }43 return await errorOf(res);44 } catch { return new ApiError(0, 'network', 'network error'); }45}4647export type Derived<T> =48 | { status: 'loading' } | { status: 'pending' } | { status: 'timeout' }49 | { status: 'failed'; error: ApiError } | { status: 'error'; error: ApiError } | { status: 'ready'; data: T };50const GIVE_UP_MS = 120_000;51/**52 * A derived preview made by the worker. 202 = not produced yet: poll (1.5 s, backing off to 5 s) and give up after two53 * minutes; 422 = the conversion failed (the server's reason is kept). `probe` only checks availability (first byte),54 * for previews that an <img> or pdf.js will load themselves.55 */56export function useDerived<T>(url: string, mode: 'json' | 'probe'): [Derived<T>, () => void] {57 const [state, setState] = useState<Derived<T>>({ status: 'loading' });58 const [attempt, setAttempt] = useState(0);59 useEffect(() => {60 const ctl = new AbortController();61 let timer: ReturnType<typeof setTimeout> | undefined;62 const started = Date.now();63 let delay = 1500;64 setState({ status: 'loading' });65 const run = async () => {66 try {67 const res = await fetch(url, { credentials: 'same-origin', signal: ctl.signal, headers: mode === 'probe' ? { Range: 'bytes=0-0' } : {} }).catch(network);68 if (res.status === 202) {69 void res.body?.cancel().catch(() => {});70 if (Date.now() - started > GIVE_UP_MS) return setState({ status: 'timeout' });71 setState({ status: 'pending' });72 timer = setTimeout(() => void run(), delay);73 delay = Math.min(5000, delay * 1.5);74 return;75 }76 if (!res.ok && !(mode === 'probe' && res.status === 416)) { const error = await errorOf(res); return setState({ status: res.status === 422 ? 'failed' : 'error', error }); }77 if (mode === 'probe') { void res.body?.cancel().catch(() => {}); return setState({ status: 'ready', data: undefined as T }); }78 setState({ status: 'ready', data: (await res.json()) as T });79 } catch (e) {80 if ((e as Error).name === 'AbortError') return;81 setState({ status: 'error', error: e instanceof ApiError ? e : new ApiError(0, 'unknown', (e as Error).message) });82 }83 };84 void run();85 return () => { ctl.abort(); clearTimeout(timer); };86 }, [url, mode, attempt]);87 return [state, useCallback(() => setAttempt((n) => n + 1), [])];88}8990export const TEXT_CAP = 2 * 1024 * 1024;91export type TextState = { status: 'loading' } | { status: 'error'; error: ApiError } | { status: 'ready'; text: string; truncated: boolean; total: number };92/** The first 2 MB of a file as text (a Range request: a huge log is never downloaded whole). */93export function useText(source: PreviewSource): [TextState, () => void] {94 const [state, setState] = useState<TextState>({ status: 'loading' });95 const [attempt, setAttempt] = useState(0);96 const url = source.contentUrl({ text: true });97 useEffect(() => {98 const ctl = new AbortController();99 setState({ status: 'loading' });100 (async () => {101 try {102 const res = await fetch(url, { credentials: 'same-origin', signal: ctl.signal, headers: { Range: `bytes=0-${TEXT_CAP - 1}` } }).catch(network);103 if (res.status === 416) return setState({ status: 'ready', text: '', truncated: false, total: 0 }); // empty file104 if (!res.ok) return setState({ status: 'error', error: await errorOf(res) });105 // a server that ignored the Range header must not make us hold more than the cap106 const reader = res.body!.getReader();107 const parts: Uint8Array[] = [];108 let got = 0, cut = false;109 for (;;) {110 const { done, value } = await reader.read();111 if (done) break;112 if (got + value.length > TEXT_CAP) { parts.push(value.subarray(0, TEXT_CAP - got)); got = TEXT_CAP; cut = true; void reader.cancel().catch(() => {}); break; }113 parts.push(value); got += value.length;114 }115 const bytes = new Uint8Array(got);116 let o = 0; for (const p of parts) { bytes.set(p, o); o += p.length; }117 const total = Number(/\/(\d+)$/.exec(res.headers.get('content-range') ?? '')?.[1] ?? (cut ? source.size : got));118 const enc = bytes[0] === 0xff && bytes[1] === 0xfe ? 'utf-16le' : bytes[0] === 0xfe && bytes[1] === 0xff ? 'utf-16be' : 'utf-8';119 setState({ status: 'ready', text: new TextDecoder(enc).decode(bytes), truncated: cut || total > got, total: Math.max(total, got) });120 } catch (e) {121 if ((e as Error).name === 'AbortError') return;122 setState({ status: 'error', error: e instanceof ApiError ? e : new ApiError(0, 'unknown', (e as Error).message) });123 }124 })();125 return () => ctl.abort();126 }, [url, attempt, source.size]);127 return [state, useCallback(() => setAttempt((n) => n + 1), [])];128}129130// --- panels ---131export function DownloadLink({ source, primary }: { source: PreviewSource; primary?: boolean }) {132 const t = useT();133 if (!source.canDownload) return null;134 return (135 <a href={source.contentUrl({ download: true })} download={source.name} data-testid="panel-download"136 className={cx('inline-flex h-10 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition', primary ? 'bg-accent text-accent-ink hover:brightness-110' : 'border border-line-strong bg-surface text-ink hover:bg-hover')}>137 <Glyph name="download" size={18} />{t('common.download')}138 </a>139 );140}141export function Panel({ icon, tone = 'neutral', title, text, detail, children, testId }: { icon: string; tone?: 'neutral' | 'warn'; title: string; text?: string; detail?: string; children?: ReactNode; testId?: string }) {142 return (143 <div className="flex h-full min-h-0 w-full items-center justify-center overflow-y-auto p-6" data-testid={testId}>144 <div className="flex max-w-md flex-col items-center gap-3 text-center">145 <div className={cx('flex h-14 w-14 items-center justify-center rounded-2xl', tone === 'warn' ? 'bg-warn-soft text-warn' : 'bg-accent-soft text-accent')}><Glyph name={icon} size={28} /></div>146 <h2 className="text-base font-semibold text-ink">{title}</h2>147 {text ? <p className="text-sm leading-relaxed text-ink-2">{text}</p> : null}148 {detail ? <p className="break-words rounded-xl bg-hover px-3 py-2 font-mono text-xs text-ink-2" data-testid="panel-detail">{detail}</p> : null}149 {children ? <div className="mt-1 flex flex-wrap items-center justify-center gap-2">{children}</div> : null}150 </div>151 </div>152 );153}154export function Busy({ label, text }: { label: string; text?: string }) {155 return (156 <div className="flex h-full w-full flex-col items-center justify-center gap-3 p-6 text-center text-ink-2" role="status" data-testid="preview-busy">157 <Spinner size={26} />158 <p className="text-sm font-medium text-ink">{label}</p>159 {text ? <p className="max-w-sm text-xs text-ink-3">{text}</p> : null}160 </div>161 );162}163/** An explicit state for an error from the API or the network, with a retry and the download when allowed. */164export function Failure({ error, source, onRetry, title }: { error: unknown; source: PreviewSource; onRetry?: () => void; title?: string }) {165 const t = useT();166 const e = error as Partial<ApiError> | null;167 const infected = e?.code === 'infected';168 const translated = errorText(error);169 return (170 <Panel icon={infected ? 'shield' : 'warning'} tone="warn" title={title ?? translated} text={title ? translated : undefined} testId="preview-error">171 {onRetry && !infected ? <Button icon="refresh" onClick={onRetry}>{t('common.retry')}</Button> : null}172 {e?.status === 401 || e?.status === 403 || e?.status === 404 ? null : <DownloadLink source={source} />}173 </Panel>174 );175}176/** Loading / pending / failed / timed-out states of a derived preview; renders `children` once it is ready. */177export function DerivedGate<T>({ state, retry, source, children }: { state: Derived<T>; retry: () => void; source: PreviewSource; children: (data: T) => ReactNode }) {178 const t = useT();179 switch (state.status) {180 case 'loading': return <Busy label={t('common.loading')} />;181 case 'pending': return <Busy label={t('preview.converting')} text={t('preview.convertingHint')} />;182 case 'timeout': return <Panel icon="clock" tone="warn" title={t('preview.timeoutTitle')} text={t('preview.timeoutText')} testId="preview-timeout"><Button icon="refresh" onClick={retry}>{t('common.retry')}</Button><DownloadLink source={source} /></Panel>;183 case 'failed': return (184 <Panel icon="warning" tone="warn" title={t('preview.failedTitle')} text={t('preview.failedText')} detail={state.error.message} testId="preview-failed">185 <DownloadLink source={source} primary />186 </Panel>187 );188 case 'error': return state.error.code === 'preview_unsupported' ? <NoPreview source={source} /> : <Failure error={state.error} source={source} onRetry={retry} />;189 case 'ready': return <>{children(state.data)}</>;190 }191}192export const extLabel = (name: string): string => { const m = /\.([A-Za-z0-9]{1,8})$/.exec(name); return m ? m[1]!.toUpperCase() : ''; };193export function NoPreview({ source }: { source: PreviewSource }) {194 const t = useT();195 const ext = extLabel(source.name);196 return (197 <Panel icon="file" title={t('preview.noneTitle')} text={`${ext ? t('preview.noneType', { ext }) : t('preview.noneTypeUnknown')} · ${fmtSize(source.size)}`} testId="preview-none">198 {source.canDownload ? <DownloadLink source={source} primary /> : <p className="text-xs text-ink-3">{t('preview.noDownload')}</p>}199 </Panel>200 );201}202/** A slim strip of information above the content (truncation, converted rendering…). */203export function Note({ tone = 'info', children, testId }: { tone?: 'info' | 'warn'; children: ReactNode; testId?: string }) {204 return (205 <div data-testid={testId} role={tone === 'warn' ? 'alert' : 'note'} className={cx('flex shrink-0 items-start gap-2 border-b border-line px-4 py-2 text-xs leading-relaxed', tone === 'warn' ? 'bg-warn-soft text-warn' : 'bg-surface text-ink-2')}>206 <Glyph name={tone === 'warn' ? 'warning' : 'info'} size={15} className="mt-0.5 shrink-0" /><span className="min-w-0">{children}</span>207 </div>208 );209}210211export const fmtSize = (n: number) => formatBytes(n, getLocale());212213/** Fixed-height row windowing for long lists inside a scroll container. */214export function useWindow(count: number, rowHeight: number, overscan = 12) {215 const ref = useRef<HTMLDivElement>(null);216 const [view, setView] = useState({ top: 0, height: 800 });217 useEffect(() => {218 const el = ref.current; if (!el) return;219 let raf = 0;220 const read = () => { raf = 0; setView((v) => (v.top === el.scrollTop && v.height === el.clientHeight ? v : { top: el.scrollTop, height: el.clientHeight })); };221 const on = () => { if (!raf) raf = requestAnimationFrame(read); };222 read();223 el.addEventListener('scroll', on, { passive: true });224 const ro = new ResizeObserver(on); ro.observe(el);225 return () => { el.removeEventListener('scroll', on); ro.disconnect(); cancelAnimationFrame(raf); };226 }, []);227 const start = Math.max(0, Math.floor(view.top / rowHeight) - overscan);228 const end = Math.min(count, Math.ceil((view.top + view.height) / rowHeight) + overscan);229 return { ref, start, end, before: start * rowHeight, after: Math.max(0, count - end) * rowHeight };230}231232/** A view that throws while rendering (an unexpected shape in a preview, a browser limit) ends in a panel, not a blank screen. */233export class Guard extends Component<{ source: PreviewSource; children: ReactNode }, { failed: boolean }> {234 override state = { failed: false };235 static getDerivedStateFromError() { return { failed: true }; }236 override render() { return this.state.failed ? <CrashPanel source={this.props.source} /> : this.props.children; }237}238function CrashPanel({ source }: { source: PreviewSource }) {239 const t = useT();240 return <Panel icon="warning" tone="warn" title={t('preview.crashTitle')} text={t('preview.crashText')} testId="preview-error"><DownloadLink source={source} primary /></Panel>;241}242