Repository Public
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