SPB Git

Repository Public

Palette.tsx 7.6 KB 107 lines tsx

1// Command palette (⌘K / Ctrl+K): the application's commands, and live file and folder results.2import { keepPreviousData, useQuery } from '@tanstack/react-query';3import { formatBytes } from '@spd/shared';4import { useEffect, useMemo, useRef, useState } from 'react';5import { useLocation } from 'wouter';6import { get, qs } from '../api.ts';7import { Glyph } from '../glyphs.tsx';8import { getLocale, useT } from '../i18n.ts';9import { useAuth } from '../session.ts';10import { setTheme, useTheme } from '../theme.ts';11import type { Listing } from '../types.ts';12import { cx, Dialog, Spinner } from '../ui.tsx';13import { changeLocale, otherLocale, useSignOut } from './account-ui.tsx';14import { ADMIN, NAV, SETTINGS } from './nav.ts';1516interface Entry { id: string; icon: string; label: string; hint?: string; group: 'commands' | 'results'; run: () => void }17const fold = (s: string) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();18const ICON: Record<string, string> = { image: 'image', heic: 'image', svg: 'image', video: 'video', audio: 'audio', archive: 'archive', sheet: 'sheet', csv: 'sheet', text: 'text', markdown: 'text', json: 'text' };1920export function Palette({ onClose }: { onClose: () => void }) {21  const t = useT();22  const [, navigate] = useLocation();23  const { state } = useAuth();24  const { dark } = useTheme();25  const signOut = useSignOut();26  const [query, setQuery] = useState('');27  const [term, setTerm] = useState('');28  const [active, setActive] = useState(0);29  const list = useRef<HTMLDivElement>(null);30  useEffect(() => { const h = setTimeout(() => setTerm(query.trim()), 180); return () => clearTimeout(h); }, [query]);31  const searching = term.length >= 2;32  const found = useQuery({ queryKey: ['palette-search', term], queryFn: ({ signal }) => get<Listing>(`/search${qs({ q: term })}`, { signal }), enabled: searching, placeholderData: keepPreviousData, staleTime: 10_000 });3334  const entries = useMemo<Entry[]>(() => {35    const go = (href: string) => () => navigate(href);36    const commands: Entry[] = [37      ...[...NAV, SETTINGS, ...(state?.user?.role === 'admin' ? [ADMIN] : [])].map((n) => ({ id: `nav${n.href}`, icon: n.icon, label: t(n.label), hint: t('account.palette.goTo'), group: 'commands' as const, run: go(n.href) })),38      { id: 'theme', icon: dark ? 'sun' : 'moon', label: dark ? t('account.theme.toLight') : t('account.theme.toDark'), group: 'commands', run: () => setTheme(dark ? 'light' : 'dark') },39      { id: 'lang', icon: 'globe', label: t('account.lang.switchTo'), group: 'commands', run: () => changeLocale(otherLocale(), true) },40      { id: 'signout', icon: 'logout', label: t('account.menu.signOut'), group: 'commands', run: () => void signOut() },41    ];42    const f = fold(query.trim());43    const out = f ? commands.filter((c) => fold(c.label).includes(f)) : commands;44    if (searching && found.data && query.trim().length >= 2) {45      for (const d of found.data.folders.slice(0, 6)) out.push({ id: `d${d.id}`, icon: 'folder', label: d.name, hint: t('account.palette.folder'), group: 'results', run: go(`/folder/${d.id}`) });46      for (const x of found.data.files.slice(0, 12)) out.push({ id: `f${x.id}`, icon: ICON[x.kind] ?? 'file', label: x.name, hint: formatBytes(x.size, getLocale()), group: 'results', run: go(`/folder/${x.folderId}?file=${x.id}`) });47    }48    return out;49    // eslint-disable-next-line react-hooks/exhaustive-deps50  }, [query, searching, found.data, state?.user?.role, dark, t, getLocale()]);51  useEffect(() => { setActive(0); }, [query]);52  const index = Math.min(active, Math.max(0, entries.length - 1));53  useEffect(() => { list.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }); }, [index, entries]);5455  const run = (e: Entry | undefined) => { if (!e) return; onClose(); e.run(); };56  const onKey = (e: React.KeyboardEvent) => {57    const n = entries.length;58    if (e.key === 'ArrowDown') { e.preventDefault(); if (n) setActive((index + 1) % n); }59    else if (e.key === 'ArrowUp') { e.preventDefault(); if (n) setActive((index - 1 + n) % n); }60    else if (e.key === 'Home' && n) { e.preventDefault(); setActive(0); }61    else if (e.key === 'End' && n) { e.preventDefault(); setActive(n - 1); }62    else if (e.key === 'Enter') { e.preventDefault(); run(entries[index]); }63  };64  const pending = query.trim().length >= 2 && (found.isFetching || term !== query.trim());65  const groups: { key: Entry['group']; title: string }[] = [{ key: 'commands', title: t('account.palette.commands') }, { key: 'results', title: t('account.palette.results') }];66  return (67    <Dialog title={t('account.palette.title')} onClose={onClose} wide>68      <div className="flex flex-col gap-3">69        <div className="relative">70          <Glyph name="search" size={18} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-ink-3" />71          <input data-autofocus type="text" role="combobox" aria-expanded="true" aria-controls="spd-palette-list" aria-autocomplete="list" aria-label={t('account.palette.placeholder')}72            aria-activedescendant={entries[index] ? `spd-palette-${entries[index]!.id}` : undefined} placeholder={t('account.palette.placeholder')} value={query} onChange={(e) => setQuery(e.currentTarget.value)} onKeyDown={onKey}73            autoComplete="off" autoCapitalize="off" spellCheck={false} enterKeyHint="go"74            className="h-12 w-full rounded-2xl border border-line-strong bg-surface pr-10 pl-10 text-base text-ink placeholder:text-ink-3 focus:border-accent focus:ring-4 focus:ring-accent/15 focus:outline-none" />75          {pending ? <span className="absolute top-1/2 right-3 -translate-y-1/2 text-ink-3"><Spinner size={16} /></span> : null}76        </div>77        <div ref={list} id="spd-palette-list" role="listbox" aria-label={t('account.palette.title')} className="-mx-2 h-[min(22rem,50dvh)] overflow-y-auto">78          {groups.map((g) => {79            const items = entries.filter((e) => e.group === g.key);80            if (!items.length) return null;81            return (82              <div key={g.key} role="group" aria-label={g.title}>83                <div className="px-3 pt-2 pb-1 text-xs font-medium tracking-wide text-ink-3 uppercase" aria-hidden="true">{g.title}</div>84                {items.map((e) => {85                  const on = entries[index]?.id === e.id;86                  return (87                    <div key={e.id} id={`spd-palette-${e.id}`} role="option" aria-selected={on} onMouseMove={() => { if (!on) setActive(entries.indexOf(e)); }} onClick={() => run(e)}88                      className={cx('mx-1 flex min-h-11 cursor-pointer items-center gap-3 rounded-xl px-3 py-2 text-sm', on ? 'bg-accent-soft text-ink' : 'text-ink')}>89                      <Glyph name={e.icon} size={18} className={cx('shrink-0', on ? 'text-accent' : 'text-ink-3')} />90                      <span className="min-w-0 flex-1 truncate">{e.label}</span>91                      {e.hint ? <span className="shrink-0 text-xs text-ink-3">{e.hint}</span> : null}92                    </div>93                  );94                })}95              </div>96            );97          })}98          {!entries.length && !pending ? <p className="px-3 py-8 text-center text-sm text-ink-2" role="status">{found.error ? t('account.palette.searchError') : t('account.palette.nothing')}</p> : null}99        </div>100        <p className="hidden items-center gap-4 border-t border-line pt-3 text-xs text-ink-3 sm:flex" aria-hidden="true">101          <span><kbd className="font-sans">↑↓</kbd> {t('account.palette.hintMove')}</span><span><kbd className="font-sans">↵</kbd> {t('account.palette.hintOpen')}</span><span><kbd className="font-sans">Esc</kbd> {t('common.close')}</span>102        </p>103      </div>104    </Dialog>105  );106}107
Files main
Menu

Theme