SPB Git

Repository Public

Thumb.tsx 4.7 KB 68 lines tsx

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
Files main
Menu

Theme