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