SPB Git

Repository Public

SharePage.tsx 24.7 KB 335 lines tsx

1// A share link, as seen by a visitor without an account. Opening the link counts one view on the server and sets a2// short-lived grant cookie; everything after that is re-checked by the server on each request.3import { formatBytes, type PreviewKind } from '@spd/shared';4import { type FormEvent, useCallback, useEffect, useRef, useState } from 'react';5import { ApiError, get, post } from '../api.ts';6import { Art } from '../art.tsx';7import { Glyph } from '../glyphs.tsx';8import { fmtDate, getLocale, useT } from '../i18n.ts';9import { Thumb } from '../preview/Thumb.tsx';10import { Viewer } from '../preview/Viewer.tsx';11import type { PreviewSource } from '../types.ts';12import { Button, cx, IconButton, ProgressBar, Spinner } from '../ui.tsx';13import { Notice, PField, PublicShell } from './PublicShell.tsx';14import { store, tokenKey } from './util.ts';1516interface PubFile { id: string; name: string; size: number; kind: PreviewKind; updatedAt: string; thumb: boolean }17interface PubFolder { id: string; name: string; updatedAt: string }18type Opened = { label: string; owner: string; canDownload: boolean; expiresAt: string | null } & ({ type: 'file'; file: PubFile } | { type: 'folder'; root: { id: string; name: string } });19interface FolderListing { folder: { id: string; name: string }; breadcrumbs: { id: string; name: string }[]; folders: PubFolder[]; files: PubFile[] }20type State = { kind: 'loading' } | { kind: 'unavailable' } | { kind: 'error'; tooMany?: boolean } | { kind: 'gate'; needsPassword: boolean; locked: boolean } | { kind: 'open'; share: Opened };21/** What a failed call means for the page: the link died, the grant expired, or something else. */22type Fate = 'unavailable' | 'locked' | 'other';23const fateOf = (e: unknown): Fate => (e instanceof ApiError && e.code === 'share_unavailable' ? 'unavailable' : e instanceof ApiError && e.code === 'share_locked' ? 'locked' : 'other');2425const base = (token: string) => `/api/v1/public/shares/${token}`;26const sourceOf = (token: string, f: PubFile, canDownload: boolean): PreviewSource => ({27  id: f.id, name: f.name, size: f.size, kind: f.kind, canDownload,28  contentUrl: (o) => `${base(token)}/files/${f.id}/content${o?.download ? '?download=1' : o?.text ? '?as=text' : ''}`,29  previewUrl: (k) => `${base(token)}/files/${f.id}/preview/${k}`,30});31const KIND_ICON: Partial<Record<PreviewKind, string>> = { image: 'image', heic: 'image', svg: 'image', video: 'video', audio: 'audio', archive: 'archive', sheet: 'sheet', csv: 'sheet', text: 'text', markdown: 'text', json: 'text' };32/** Above this size a download is handed to the browser directly instead of being read in memory first. */33const BLOB_LIMIT = 300 * 1024 * 1024;3435// One "open" per page load, even if React mounts the page twice (development) or the visitor double-submits.36const opening = new Map<string, Promise<Opened>>();37const openOnce = (token: string, password?: string): Promise<Opened> => {38  const k = `${token}|${password ?? ''}`;39  let p = opening.get(k);40  if (!p) { p = post<Opened>(`/public/shares/${token}/open`, password ? { password } : {}); opening.set(k, p); void p.catch(() => opening.delete(k)); }41  return p;42};4344export function SharePage({ token }: { token: string }) {45  const t = useT();46  const key = tokenKey('spd.share', token);47  const [state, setState] = useState<State>({ kind: 'loading' });48  const [round, setRound] = useState(0);4950  const enter = useCallback((share: Opened) => { store.set(key, share); setState({ kind: 'open', share }); }, [key]);51  /** A call failed in the middle of the visit: revoked or expired link → neutral state; grant expired → reopen. */52  const fail = useCallback((e: unknown): Fate => {53    const f = fateOf(e);54    if (f === 'unavailable') { store.del(key); setState({ kind: 'unavailable' }); }55    if (f === 'locked') { store.del(key); opening.clear(); setState((s) => ({ kind: 'gate', needsPassword: s.kind === 'gate' ? s.needsPassword : false, locked: true })); void get<{ needsPassword: boolean }>(`/public/shares/${token}`).then((r) => setState((s) => (s.kind === 'gate' ? { ...s, needsPassword: r.needsPassword } : s)), (x) => { if (fateOf(x) === 'unavailable') setState({ kind: 'unavailable' }); }); }56    return f;57  }, [key, token]);5859  useEffect(() => {60    let live = true;61    void (async () => {62      try {63        const { needsPassword } = await get<{ needsPassword: boolean }>(`/public/shares/${token}`);64        // a reload of the tab while the grant is still valid does not count a second view65        const cached = store.get<Opened>(key);66        if (cached) {67          try {68            if (cached.type === 'folder') await get(`/public/shares/${token}/list`);69            else await probe(token, cached.file.id);70            if (live) setState({ kind: 'open', share: cached });71            return;72          } catch (e) { store.del(key); if (fateOf(e) === 'unavailable') throw e; }73        }74        if (needsPassword) { if (live) setState({ kind: 'gate', needsPassword: true, locked: false }); return; }75        const share = await openOnce(token);76        if (live) enter(share);77      } catch (e) {78        if (!live) return;79        setState(e instanceof ApiError && e.status === 404 ? { kind: 'unavailable' } : { kind: 'error', tooMany: e instanceof ApiError && e.status === 429 });80      }81    })();82    return () => { live = false; };83  }, [token, key, round, enter]);8485  const share = state.kind === 'open' ? state.share : null;86  const title = share ? share.label || (share.type === 'file' ? share.file.name : share.root.name) : t('public.share.title');87  return (88    <PublicShell title={title} wide={share?.type === 'folder'}>89      {state.kind === 'loading' ? <div className="flex justify-center py-24 text-ink-3"><Spinner size={28} /></div>90        : state.kind === 'unavailable' ? <Notice icon="link" title={t('public.share.unavailable')} text={t('public.share.unavailableText')} />91          : state.kind === 'error' ? <Notice icon="warning" tone="warn" title={state.tooMany ? t('public.err.tooMany') : t('public.err.network')} action={<Button size="lg" icon="refresh" onClick={() => { setState({ kind: 'loading' }); setRound((n) => n + 1); }}>{t('public.retry')}</Button>} />92            : state.kind === 'gate' ? <Gate token={token} needsPassword={state.needsPassword} locked={state.locked} onOpen={enter} onGone={() => setState({ kind: 'unavailable' })} />93              : state.share.type === 'file' ? <FileShare token={token} share={state.share} file={state.share.file} fail={fail} />94                : <FolderShare token={token} share={state.share} rootId={state.share.root.id} fail={fail} />}95    </PublicShell>96  );97}9899/** Is the file still reachable? One byte of content is asked for; nothing is counted. */100async function probe(token: string, fileId: string): Promise<void> {101  const res = await fetch(`${base(token)}/files/${fileId}/content`, { headers: { Range: 'bytes=0-0' }, credentials: 'same-origin' }).catch(() => { throw new ApiError(0, 'network', ''); });102  if (res.ok) { void res.body?.cancel().catch(() => {}); return; }103  const body = await res.json().catch(() => null);104  if (body?.error === 'preview_unsupported') return; // alive, simply not displayable inline105  throw new ApiError(res.status, body?.error ?? 'http_error', body?.message ?? '');106}107108function Gate({ token, needsPassword, locked, onOpen, onGone }: { token: string; needsPassword: boolean; locked: boolean; onOpen: (s: Opened) => void; onGone: () => void }) {109  const t = useT();110  const [password, setPassword] = useState('');111  const [error, setError] = useState<string | null>(null);112  const [busy, setBusy] = useState(false);113  const submit = async (e: FormEvent) => {114    e.preventDefault();115    if (needsPassword && !password) { setError(t('public.err.passwordRequired')); return; }116    setBusy(true); setError(null);117    try { onOpen(await openOnce(token, needsPassword ? password : undefined)); }118    catch (x) {119      setBusy(false);120      const c = x instanceof ApiError ? x : new ApiError(0, 'network', '');121      if (c.status === 404) onGone();122      else setError(c.code === 'password_invalid' ? t('public.err.passwordInvalid') : c.code === 'password_required' ? t('public.err.passwordRequired') : c.status === 429 ? t('public.err.tooMany') : c.status === 0 ? t('public.err.network') : t('public.err.generic'));123    }124  };125  return (126    <form onSubmit={submit} noValidate className="mx-auto max-w-sm card px-6 py-8" data-testid="gate">127      <div className="flex justify-center"><Art name={locked ? 'clock' : 'lock'} size={112} /></div>128      <h1 className="mt-3 text-center text-2xl font-bold tracking-tight">{locked ? t('public.share.locked') : t('public.share.protected')}</h1>129      <p className="mt-2 text-center text-sm leading-relaxed text-ink-2">{locked ? t('public.share.lockedText') : t('public.share.protectedText')}</p>130      <div className="mt-5 space-y-4">131        {needsPassword ? <PField label={t('public.password')} type="password" autoComplete="off" autoFocus value={password} onChange={(e) => { setPassword(e.target.value); setError(null); }} error={error} /> : error ? <p role="alert" className="text-sm text-danger">{error}</p> : null}132        <Button type="submit" variant="primary" size="lg" busy={busy} className="w-full">{locked && !needsPassword ? t('public.share.reopen') : t('public.share.open')}</Button>133      </div>134    </form>135  );136}137138// ---------------------------------------------------------------- downloads ----------------------------------------------------------------139type Dl = { id: string; pct: number | null } | null;140/**141 * Download through fetch so that a refusal (limit reached, link revoked, grant expired) is explained on the page instead142 * of the browser showing the server's raw answer. Very large files go to the browser's own download manager.143 */144function useDownload(fail: (e: unknown) => Fate) {145  const t = useT();146  const [active, setActive] = useState<Dl>(null);147  const [message, setMessage] = useState<string | null>(null);148  const run = async (id: string, url: string, name: string, size: number | null) => {149    if (active) return;150    setMessage(null);151    if (size === null || size > BLOB_LIMIT) { window.location.assign(url); return; }152    setActive({ id, pct: null });153    try {154      const res = await fetch(url, { credentials: 'same-origin' }).catch(() => { throw new ApiError(0, 'network', ''); });155      if (!res.ok) { const b = await res.json().catch(() => null); throw new ApiError(res.status, b?.error ?? 'http_error', b?.message ?? ''); }156      const total = Number(res.headers.get('content-length')) || size || 0;157      const parts: BlobPart[] = []; let got = 0;158      const reader = res.body?.getReader();159      if (reader) for (;;) { const { done, value } = await reader.read(); if (done) break; parts.push(value); got += value.byteLength; if (total) setActive({ id, pct: Math.min(99, Math.floor((got / total) * 100)) }); }160      else parts.push(await res.blob());161      const blobUrl = URL.createObjectURL(new Blob(parts, { type: 'application/octet-stream' }));162      const a = document.createElement('a'); a.href = blobUrl; a.download = name; a.rel = 'noopener'; document.body.appendChild(a); a.click(); a.remove();163      setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);164    } catch (e) {165      if (fail(e) === 'other') {166        const c = e instanceof ApiError ? e : new ApiError(0, 'network', '');167        setMessage(c.code === 'download_limit' ? t('public.share.downloadLimit') : c.code === 'download_forbidden' ? t('public.share.downloadForbidden') : c.code === 'empty_selection' ? t('public.share.emptyZip')168          : c.status === 404 ? t('public.share.fileGone') : c.status === 429 ? t('public.err.tooMany') : c.status === 0 ? t('public.err.network') : t('public.err.generic'));169      }170    } finally { setActive(null); }171  };172  return { active, message, setMessage, run };173}174function Alert({ text, onClose }: { text: string | null; onClose: () => void }) {175  const t = useT();176  return text ? (177    <div role="alert" className="flex items-start gap-3 rounded-2xl bg-warn-soft px-4 py-3 text-sm text-warn" data-testid="share-alert">178      <Glyph name="warning" size={18} className="mt-px shrink-0" /><p className="min-w-0 flex-1">{text}</p>179      <button type="button" onClick={onClose} aria-label={t('public.share.dismiss')} className="-m-1 shrink-0 rounded p-1"><Glyph name="x" size={16} /></button>180    </div>181  ) : null;182}183function ShareHead({ share, name, icon, sub }: { share: Opened; name: string; icon: string; sub?: string }) {184  const t = useT();185  return (186    <header className="flex items-start gap-4">187      <div className="btn-grad flex h-14 w-14 shrink-0 items-center justify-center rounded-[18px]"><Glyph name={icon} size={26} /></div>188      <div className="min-w-0 flex-1">189        <h1 className="text-2xl leading-tight font-bold tracking-tight break-words">{name}</h1>190        {share.label && share.label !== name ? <p className="mt-0.5 text-sm break-words text-ink-2">{share.label}</p> : null}191        <p className="mt-1 text-sm text-ink-3">{[sub, share.owner ? t('public.share.sharedBy', { owner: share.owner }) : '', share.expiresAt ? t('public.share.expires', { date: fmtDate(share.expiresAt) }) : ''].filter(Boolean).join(' · ')}</p>192        {share.canDownload ? null : <p className="mt-2 inline-flex items-center gap-1.5 rounded-full bg-hover px-2.5 py-1 text-xs font-medium text-ink-2" title={t('public.share.viewOnlyText')}><Glyph name="eye" size={14} />{t('public.share.viewOnly')}</p>}193      </div>194    </header>195  );196}197198// ---------------------------------------------------------------- one file ----------------------------------------------------------------199function FileShare({ token, share, file, fail }: { token: string; share: Opened; file: PubFile; fail: (e: unknown) => Fate }) {200  const t = useT();201  const loc = getLocale();202  const [viewing, setViewing] = useState(false);203  const [checking, setChecking] = useState(false);204  const dl = useDownload(fail);205  const src = sourceOf(token, file, share.canDownload);206  const open = async () => {207    setChecking(true);208    try { await probe(token, file.id); setViewing(true); } catch (e) { if (fail(e) === 'other') dl.setMessage(e instanceof ApiError && e.status === 404 ? t('public.share.fileGone') : t('public.err.network')); } finally { setChecking(false); }209  };210  return (211    <div className="mx-auto max-w-xl space-y-4" data-testid="file-share">212      <section className="card p-5 sm:p-6">213        <ShareHead share={share} name={file.name} icon={KIND_ICON[file.kind] ?? 'file'} sub={formatBytes(file.size, loc)} />214        <button type="button" onClick={open} aria-label={`${t('public.share.preview')} — ${file.name}`}215          className="press mt-5 flex aspect-[4/3] w-full items-center justify-center overflow-hidden rounded-[22px] bg-bg ring-1 ring-line ring-inset">216          <Thumb src={file.thumb ? src.previewUrl('thumb') : undefined} name={file.name} kind={file.kind} className="h-full w-full rounded-none!" />217        </button>218        <div className="mt-4 flex flex-col gap-2 sm:flex-row">219          <Button size="lg" variant={share.canDownload ? 'secondary' : 'primary'} icon="eye" className="sm:flex-1" busy={checking} onClick={open}>{t('public.share.preview')}</Button>220          {share.canDownload ? <Button size="lg" variant="primary" icon="download" className="sm:flex-1" busy={!!dl.active} onClick={() => void dl.run(file.id, src.contentUrl({ download: true }), file.name, file.size)}>{dl.active?.pct != null ? t('public.share.downloading', { pct: dl.active.pct }) : t('public.share.download')}</Button> : null}221        </div>222        {dl.active?.pct != null ? <div className="mt-3"><ProgressBar value={dl.active.pct / 100} label={t('public.share.downloadName', { name: file.name })} /></div> : null}223      </section>224      <Alert text={dl.message} onClose={() => dl.setMessage(null)} />225      {viewing ? <Viewer source={src} onClose={() => setViewing(false)} /> : null}226    </div>227  );228}229230// ---------------------------------------------------------------- a folder ----------------------------------------------------------------231const hashFolder = () => { const h = window.location.hash.slice(1); return /^[0-9a-f-]{36}$/i.test(h) ? h : null; };232function FolderShare({ token, share, rootId, fail }: { token: string; share: Opened; rootId: string; fail: (e: unknown) => Fate }) {233  const t = useT();234  const loc = getLocale();235  const [folderId, setFolderId] = useState<string | null>(hashFolder);236  const [listing, setListing] = useState<FolderListing | null>(null);237  const [loading, setLoading] = useState(true);238  const [grid, setGrid] = useState(() => window.matchMedia('(min-width: 640px)').matches);239  const [viewIndex, setViewIndex] = useState<number | null>(null);240  const dl = useDownload(fail);241  const failRef = useRef(fail); failRef.current = fail;242  const setMessage = dl.setMessage;243244  useEffect(() => { const h = () => setFolderId(hashFolder()); window.addEventListener('hashchange', h); return () => window.removeEventListener('hashchange', h); }, []);245  const go = (id: string) => { window.location.hash = id === rootId ? '' : id; setFolderId(id === rootId ? null : id); };246  const load = useCallback(async (id: string | null, quiet = false): Promise<boolean> => {247    if (!quiet) setLoading(true);248    try { setListing(await get<FolderListing>(`/public/shares/${token}/list${id ? `?folder=${id}` : ''}`)); return true; }249    catch (e) {250      if (failRef.current(e) === 'other') {251        if (e instanceof ApiError && e.status === 404 && id) { setMessage(t('public.share.folderGone')); window.location.hash = ''; setFolderId(null); } // outside the share, or deleted since252        else setMessage(e instanceof ApiError && e.status === 429 ? t('public.err.tooMany') : t('public.err.network'));253      }254      return false;255    } finally { setLoading(false); }256  }, [token, setMessage, t]);257  useEffect(() => { setViewIndex(null); void load(folderId); }, [folderId, load]);258259  const files = listing?.files ?? [], folders = listing?.folders ?? [];260  /** Re-ask the server before showing a file: a revoked link must not appear to work. */261  const openFile = async (i: number) => { if (await load(folderId, true)) setViewIndex(i); };262  const viewed = viewIndex !== null ? files[viewIndex] : undefined;263  const hasSub = folders.length > 0, sum = files.reduce((n, f) => n + f.size, 0);264  const zip = () => void dl.run('zip', `${base(token)}/zip${listing && listing.folder.id !== rootId ? `?folder=${listing.folder.id}` : ''}`, `${listing?.folder.name ?? 'dossier'}.zip`, hasSub ? null : sum);265266  return (267    <div className="space-y-4" data-testid="folder-share">268      <section className="card p-5 sm:p-6">269        <ShareHead share={share} name={share.type === 'folder' ? share.root.name : ''} icon="folder" />270      </section>271      <Alert text={dl.message} onClose={() => dl.setMessage(null)} />272      <section className="card">273        <div className="flex flex-wrap items-center gap-2 border-b border-line px-3 py-2 sm:px-4">274          <nav aria-label={t('public.share.breadcrumb')} className="min-w-0 flex-1">275            <ol className="flex flex-wrap items-center gap-0.5 text-sm" data-testid="crumbs">276              {(listing?.breadcrumbs ?? [{ id: rootId, name: share.type === 'folder' ? share.root.name : '' }]).map((c, i, all) => (277                <li key={c.id} className="flex min-w-0 items-center gap-0.5">278                  {i ? <Glyph name="chevronRight" size={14} className="shrink-0 text-ink-3" /> : null}279                  {i === all.length - 1 ? <span aria-current="page" className="truncate px-1.5 py-1.5 font-semibold">{c.name}</span>280                    : <button type="button" onClick={() => go(c.id)} className="max-w-40 truncate rounded-lg px-1.5 py-1.5 text-ink-2 hover:bg-hover hover:text-ink">{c.name}</button>}281                </li>282              ))}283            </ol>284          </nav>285          <div className="flex shrink-0 items-center gap-1">286            <IconButton icon={grid ? 'list' : 'grid'} label={grid ? t('public.share.viewList') : t('public.share.viewGrid')} onClick={() => setGrid((g) => !g)} />287            {share.canDownload && (files.length || folders.length) ? <Button icon="download" busy={dl.active?.id === 'zip'} onClick={zip} data-testid="zip">{dl.active?.id === 'zip' && dl.active.pct != null ? t('public.share.downloading', { pct: dl.active.pct }) : t('public.share.downloadAll')}</Button> : null}288          </div>289        </div>290        {loading && !listing ? <div className="flex justify-center py-16 text-ink-3"><Spinner /></div>291          : !files.length && !folders.length ? <p className="px-6 py-16 text-center text-sm text-ink-3">{t('public.share.emptyFolder')}</p>292            : grid ? (293              <ul className={cx('grid grid-cols-2 gap-3 p-3 sm:grid-cols-3 sm:p-4 lg:grid-cols-4', loading && 'opacity-60')} data-testid="entries">294                {folders.map((d) => (295                  <li key={d.id}><button type="button" onClick={() => go(d.id)} className="press flex w-full flex-col overflow-hidden rounded-[18px] bg-bg text-left ring-1 ring-line ring-inset" data-entry="folder">296                    <span className="flex aspect-[4/3] w-full items-center justify-center text-accent"><Thumb name={d.name} kind="folder" className="h-14 w-14" /></span>297                    <span className="truncate border-t border-line bg-surface px-3 py-2.5 text-sm font-medium">{d.name}</span>298                  </button></li>299                ))}300                {files.map((f, i) => (301                  <li key={f.id} className="relative overflow-hidden rounded-[18px] bg-bg ring-1 ring-line ring-inset" data-entry="file">302                    <button type="button" onClick={() => void openFile(i)} aria-label={t('public.share.openItem', { name: f.name })} className="flex w-full flex-col text-left">303                      <span className="flex aspect-[4/3] w-full items-center justify-center overflow-hidden text-ink-3"><Thumb src={f.thumb ? sourceOf(token, f, false).previewUrl('thumb') : undefined} name={f.name} kind={f.kind} className="h-full w-full rounded-none!" /></span>304                      <span className={cx('block w-full border-t border-line bg-surface py-2 pl-3', share.canDownload ? 'pr-11' : 'pr-3')}><span className="block truncate text-sm font-medium">{f.name}</span><span className="block text-xs text-ink-3">{formatBytes(f.size, loc)}</span></span>305                    </button>306                    {share.canDownload ? <IconButton icon="download" size={18} label={t('public.share.downloadName', { name: f.name })} disabled={!!dl.active} className="absolute right-0.5 bottom-1" onClick={() => void dl.run(f.id, sourceOf(token, f, true).contentUrl({ download: true }), f.name, f.size)} /> : null}307                  </li>308                ))}309              </ul>310            ) : (311              <ul className={cx('divide-y divide-line', loading && 'opacity-60')} data-testid="entries">312                {folders.map((d) => (313                  <li key={d.id}><button type="button" onClick={() => go(d.id)} className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-hover" data-entry="folder">314                    <Thumb name={d.name} kind="folder" className="h-10 w-10" /><span className="min-w-0 flex-1 truncate text-[15px] font-medium">{d.name}</span><Glyph name="chevronRight" size={18} className="shrink-0 text-ink-3" />315                  </button></li>316                ))}317                {files.map((f, i) => (318                  <li key={f.id} className="flex items-center hover:bg-hover" data-entry="file">319                    <button type="button" onClick={() => void openFile(i)} aria-label={t('public.share.openItem', { name: f.name })} className="flex min-w-0 flex-1 items-center gap-3 py-3 pl-4 text-left">320                      <Thumb src={f.thumb ? sourceOf(token, f, false).previewUrl('thumb') : undefined} name={f.name} kind={f.kind} className="h-10 w-10" />321                      <span className="min-w-0 flex-1"><span className="block truncate text-[15px] font-medium">{f.name}</span><span className="block text-xs text-ink-3">{formatBytes(f.size, loc)} · {t('public.share.modified', { date: fmtDate(f.updatedAt, { dateStyle: 'medium' }) })}</span></span>322                    </button>323                    {share.canDownload ? <IconButton icon="download" label={t('public.share.downloadName', { name: f.name })} disabled={!!dl.active} className="mx-1 h-11 w-11" onClick={() => void dl.run(f.id, sourceOf(token, f, true).contentUrl({ download: true }), f.name, f.size)} /> : <span className="w-3" />}324                  </li>325                ))}326              </ul>327            )}328        {listing && (files.length || folders.length) ? <p className="border-t border-line px-4 py-2.5 text-xs text-ink-3">{t('public.share.items', { n: files.length + folders.length })}</p> : null}329      </section>330      {viewed ? <Viewer source={sourceOf(token, viewed, share.canDownload)} onClose={() => setViewIndex(null)}331        onPrev={viewIndex! > 0 ? () => setViewIndex(viewIndex! - 1) : undefined} onNext={viewIndex! < files.length - 1 ? () => setViewIndex(viewIndex! + 1) : undefined} /> : null}332    </div>333  );334}335
Files main
Menu

Theme