Repository Public
1// Deposit portals, owner side: state badge, the actions on a portal, the folder picker used to create one.2import { useQuery, useQueryClient } from '@tanstack/react-query';3import { useState } from 'react';4import { del, get, patch, post } from '../api.ts';5import { Glyph } from '../glyphs.tsx';6import { useT } from '../i18n.ts';7import type { Folder, Portal } from '../types.ts';8import { Badge, Button, ConfirmDialog, cx, Dialog, ErrorState, IconButton, Menu, Spinner, useToast } from '../ui.tsx';9import { LinkOnce } from './common.tsx';10import { RequestDialog } from './RequestDialog.tsx';1112export const PORTAL_TONE: Record<Portal['state'], 'ok' | 'danger' | 'warn' | 'neutral'> = { open: 'ok', paused: 'neutral', revoked: 'danger', expired: 'warn', full: 'warn', target_missing: 'danger' };13export function PortalState({ state }: { state: Portal['state'] }) {14 const t = useT();15 return <Badge tone={PORTAL_TONE[state]}><span title={t(`sharing.pstateHint.${state}`)} data-testid="portal-state">{t(`sharing.pstate.${state}`)}</span></Badge>;16}17/** Reject codes written by the inspection worker, in the owner's words. */18export const REJECT_CODES = ['type_not_allowed', 'incomplete', 'executable', 'type_mismatch', 'infected', 'unscannable', 'target_unavailable', 'quota_exceeded', 'expired'];1920type Sub = 'edit' | 'revoke' | 'purge' | 'rotate' | { url: string } | null;21/** Pause/reactivate button and the menu of the other actions; owns the dialogs they open. */22export function PortalActions({ portal, onPurged }: { portal: Portal; onPurged?: () => void }) {23 const t = useT();24 const toast = useToast();25 const qc = useQueryClient();26 const invalidate = () => qc.invalidateQueries({ queryKey: ['requests'] });27 const [sub, setSub] = useState<Sub>(null);28 const [menu, setMenu] = useState<DOMRect | null>(null);29 const [busy, setBusy] = useState(false);30 const live = !portal.revokedAt;31 const toggle = async () => {32 setBusy(true);33 try { await patch(`/requests/${portal.id}`, { active: !portal.active }); await invalidate(); toast(portal.active ? t('sharing.portal.paused') : t('sharing.portal.resumed'), { tone: 'ok' }); }34 catch { toast(t('common.error.unknown'), { tone: 'error' }); } finally { setBusy(false); }35 };36 const label = t('sharing.item.actions', { name: portal.title });37 return (38 <>39 {live ? <Button size="sm" icon={portal.active ? 'pause' : 'play'} busy={busy} onClick={toggle}>{portal.active ? t('sharing.portal.pause') : t('sharing.portal.resume')}</Button> : null}40 <IconButton icon="dots" label={label} onClick={(e) => setMenu(e.currentTarget.getBoundingClientRect())} className="h-8 w-8" />41 {menu ? <Menu anchor={menu} label={label} onClose={() => setMenu(null)} items={[42 ...(live ? [{ label: t('common.edit'), icon: 'pencil', onSelect: () => setSub('edit') }, { label: t('sharing.item.rotate'), icon: 'refresh', onSelect: () => setSub('rotate') },43 { label: t('sharing.item.revoke'), icon: 'x', danger: true, separatorBefore: true, onSelect: () => setSub('revoke') }] : []),44 { label: t('common.delete'), icon: 'trash', danger: true, onSelect: () => setSub('purge') },45 ]} /> : null}46 {sub === 'edit' ? <RequestDialog folder={{ id: portal.folderId, name: portal.folderName ?? '' }} portal={portal} onClose={() => setSub(null)} /> : null}47 {sub === 'rotate' ? <ConfirmDialog title={t('sharing.portal.rotate')} text={t('sharing.portal.rotateText')} confirmLabel={t('sharing.item.rotateConfirm')} onClose={() => setSub((s) => (s === 'rotate' ? null : s))}48 onConfirm={async () => { const r = await post<{ url: string }>(`/requests/${portal.id}/rotate`); setSub({ url: r.url }); }} /> : null}49 {sub && typeof sub === 'object' ? <Dialog title={t('sharing.link.newTitle')} onClose={() => setSub(null)} footer={<Button variant="primary" onClick={() => setSub(null)}>{t('sharing.link.done')}</Button>}><LinkOnce url={sub.url} title={portal.title} qr /></Dialog> : null}50 {sub === 'revoke' ? <ConfirmDialog danger title={t('sharing.portal.revokeTitle')} text={t('sharing.portal.revokeText')} confirmLabel={t('sharing.item.revoke')} onClose={() => setSub(null)}51 onConfirm={async () => { await del(`/requests/${portal.id}`); await invalidate(); toast(t('sharing.portal.revoked'), { tone: 'ok' }); }} /> : null}52 {sub === 'purge' ? <ConfirmDialog danger title={t('sharing.portal.purgeTitle')} text={t('sharing.portal.purgeText')} confirmLabel={t('common.delete')} onClose={() => setSub(null)}53 onConfirm={async () => { await del(`/requests/${portal.id}?purge=1`); toast(t('sharing.portal.purged'), { tone: 'ok' }); if (onPurged) onPurged(); await invalidate(); }} /> : null}54 </>55 );56}5758// ---------------------------------------------------------------- folder picker ----------------------------------------------------------------59interface Children { folder: Folder; children: (Folder & { hasChildren: boolean })[] }60function Node({ id, name, depth, hasChildren, selected, onSelect, startOpen }: { id: string; name: string; depth: number; hasChildren: boolean; selected: string | null; onSelect: (f: { id: string; name: string }) => void; startOpen?: boolean }) {61 const t = useT();62 const [open, setOpen] = useState(!!startOpen);63 const q = useQuery({ queryKey: ['folder-children', id], queryFn: () => get<Children>(`/folders/${id}/children`), enabled: open });64 const realId = id === 'root' ? q.data?.folder.id ?? 'root' : id;65 const label = id === 'root' ? t('sharing.picker.root') : name;66 const isSel = selected === realId || selected === id;67 return (68 <li role="treeitem" aria-expanded={hasChildren ? open : undefined} aria-selected={isSel}>69 <div className={cx('flex items-center rounded-xl', isSel ? 'bg-accent-soft' : 'hover:bg-hover')} style={{ paddingLeft: depth * 18 }}>70 {hasChildren ? <button type="button" aria-label={open ? t('sharing.picker.collapse', { name: label }) : t('sharing.picker.expand', { name: label })} onClick={() => setOpen((o) => !o)} className="inline-flex h-10 w-9 shrink-0 items-center justify-center text-ink-3">71 {open && q.isLoading ? <Spinner size={14} /> : <Glyph name={open ? 'chevronDown' : 'chevronRight'} size={16} />}</button> : <span className="w-9 shrink-0" />}72 <button type="button" onClick={() => onSelect({ id: realId, name: label })} className="flex min-w-0 flex-1 items-center gap-2 py-2 pr-3 text-left text-sm">73 <Glyph name={id === 'root' ? 'home' : 'folder'} size={18} className={cx('shrink-0', isSel ? 'text-accent' : 'text-ink-3')} /><span className={cx('truncate', isSel && 'font-semibold text-accent')}>{label}</span>74 </button>75 </div>76 {open && q.data?.children.length ? <ul role="group">{q.data.children.map((c) => <Node key={c.id} id={c.id} name={c.name} depth={depth + 1} hasChildren={c.hasChildren} selected={selected} onSelect={onSelect} />)}</ul> : null}77 {open && q.error ? <ErrorState error={q.error} onRetry={() => void q.refetch()} /> : null}78 </li>79 );80}81/** Choose one folder of the drive (root included). Children are fetched when a branch is opened. */82export function FolderPicker({ title, confirmLabel, onPick, onClose }: { title: string; confirmLabel: string; onPick: (f: { id: string; name: string }) => void; onClose: () => void }) {83 const t = useT();84 const [sel, setSel] = useState<{ id: string; name: string } | null>(null);85 return (86 <Dialog title={title} onClose={onClose} footer={<><Button onClick={onClose}>{t('common.cancel')}</Button><Button variant="primary" disabled={!sel} onClick={() => sel && onPick(sel)}>{confirmLabel}</Button></>}>87 <p className="mb-3 text-sm text-ink-2">{t('sharing.picker.intro')}</p>88 <ul role="tree" aria-label={title} data-testid="folder-tree"><Node id="root" name="" depth={0} hasChildren selected={sel?.id ?? null} onSelect={setSel} startOpen /></ul>89 </Dialog>90 );91}92