SPB Git

Repository Public

Details.tsx 8.7 KB 124 lines tsx

1// Details of one item: properties, checksum, location, tags, share count and the version history of a file.2import { extOf, formatBytes } from '@spd/shared';3import { useQuery, useQueryClient } from '@tanstack/react-query';4import { type ReactNode, useRef, useState } from 'react';5import { Link } from 'wouter';6import { get, post, del } from '../api.ts';7import { errorText, fmtDate, getLocale, useT } from '../i18n.ts';8import { Thumb } from '../preview/Thumb.tsx';9import { ownerSource } from '../sources.ts';10import type { Crumb, FileDetail, FileItem, Item, Tag, Version } from '../types.ts';11import { Badge, Button, ConfirmDialog, copyText, IconButton, Loading, useToast } from '../ui.tsx';12import { TagChip } from './Items.tsx';13import { triggerDownload, XKEY } from './lib.ts';14import { uploads } from './uploads.ts';1516export const fileKey = (id: string) => [XKEY, 'file', id];17const Line = ({ label, children }: { label: string; children: ReactNode }) => (18  <div className="flex gap-3 border-b border-line py-2.5 text-[15px] last:border-b-0 sm:text-sm"><dt className="w-24 shrink-0 text-ink-3">{label}</dt><dd className="min-w-0 flex-1 break-words text-ink">{children}</dd></div>19);20export const pathText = (path: Crumb[], root: string) => path.map((c, i) => (i === 0 ? root : c.name)).join(' / ');2122function Versions({ file, versions, readOnly }: { file: FileItem; versions: Version[]; readOnly: boolean }) {23  const t = useT();24  const qc = useQueryClient();25  const toast = useToast();26  const input = useRef<HTMLInputElement>(null);27  const [removing, setRemoving] = useState<Version | null>(null);28  const refresh = () => qc.invalidateQueries({ queryKey: [XKEY] });29  const restore = async (v: Version) => {30    try { await post(`/files/${file.id}/versions/${v.id}/restore`); await refresh(); toast(t('explorer.ver.restored', { n: v.seq }), { tone: 'ok' }); } catch (e) { toast(errorText(e), { tone: 'error' }); }31  };32  const note = (v: Version) => { const m = /^restored from version (\d+)$/.exec(v.note); return m ? t('explorer.ver.restoredFrom', { n: Number(m[1]) }) : v.note; };33  return (34    <section className="mt-5">35      <h3 className="mb-1 text-xs font-semibold tracking-wide text-ink-3 uppercase">{t('explorer.ver.title', { n: versions.length })}</h3>36      <ul className="divide-y divide-line" aria-label={t('explorer.ver.title', { n: versions.length })}>37        {versions.map((v) => (38          <li key={v.id} className="flex items-center gap-1 py-2">39            <div className="min-w-0 flex-1">40              <p className="flex items-center gap-2 text-sm font-medium text-ink">{t('explorer.ver.n', { n: v.seq })}{v.current ? <Badge tone="accent">{t('explorer.ver.current')}</Badge> : null}</p>41              <p className="truncate text-xs text-ink-3">{formatBytes(v.size, getLocale())} · {fmtDate(v.createdAt)}{v.note ? ` · ${note(v)}` : ''}</p>42            </div>43            <IconButton icon="download" size={18} label={t('explorer.ver.download', { n: v.seq })} onClick={() => triggerDownload(ownerSource(file, v.id).contentUrl({ download: true }))} />44            {!v.current && !readOnly ? <IconButton icon="restore" size={18} label={t('explorer.ver.restore', { n: v.seq })} onClick={() => void restore(v)} /> : null}45            {!v.current && !readOnly ? <IconButton icon="trash" size={18} label={t('explorer.ver.delete', { n: v.seq })} onClick={() => setRemoving(v)} /> : null}46          </li>47        ))}48      </ul>49      {!readOnly ? (50        <>51          <input ref={input} type="file" hidden data-testid="version-input" onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) uploads.enqueue([{ file: f }], file.folderId, { fileId: file.id }); }} />52          <Button icon="upload" className="mt-2 w-full" onClick={() => input.current?.click()}>{t('explorer.ver.upload')}</Button>53          <p className="mt-1.5 text-xs text-ink-3">{t('explorer.ver.hint')}</p>54        </>55      ) : null}56      {removing ? <ConfirmDialog danger title={t('explorer.ver.deleteTitle', { n: removing.seq })} text={t('explorer.ver.deleteText')} confirmLabel={t('common.delete')} onClose={() => setRemoving(null)}57        onConfirm={async () => { await del(`/files/${file.id}/versions/${removing.id}`); await refresh(); }} /> : null}58    </section>59  );60}6162export interface DetailsProps { item: Item; tags: Map<string, Tag>; folderPath?: Crumb[]; onTags: (f: FileItem) => void; onShare: (i: Item) => void; onClose: () => void }63export function DetailsBody({ item, tags, folderPath, onTags, onShare, onClose }: DetailsProps) {64  const t = useT();65  const [copied, setCopied] = useState(false);66  const q = useQuery({ queryKey: fileKey(item.id), queryFn: () => get<FileDetail>(`/files/${item.id}`), enabled: item.type === 'file' });67  const file = item.type === 'file' ? q.data?.file ?? item : null;68  const cur = file ?? item;69  const trashed = !!cur.trashedAt;70  const path = file ? q.data?.path : folderPath;71  const where = path?.length ? path[path.length - 1]! : null;72  const ext = extOf(cur.name);73  return (74    <div>75      <div className="flex flex-col items-center gap-3 pt-1 pb-2 text-center">76        <Thumb name={cur.name} kind={file ? file.kind : 'folder'} className="h-24 w-24 shrink-0 overflow-hidden rounded-[26px] shadow-(--shadow-card)"77          src={file && file.thumb && file.scan !== 'infected' ? ownerSource(file).previewUrl('thumb') : undefined} />78        <p className="max-w-full min-w-0 text-[17px] font-bold tracking-tight break-words text-ink">{cur.name}</p>79      </div>80      {file?.scan === 'infected' ? <p role="alert" className="mt-3 rounded-xl bg-danger-soft px-3 py-2 text-sm text-danger">{t('explorer.infectedText')}</p> : null}81      {file?.origin === 'request' ? <p className="mt-3 rounded-xl bg-accent-soft px-3 py-2 text-sm text-ink-2">{t('explorer.viaPortal')}</p> : null}82      <dl className="mt-3 rounded-2xl bg-hover/60 px-4 py-1">83        <Line label={t('explorer.d.type')}>{file ? `${t(`explorer.kind.${file.kind}`)}${ext ? ` (.${ext})` : ''}` : t('explorer.kind.folder')}</Line>84        {file ? <Line label={t('explorer.d.size')}>{formatBytes(file.size, getLocale())}<span className="text-ink-3"> · {t('explorer.d.bytes', { n: new Intl.NumberFormat(getLocale()).format(file.size) })}</span></Line> : null}85        <Line label={t('explorer.d.updated')}>{fmtDate(cur.updatedAt)}</Line>86        <Line label={t('explorer.d.created')}>{fmtDate(cur.createdAt)}</Line>87        {cur.trashedAt ? <Line label={t('explorer.d.trashed')}>{fmtDate(cur.trashedAt)}</Line> : null}88        {path?.length ? (89          <Line label={t('explorer.d.location')}>90            {trashed ? pathText(path, t('explorer.root')) : <Link href={path.length === 1 ? '/' : `/folder/${where!.id}`} onClick={onClose} className="text-accent underline-offset-2 hover:underline">{pathText(path, t('explorer.root'))}</Link>}91          </Line>92        ) : null}93        {file ? (94          <Line label="SHA-256">95            <span className="flex items-start gap-1">96              <code className="min-w-0 flex-1 font-mono text-xs leading-relaxed break-all text-ink-2" data-testid="sha256">{file.sha256}</code>97              <IconButton icon={copied ? 'check' : 'copy'} size={16} className="-my-1.5" label={copied ? t('common.copied') : t('explorer.d.copySha')}98                onClick={async () => { if (await copyText(file.sha256)) { setCopied(true); setTimeout(() => setCopied(false), 1800); } }} />99            </span>100          </Line>101        ) : null}102        {file ? (103          <Line label={t('explorer.d.tags')}>104            <span className="flex flex-wrap items-center gap-1.5">105              {file.tagIds.map((id) => tags.get(id)).filter((x): x is Tag => !!x).map((g) => <TagChip key={g.id} tag={g} />)}106              {!file.tagIds.length ? <span className="text-ink-3">{t('common.none')}</span> : null}107              {!trashed ? <button type="button" className="rounded px-1 text-xs font-medium text-accent hover:underline" onClick={() => onTags(file)}>{t('common.edit')}</button> : null}108            </span>109          </Line>110        ) : null}111        {file && q.data ? (112          <Line label={t('explorer.d.shares')}>113            <span className="flex flex-wrap items-center gap-1.5">114              {q.data.shares ? t('explorer.d.sharesN', { n: q.data.shares }) : <span className="text-ink-3">{t('explorer.d.sharesNone')}</span>}115              {!trashed && file.scan !== 'infected' ? <button type="button" className="rounded px-1 text-xs font-medium text-accent hover:underline" onClick={() => onShare(file)}>{t('explorer.d.manageShares')}</button> : null}116            </span>117          </Line>118        ) : null}119      </dl>120      {file ? (q.isLoading ? <Loading /> : q.error ? <p role="alert" className="mt-4 text-sm text-danger">{errorText(q.error)}</p> : q.data ? <Versions file={file} versions={q.data.versions} readOnly={trashed} /> : null) : null}121    </div>122  );123}124
Files main
Menu

Theme