SPB Git

Repository Public

dialogs.tsx 16.1 KB 250 lines tsx

1// Dialogs of the explorer: name (new folder, rename), destination picker (move, copy), tags.2import { splitExt } from '@spd/shared';3import { useQuery, useQueryClient } from '@tanstack/react-query';4import { type FormEvent, useEffect, useRef, useState } from 'react';5import { ApiError, del, get, patch, post } from '../api.ts';6import { Glyph } from '../glyphs.tsx';7import { errorText, useT } from '../i18n.ts';8import type { FileItem, Folder, Tag } from '../types.ts';9import { Button, cx, Dialog, IconButton, Loading, Spinner, TextField, useToast } from '../ui.tsx';10import { TAG_COLORS, tagStyle, XKEY } from './lib.ts';1112/** Ask for a name; a refusal of the server (409 name_conflict…) is shown under the field and the dialog stays open. */13export function NameDialog({ title, label, initial = '', submitLabel, selectStem, onSubmit, onClose }: { title: string; label: string; initial?: string; submitLabel: string; selectStem?: boolean; onSubmit: (name: string) => Promise<void>; onClose: () => void }) {14  const t = useT();15  const [name, setName] = useState(initial);16  const [error, setError] = useState<string | null>(null);17  const [busy, setBusy] = useState(false);18  const form = useRef<HTMLFormElement>(null);19  useEffect(() => { const el = form.current?.querySelector('input'); if (!el) return; el.focus(); el.setSelectionRange(0, selectStem ? splitExt(initial)[0].length : initial.length); }, []); // eslint-disable-line react-hooks/exhaustive-deps20  const submit = async (e: FormEvent) => {21    e.preventDefault();22    const v = name.trim();23    if (!v) return;24    if (v === initial) { onClose(); return; }25    setBusy(true); setError(null);26    try { await onSubmit(v); onClose(); } catch (err) { setError(errorText(err)); setBusy(false); }27  };28  return (29    <Dialog title={title} onClose={onClose}>30      <form ref={form} onSubmit={submit} className="flex flex-col gap-4">31        <TextField label={label} value={name} error={error} maxLength={255} autoComplete="off" autoCapitalize="sentences" spellCheck={false} data-autofocus32          onChange={(e) => { setName(e.target.value); setError(null); }} />33        <div className="flex justify-end gap-2">34          <Button onClick={onClose}>{t('common.cancel')}</Button>35          <Button type="submit" variant="primary" busy={busy} disabled={!name.trim()}>{submitLabel}</Button>36        </div>37      </form>38    </Dialog>39  );40}4142// --- destination picker ---43type Child = Folder & { hasChildren: boolean };44interface Children { folder: Folder; children: Child[] }45const childrenKey = (id: string) => [XKEY, 'children', id];4647function Node({ id, name, depth, hasChildren, root, selected, expanded, blocked, onSelect, onToggle }: {48  id: string; name: string; depth: number; hasChildren: boolean; root?: boolean; selected: string | null; expanded: Set<string>; blocked: Set<string>;49  onSelect: (id: string, name: string) => void; onToggle: (id: string) => void;50}) {51  const t = useT();52  const open = expanded.has(id);53  const q = useQuery({ queryKey: childrenKey(id), queryFn: () => get<Children>(`/folders/${id}/children`), enabled: open });54  const off = blocked.has(id);55  const kids = q.data?.children ?? [];56  return (57    <li role="treeitem" aria-expanded={hasChildren ? open : undefined} aria-selected={selected === id} aria-disabled={off || undefined}>58      <div className={cx('flex items-center rounded-xl', selected === id ? 'bg-accent-soft' : 'hover:bg-hover', off && 'opacity-45')} style={{ paddingLeft: depth * 18 }}>59        {hasChildren && !off60          ? <button type="button" aria-label={open ? t('explorer.pick.collapse', { name }) : t('explorer.pick.expand', { name })} onClick={() => onToggle(id)} className="flex h-10 w-9 shrink-0 items-center justify-center rounded-xl text-ink-3 hover:text-ink">61            {q.isFetching && !q.data ? <Spinner size={14} /> : <Glyph name={open ? 'chevronDown' : 'chevronRight'} size={16} />}62          </button>63          : <span className="w-9 shrink-0" />}64        <button type="button" disabled={off} data-folder-name={name} onClick={() => onSelect(id, name)} onDoubleClick={() => { if (hasChildren) onToggle(id); }}65          className={cx('flex h-10 min-w-0 flex-1 items-center gap-2.5 rounded-xl pr-3 text-left text-sm', selected === id ? 'font-medium text-accent' : 'text-ink')}>66          <Glyph name={root ? 'home' : 'folder'} size={18} className="shrink-0 opacity-80" />67          <span className="truncate">{name}</span>68        </button>69      </div>70      {open && !off ? (71        q.error ? <p role="alert" className="py-1 text-xs text-danger" style={{ paddingLeft: (depth + 1) * 18 + 36 }}>{errorText(q.error)}</p>72          : kids.length ? <ul role="group">{kids.map((c) => <Node key={c.id} id={c.id} name={c.name} depth={depth + 1} hasChildren={c.hasChildren} selected={selected} expanded={expanded} blocked={blocked} onSelect={onSelect} onToggle={onToggle} />)}</ul> : null73      ) : null}74    </li>75  );76}7778/**79 * Choose a destination folder in a tree loaded level by level. `onConfirm` may be called a second time with80 * `rename` after a name collision ("keep both"). Folders being moved cannot be chosen (nor opened: their content neither).81 */82export function FolderPicker({ title, confirmLabel, blockedIds = [], allowKeepBoth, onConfirm, onClose }: {83  title: string; confirmLabel: string; blockedIds?: string[]; allowKeepBoth?: boolean; onConfirm: (dest: { id: string; name: string }, onConflict: 'fail' | 'rename') => Promise<void>; onClose: () => void;84}) {85  const t = useT();86  const qc = useQueryClient();87  const root = useQuery({ queryKey: childrenKey('root'), queryFn: () => get<Children>('/folders/root/children') });88  const rootId = root.data?.folder.id;89  const [dest, setDest] = useState<{ id: string; name: string } | null>(null);90  const [expanded, setExpanded] = useState<Set<string>>(new Set());91  const [busy, setBusy] = useState(false);92  const [error, setError] = useState<{ text: string; conflict: boolean } | null>(null);93  const [naming, setNaming] = useState<string | null>(null);94  const [nameError, setNameError] = useState<string | null>(null);95  const blocked = new Set(blockedIds);96  const sel = dest ?? (rootId ? { id: rootId, name: t('explorer.root') } : null);97  const toggle = (id: string) => setExpanded((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });98  const go = async (mode: 'fail' | 'rename') => {99    if (!sel) return;100    setBusy(true); setError(null);101    try { await onConfirm(sel, mode); onClose(); } catch (e) {102      const conflict = e instanceof ApiError && e.code === 'name_conflict';103      setError({ text: conflict && e.data.name ? t('explorer.pick.conflict', { name: String(e.data.name) }) : errorText(e), conflict: conflict && !!allowKeepBoth });104      setBusy(false);105    }106  };107  const createFolder = async (e: FormEvent) => {108    e.preventDefault();109    if (!sel || !naming?.trim()) return;110    setNameError(null);111    try {112      const f = await post<Folder>('/folders', { parentId: sel.id, name: naming.trim() });113      await qc.invalidateQueries({ queryKey: [XKEY] });114      setExpanded((s) => new Set(s).add(sel.id));115      setDest({ id: f.id, name: f.name }); setNaming(null); setError(null);116    } catch (err) { setNameError(errorText(err)); }117  };118  return (119    <Dialog title={title} onClose={onClose} footer={120      <>121        <Button icon="plus" variant="ghost" className="mr-auto" disabled={!sel || naming !== null} onClick={() => { setNaming(''); setNameError(null); }}>{t('explorer.newFolder')}</Button>122        <Button onClick={onClose}>{t('common.cancel')}</Button>123        {error?.conflict124          ? <Button variant="primary" busy={busy} onClick={() => void go('rename')}>{t('explorer.keepBoth')}</Button>125          : <Button variant="primary" busy={busy} disabled={!sel} onClick={() => void go('fail')}>{confirmLabel}</Button>}126      </>127    }>128      {root.isLoading ? <Loading /> : root.error || !rootId ? <p role="alert" className="text-sm text-danger">{errorText(root.error)}</p> : (129        <ul role="tree" aria-label={t('explorer.pick.tree')} className="-mx-2 min-h-48">130          <li role="treeitem" aria-expanded aria-selected={sel?.id === rootId}>131            <button type="button" data-folder-name={t('explorer.root')} onClick={() => { setDest({ id: rootId, name: t('explorer.root') }); setError(null); }}132              className={cx('flex h-10 w-full items-center gap-2.5 rounded-xl px-3 text-left text-sm', sel?.id === rootId ? 'bg-accent-soft font-medium text-accent' : 'text-ink hover:bg-hover')}>133              <Glyph name="home" size={18} className="shrink-0 opacity-80" /><span className="truncate">{t('explorer.root')}</span>134            </button>135            <ul role="group">136              {root.data!.children.map((c) => <Node key={c.id} id={c.id} name={c.name} depth={0} hasChildren={c.hasChildren} selected={sel?.id ?? null} expanded={expanded} blocked={blocked}137                onSelect={(id, name) => { setDest({ id, name }); setError(null); }} onToggle={toggle} />)}138            </ul>139          </li>140        </ul>141      )}142      {naming !== null && sel ? (143        <form onSubmit={createFolder} className="mt-3 flex flex-col gap-2 rounded-2xl border border-line p-3">144          <TextField label={t('explorer.pick.newIn', { name: sel.name })} value={naming} error={nameError} maxLength={255} autoFocus autoComplete="off" onChange={(e) => { setNaming(e.target.value); setNameError(null); }} />145          <div className="flex justify-end gap-2"><Button size="sm" onClick={() => setNaming(null)}>{t('common.cancel')}</Button><Button size="sm" type="submit" variant="primary" disabled={!naming.trim()}>{t('common.create')}</Button></div>146        </form>147      ) : null}148      {sel ? <p className="mt-3 text-xs text-ink-3" aria-live="polite">{t('explorer.pick.dest', { name: sel.name })}</p> : null}149      {error ? <p role="alert" className="mt-2 rounded-xl bg-danger-soft px-3 py-2 text-sm text-danger">{error.text}{error.conflict ? ` ${t('explorer.pick.keepBothHint')}` : ''}</p> : null}150    </Dialog>151  );152}153154// --- tags ---155function Swatches({ value, onChange }: { value: string; onChange: (c: string) => void }) {156  const t = useT();157  return (158    <div role="radiogroup" aria-label={t('explorer.tags.color')} className="flex flex-wrap gap-1">159      {TAG_COLORS.map((c) => (160        <button key={c} type="button" role="radio" aria-checked={value === c} aria-label={t(`explorer.color.${c}`)} title={t(`explorer.color.${c}`)} onClick={() => onChange(c)}161          className={cx('flex h-8 w-8 items-center justify-center rounded-full', value === c && 'ring-2 ring-accent')}>162          <span className={cx('h-5 w-5 rounded-full', tagStyle(c).dot)} />163        </button>164      ))}165    </div>166  );167}168export const tagsKey = [XKEY, 'tags'];169export const useTags = () => useQuery({ queryKey: tagsKey, queryFn: () => get<Tag[]>('/tags'), staleTime: 30_000 });170171/** Tick tags on one file; create, rename, recolour and delete the account's tags. */172export function TagsDialog({ file, onClose }: { file: FileItem; onClose: () => void }) {173  const t = useT();174  const qc = useQueryClient();175  const toast = useToast();176  const tags = useTags();177  const [ids, setIds] = useState<string[]>(file.tagIds);178  const [edit, setEdit] = useState<{ id: string; name: string; color: string } | null>(null);179  const [removing, setRemoving] = useState<string | null>(null);180  const [draft, setDraft] = useState({ name: '', color: 'blue' });181  const [error, setError] = useState<string | null>(null);182  const [editError, setEditError] = useState<string | null>(null);183  const refresh = () => qc.invalidateQueries({ queryKey: [XKEY] });184  const assign = async (next: string[]) => {185    const prev = ids;186    setIds(next);187    try { await patch(`/files/${file.id}`, { tagIds: next }); void refresh(); } catch (e) { setIds(prev); toast(errorText(e), { tone: 'error' }); }188  };189  const create = async (e: FormEvent) => {190    e.preventDefault();191    if (!draft.name.trim()) return;192    setError(null);193    try {194      const tag = await post<Tag>('/tags', { name: draft.name.trim(), color: draft.color });195      setDraft({ name: '', color: draft.color });196      await refresh();197      await assign([...ids, tag.id]);198    } catch (err) { setError(err instanceof ApiError && err.code === 'name_conflict' ? t('explorer.tags.exists') : errorText(err)); }199  };200  const saveEdit = async (e: FormEvent) => {201    e.preventDefault();202    if (!edit?.name.trim()) return;203    try { await patch(`/tags/${edit.id}`, { name: edit.name.trim(), color: edit.color }); await refresh(); setEdit(null); setEditError(null); }204    catch (err) { setEditError(err instanceof ApiError && err.code === 'name_conflict' ? t('explorer.tags.exists') : errorText(err)); }205  };206  const remove = async (id: string) => {207    try { await del(`/tags/${id}`); setIds((x) => x.filter((y) => y !== id)); setRemoving(null); await refresh(); } catch (e) { toast(errorText(e), { tone: 'error' }); }208  };209  return (210    <Dialog title={t('explorer.tags.title', { name: file.name })} onClose={onClose} footer={<Button variant="primary" onClick={onClose}>{t('explorer.tags.done')}</Button>}>211      {tags.isLoading ? <Loading /> : tags.error ? <p role="alert" className="text-sm text-danger">{errorText(tags.error)}</p> : (212        <ul className="-mx-2 flex flex-col">213          {tags.data!.length === 0 ? <li className="px-2 py-3 text-sm text-ink-3">{t('explorer.tags.empty')}</li> : null}214          {tags.data!.map((g) => edit?.id === g.id ? (215            <li key={g.id} className="my-1 rounded-2xl border border-line p-3">216              <form onSubmit={saveEdit} className="flex flex-col gap-2.5">217                <TextField label={t('explorer.tags.name')} value={edit.name} maxLength={40} error={editError} autoFocus onChange={(e) => { setEdit({ ...edit, name: e.target.value }); setEditError(null); }} />218                <Swatches value={edit.color} onChange={(color) => setEdit({ ...edit, color })} />219                <div className="flex justify-end gap-2"><Button size="sm" onClick={() => { setEdit(null); setEditError(null); }}>{t('common.cancel')}</Button><Button size="sm" type="submit" variant="primary">{t('common.save')}</Button></div>220              </form>221            </li>222          ) : removing === g.id ? (223            <li key={g.id} className="my-1 flex flex-wrap items-center gap-2 rounded-2xl bg-danger-soft px-3 py-2">224              <p className="min-w-0 flex-1 text-sm text-danger">{t('explorer.tags.confirmDelete', { name: g.name, n: g.files })}</p>225              <Button size="sm" onClick={() => setRemoving(null)}>{t('common.cancel')}</Button><Button size="sm" variant="danger" onClick={() => void remove(g.id)}>{t('common.delete')}</Button>226            </li>227          ) : (228            <li key={g.id} className="flex items-center rounded-xl hover:bg-hover">229              <label className="flex min-h-11 min-w-0 flex-1 cursor-pointer items-center gap-3 px-2">230                <input type="checkbox" className="h-4 w-4 shrink-0 accent-[var(--accent)]" checked={ids.includes(g.id)} onChange={(e) => void assign(e.target.checked ? [...ids, g.id] : ids.filter((x) => x !== g.id))} />231                <span className={cx('truncate rounded-full px-2 py-0.5 text-xs font-medium', tagStyle(g.color).chip)}>{g.name}</span>232                <span className="shrink-0 text-xs text-ink-3">{t('explorer.tags.count', { n: g.files })}</span>233              </label>234              <IconButton icon="pencil" size={16} label={t('explorer.tags.edit', { name: g.name })} onClick={() => { setEdit({ id: g.id, name: g.name, color: g.color }); setRemoving(null); }} />235              <IconButton icon="trash" size={16} label={t('explorer.tags.delete', { name: g.name })} onClick={() => { setRemoving(g.id); setEdit(null); }} />236            </li>237          ))}238        </ul>239      )}240      <form onSubmit={create} className="mt-4 flex flex-col gap-2.5 border-t border-line pt-4">241        <TextField label={t('explorer.tags.new')} value={draft.name} maxLength={40} error={error} autoComplete="off" onChange={(e) => { setDraft({ ...draft, name: e.target.value }); setError(null); }} />242        <div className="flex flex-wrap items-center justify-between gap-2">243          <Swatches value={draft.color} onChange={(color) => setDraft({ ...draft, color })} />244          <Button type="submit" icon="plus" disabled={!draft.name.trim()}>{t('explorer.tags.create')}</Button>245        </div>246      </form>247    </Dialog>248  );249}250
Files main
Menu

Theme