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