SPB Git

Repository Public

ShareParts.tsx 13.0 KB 151 lines tsx

1// One share link as the owner sees it (status, counters, actions) and the form that creates or edits a link.2import { useQuery, useQueryClient } from '@tanstack/react-query';3import { type FormEvent, type ReactNode, useRef, useState } from 'react';4import { ApiError, del, get, patch, post } from '../api.ts';5import { Glyph } from '../glyphs.tsx';6import { errorText, fmtDate, fmtRelative, useT } from '../i18n.ts';7import type { Share, ShareEvent } from '../types.ts';8import { Badge, Button, ConfirmDialog, Dialog, ErrorState, IconButton, Loading, Menu, Switch, TextField, useToast } from '../ui.tsx';9import { type ExpiryChoice, ExpiryField, LinkOnce, NumberField, resolveExpiry, toLocalInput, uaSummary } from './common.tsx';1011export const SHARE_TONE: Record<Share['status'], 'ok' | 'danger' | 'warn' | 'neutral'> = { active: 'ok', revoked: 'danger', expired: 'warn', exhausted: 'warn', target_missing: 'neutral' };12export const useInvalidateShares = () => { const qc = useQueryClient(); return () => qc.invalidateQueries({ queryKey: ['shares'] }); };1314type Target = { type: 'file' | 'folder'; id: string };15/** Create (`target`) or edit (`share`) the settings of a link. */16export function ShareForm({ target, share, onDone, onCancel }: { target?: Target; share?: Share; onDone: (r: { share: Share; url?: string }) => void; onCancel: () => void }) {17  const t = useT();18  const [label, setLabel] = useState(share?.label ?? '');19  const [canDownload, setCanDownload] = useState(share?.canDownload ?? true);20  const [password, setPassword] = useState('');21  const [dropPassword, setDropPassword] = useState(false);22  const [choice, setChoice] = useState<ExpiryChoice>(share?.expiresAt ? 'date' : 'never');23  const [date, setDate] = useState(toLocalInput(share?.expiresAt));24  const [maxViews, setMaxViews] = useState(share?.maxViews?.toString() ?? '');25  const [maxDownloads, setMaxDownloads] = useState(share?.maxDownloads?.toString() ?? '');26  const [singleUse, setSingleUse] = useState(share?.singleUse ?? false);27  const [errors, setErrors] = useState<Record<string, string>>({});28  const [busy, setBusy] = useState(false);29  const form = useRef<HTMLFormElement>(null);3031  const submit = async (e: FormEvent) => {32    e.preventDefault();33    const errs: Record<string, string> = {};34    const int = (v: string, k: string) => { if (!v.trim()) return null; const n = Number(v); if (!Number.isInteger(n) || n < 1 || n > 1_000_000) { errs[k] = t('sharing.form.badNumber'); return null; } return n; };35    const views = int(maxViews, 'maxViews'), downloads = int(maxDownloads, 'maxDownloads');36    if (password && password.length < 4) errs.password = t('sharing.form.passwordShort');37    const expiresAt = resolveExpiry(choice, date);38    if (choice === 'date' && !expiresAt) errs.expiry = t('sharing.form.badDate');39    else if (expiresAt && new Date(expiresAt) <= new Date() && (!share || expiresAt !== share.expiresAt)) errs.expiry = t('common.error.bad_expiry');40    setErrors(errs);41    if (Object.keys(errs).length) { requestAnimationFrame(() => form.current?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()); return; }42    const body = {43      label: label.trim(), canDownload, expiresAt: share && choice === 'date' && toLocalInput(share.expiresAt) === date ? share.expiresAt : expiresAt, maxViews: views, maxDownloads: canDownload ? downloads : null, singleUse,44      password: password ? password : share ? (dropPassword ? null : undefined) : undefined,45    };46    setBusy(true);47    try {48      if (share) onDone({ share: await patch<Share>(`/shares/${share.id}`, body) });49      else onDone(await post<{ share: Share; url: string }>('/shares', { ...body, [target!.type === 'file' ? 'fileId' : 'folderId']: target!.id }));50    } catch (x) {51      setBusy(false);52      if (x instanceof ApiError && x.code === 'bad_expiry') setErrors({ expiry: errorText(x) }); else setErrors({ form: errorText(x) });53    }54  };55  return (56    <form ref={form} onSubmit={submit} noValidate className="space-y-4" data-testid="share-form">57      <TextField label={t('sharing.form.label')} hint={t('sharing.form.labelHint')} value={label} maxLength={120} onChange={(e) => setLabel(e.target.value)} data-autofocus />58      <Switch label={t('sharing.form.canDownload')} hint={canDownload ? t('sharing.form.canDownloadOn') : t('sharing.form.canDownloadOff')} checked={canDownload} onChange={setCanDownload} />59      <TextField label={share?.hasPassword ? t('sharing.form.passwordChange') : t('sharing.form.password')} hint={share?.hasPassword ? t('sharing.form.passwordKeep') : t('sharing.form.passwordHint')} value={password} maxLength={200}60        onChange={(e) => setPassword(e.target.value)} error={errors.password} autoComplete="off" autoCapitalize="none" spellCheck={false} disabled={dropPassword} />61      {share?.hasPassword ? <Switch label={t('sharing.form.passwordRemove')} checked={dropPassword} onChange={(v) => { setDropPassword(v); if (v) setPassword(''); }} /> : null}62      <ExpiryField choice={choice} date={date} onChange={(c, d) => { setChoice(c); setDate(d); }} error={errors.expiry} />63      <div className="grid grid-cols-2 gap-3">64        <NumberField label={t('sharing.form.maxViews')} placeholder={t('sharing.form.unlimited')} value={maxViews} onChange={setMaxViews} max={1_000_000} error={errors.maxViews} />65        {canDownload ? <NumberField label={t('sharing.form.maxDownloads')} placeholder={t('sharing.form.unlimited')} value={maxDownloads} onChange={setMaxDownloads} max={1_000_000} error={errors.maxDownloads} /> : null}66      </div>67      <Switch label={t('sharing.form.singleUse')} hint={t('sharing.form.singleUseHint')} checked={singleUse} onChange={setSingleUse} />68      {errors.form ? <p role="alert" className="rounded-xl bg-danger-soft px-3 py-2 text-sm text-danger">{errors.form}</p> : null}69      <div className="flex justify-end gap-2 pt-1">70        <Button onClick={onCancel}>{t('common.cancel')}</Button>71        <Button type="submit" variant="primary" busy={busy} icon={share ? 'check' : 'link'}>{share ? t('common.save') : t('sharing.form.create')}</Button>72      </div>73    </form>74  );75}7677/** Access journal of one link. */78export function ShareEventsDialog({ share, onClose }: { share: Share; onClose: () => void }) {79  const t = useT();80  const q = useQuery({ queryKey: ['shares', 'events', share.id], queryFn: () => get<ShareEvent[]>(`/shares/${share.id}/events`), staleTime: 0 });81  return (82    <Dialog title={t('sharing.events.title', { name: share.label || share.targetName })} onClose={onClose} wide footer={<><Button icon="refresh" onClick={() => void q.refetch()} busy={q.isFetching}>{t('sharing.refresh')}</Button><Button variant="primary" onClick={onClose}>{t('common.close')}</Button></>}>83      {q.isLoading ? <Loading /> : q.error ? <ErrorState error={q.error} onRetry={() => void q.refetch()} /> : !q.data?.length ? <p className="py-10 text-center text-sm text-ink-3">{t('sharing.events.empty')}</p> : (84        <ul className="divide-y divide-line" data-testid="share-events">85          {q.data.map((e) => (86            <li key={e.id} className="flex items-start gap-3 py-2.5" data-kind={e.kind} data-ok={e.ok}>87              <Glyph name={e.kind === 'download' ? 'download' : e.kind === 'password' ? 'lock' : 'eye'} size={18} className={e.ok ? 'mt-0.5 shrink-0 text-ink-3' : 'mt-0.5 shrink-0 text-danger'} />88              <div className="min-w-0 flex-1">89                <p className="flex flex-wrap items-center gap-2 text-sm font-medium">{t(`sharing.events.${e.kind}`)}<Badge tone={e.ok ? 'ok' : 'danger'}>{e.ok ? t('sharing.events.ok') : e.detail === 'limit' ? t('sharing.events.refusedLimit') : t('sharing.events.refused')}</Badge></p>90                <p className="text-xs text-ink-3">{fmtDate(e.at, { dateStyle: 'medium', timeStyle: 'medium' })} · {e.ip || '—'} · {uaSummary(e.userAgent, t('sharing.events.unknownDevice'))}</p>91              </div>92            </li>93          ))}94        </ul>95      )}96    </Dialog>97  );98}99100type Sub = { kind: 'edit' } | { kind: 'events' } | { kind: 'revoke' } | { kind: 'purge' } | { kind: 'rotate' } | { kind: 'url'; url: string } | null;101/** One link: what it allows, how much it was used, and every action on it. */102export function ShareItem({ share, heading }: { share: Share; heading?: ReactNode }) {103  const t = useT();104  const toast = useToast();105  const invalidate = useInvalidateShares();106  const [sub, setSub] = useState<Sub>(null);107  const [menu, setMenu] = useState<DOMRect | null>(null);108  const live = !share.revokedAt;109  const name = share.label || share.targetName;110  return (111    <article className="card p-5" data-testid="share-item" data-status={share.status}>112      <div className="flex items-start gap-3">113        <div className="min-w-0 flex-1">114          {heading}115          <p className="flex flex-wrap items-center gap-2">116            <span className="min-w-0 truncate text-sm font-semibold">{share.label || t('sharing.item.untitled')}</span>117            <Badge tone={SHARE_TONE[share.status]}><span title={t(`sharing.statusHint.${share.status}`)}>{t(`sharing.status.${share.status}`)}</span></Badge>118          </p>119          <p className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-ink-2">120            <span className="inline-flex items-center gap-1"><Glyph name={share.canDownload ? 'download' : 'eye'} size={14} />{share.canDownload ? t('sharing.item.download') : t('sharing.item.viewOnly')}</span>121            {share.hasPassword ? <span className="inline-flex items-center gap-1"><Glyph name="lock" size={14} />{t('sharing.item.password')}</span> : null}122            {share.singleUse ? <span>{t('sharing.item.singleUse')}</span> : null}123            <span>{share.expiresAt ? t(new Date(share.expiresAt) < new Date() ? 'sharing.item.expired' : 'sharing.item.expires', { date: fmtDate(share.expiresAt) }) : t('sharing.item.noExpiry')}</span>124          </p>125          <p className="mt-1 text-xs text-ink-3" data-testid="share-counters">126            {t('sharing.item.views', { n: share.viewCount })}{share.maxViews !== null ? ` / ${share.maxViews}` : ''} · {t('sharing.item.downloads', { n: share.downloadCount })}{share.maxDownloads !== null ? ` / ${share.maxDownloads}` : ''}127            {' · '}{share.lastAccessAt ? t('sharing.item.lastAccess', { when: fmtRelative(share.lastAccessAt) }) : t('sharing.item.neverUsed')} · {t('sharing.item.created', { date: fmtDate(share.createdAt, { dateStyle: 'medium' }) })}128          </p>129        </div>130        <IconButton icon="dots" label={t('sharing.item.actions', { name })} onClick={(e) => setMenu(e.currentTarget.getBoundingClientRect())} className="-mt-1 -mr-2" />131      </div>132      <p className="mt-2 text-xs text-ink-3">{t(`sharing.statusHint.${share.status}`)}</p>133      {menu ? <span onKeyDown={(e) => { if (e.key === 'Escape') e.stopPropagation(); }}><Menu anchor={menu} label={t('sharing.item.actions', { name })} onClose={() => setMenu(null)} items={[134        ...(live ? [{ label: t('sharing.item.edit'), icon: 'pencil', onSelect: () => setSub({ kind: 'edit' }) }, { label: t('sharing.item.rotate'), icon: 'refresh', onSelect: () => setSub({ kind: 'rotate' }) }] : []),135        { label: t('sharing.item.journal'), icon: 'history', onSelect: () => setSub({ kind: 'events' }) },136        ...(live ? [{ label: t('sharing.item.revoke'), icon: 'x', danger: true, separatorBefore: true, onSelect: () => setSub({ kind: 'revoke' }) }] : []),137        { label: t('sharing.item.purge'), icon: 'trash', danger: true, separatorBefore: !live, onSelect: () => setSub({ kind: 'purge' }) },138      ]} /></span> : null}139      {sub?.kind === 'edit' ? <Dialog title={t('sharing.item.editTitle')} onClose={() => setSub(null)}><ShareForm share={share} onCancel={() => setSub(null)} onDone={() => { void invalidate(); setSub(null); toast(t('sharing.saved'), { tone: 'ok' }); }} /></Dialog> : null}140      {sub?.kind === 'events' ? <ShareEventsDialog share={share} onClose={() => setSub(null)} /> : null}141      {sub?.kind === 'rotate' ? <ConfirmDialog title={t('sharing.item.rotate')} text={t('sharing.item.rotateText')} confirmLabel={t('sharing.item.rotateConfirm')} onClose={() => setSub((s) => (s?.kind === 'rotate' ? null : s))}142        onConfirm={async () => { const r = await post<{ url: string }>(`/shares/${share.id}/rotate`); void invalidate(); setSub({ kind: 'url', url: r.url }); }} /> : null}143      {sub?.kind === 'url' ? <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={name} /></Dialog> : null}144      {sub?.kind === 'revoke' ? <ConfirmDialog danger title={t('sharing.item.revoke')} text={t('sharing.item.revokeText')} confirmLabel={t('sharing.item.revoke')} onClose={() => setSub(null)}145        onConfirm={async () => { await del(`/shares/${share.id}`); void invalidate(); toast(t('sharing.item.revoked'), { tone: 'ok' }); }} /> : null}146      {sub?.kind === 'purge' ? <ConfirmDialog danger title={t('sharing.item.purge')} text={t('sharing.item.purgeText')} confirmLabel={t('common.delete')} onClose={() => setSub(null)}147        onConfirm={async () => { await del(`/shares/${share.id}?purge=1`); void invalidate(); toast(t('sharing.item.purged'), { tone: 'ok' }); }} /> : null}148    </article>149  );150}151
Files main
Menu

Theme