Repository Public
1import './preview.css';2import type { PreviewKind } from '@spd/shared';3import { useEffect, useRef, useState } from 'react';4import { Glyph } from '../glyphs.tsx';5import { cx } from '../ui.tsx';67type Family = 'folder' | 'image' | 'video' | 'audio' | 'pdf' | 'document' | 'sheet' | 'archive' | 'text' | 'other';8const FAMILY: Record<PreviewKind | 'folder', Family> = {9 folder: 'folder', image: 'image', heic: 'image', svg: 'image', video: 'video', audio: 'audio', pdf: 'pdf', office: 'document', sheet: 'sheet', csv: 'sheet',10 archive: 'archive', text: 'text', markdown: 'text', json: 'text', none: 'other',11};12const ICON: Record<Family, string> = { folder: 'folder', image: 'image', video: 'video', audio: 'audio', pdf: 'pdf', document: 'doc', sheet: 'sheet', archive: 'archive', text: 'text', other: 'file' };13const RETRY_MS = [2000, 5000, 12000];1415/** The picture over the tile. Its state belongs to one URL: the parent gives it a key, so nothing is reset after the fact. */16function Picture({ src, onShown, page }: { src: string; onShown: (shown: boolean) => void; page: boolean }) {17 const [state, setState] = useState<'idle' | 'loaded' | 'waiting' | 'off'>('idle');18 const [round, setRound] = useState(0);19 const tries = useRef(0);20 const timer = useRef<ReturnType<typeof setTimeout>>(undefined);21 const alive = useRef(true);22 useEffect(() => { alive.current = true; return () => { alive.current = false; clearTimeout(timer.current); }; }, []);23 const onError = () => {24 onShown(false);25 if (tries.current >= RETRY_MS.length) return setState('off');26 setState('waiting');27 timer.current = setTimeout(() => {28 // only come back with an <img> when the API really has the picture now (202 = the worker has not made it yet)29 fetch(src, { credentials: 'same-origin' }).then((r) => {30 void r.body?.cancel().catch(() => {});31 if (!alive.current) return;32 if (r.status === 200) { setState('idle'); setRound((n) => n + 1); } else if (r.status === 202) onError(); else setState('off');33 }, () => { if (alive.current) setState('off'); });34 }, RETRY_MS[tries.current++]);35 };36 if (state !== 'idle' && state !== 'loaded') return null;37 return (38 <img key={round} src={src} alt="" loading="lazy" decoding="async" draggable={false} onLoad={() => { setState('loaded'); onShown(true); }} onError={onError}39 className={cx('absolute object-cover transition-opacity duration-200', state === 'loaded' ? 'opacity-100' : 'opacity-0',40 // the first page of a document is a sheet of paper laid on the coloured tile: a blank page still reads as a document, in both themes41 page ? 'spd-sheet inset-x-[17%] top-[13%] h-[87%] w-[66%] rounded-t-[8%] object-top' : 'inset-0 h-full w-full')} />42 );43}4445/**46 * Thumbnail when `src` is given and loads, otherwise an icon tile for the kind. The tile is always there underneath,47 * so there is no layout shift and never a broken-image icon. A thumbnail the worker has not produced yet (the API48 * answers 202) is checked again a few times, then the icon stays.49 */50export function Thumb({ src, name, kind, className }: { src?: string; name: string; kind: PreviewKind | 'folder'; className?: string }) {51 const family = FAMILY[kind] ?? 'other';52 const page = family === 'pdf' || family === 'document' || family === 'sheet';53 const [shown, setShown] = useState<string | null>(null); // the URL whose picture is on screen54 const ext = /\.([A-Za-z0-9]{1,5})$/.exec(name)?.[1]?.toUpperCase();55 return (56 <span data-testid="thumb" data-family={family} data-state={src && shown === src ? 'image' : 'icon'} role="img" aria-label={name}57 className={cx('spd-thumb @container relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-[18%]', className ?? 'h-10 w-10')}>58 <span className="flex h-full w-full flex-col items-center justify-center gap-[6%]" aria-hidden="true">59 <Glyph name={ICON[family]} className="h-[46%] max-h-14 w-[46%] max-w-14" strokeWidth={1.6} />60 {ext && kind !== 'folder' ? <span className="hidden max-w-[80%] truncate text-[10px] font-semibold tracking-wide opacity-80 @[88px]:block @[140px]:text-xs">{ext}</span> : null}61 </span>62 {src ? <Picture key={src} src={src} page={page} onShown={(ok) => setShown(ok ? src : null)} /> : null}63 {page && ext && src && shown === src ? <span className="spd-badge pointer-events-none absolute bottom-[7%] left-1/2 hidden -translate-x-1/2 rounded-full px-2 py-0.5 text-[10px] leading-tight font-bold tracking-wide @[88px]:block" aria-hidden="true">{ext}</span> : null}64 {src && shown === src ? <span className="pointer-events-none absolute inset-0 rounded-[inherit] border border-line" aria-hidden="true" /> : null}65 </span>66 );67}68