集成到你的应用 Headless:你自己的界面
Headless:你自己的界面
本页内容
逐个试用 hooks
每个 hook 都运行在保存于此页面的网盘上:在演示中修改内容,即可看到 hook 返回值随之变化。下方代码就是演示文件中的代码。
useEntries({ section: 'files', folder })
读取列表显示的内容:文件夹、最近、星标、通过请求收到的文件、与我共享、回收站或搜索结果,每次变更后重新获取。
试一试
…
保存在此页面中、专为演示创建的网盘:不会发送任何内容。
Hook 的实时返回值
全部返回内容
- entries
- 文件夹和文件,文件夹在前
- path
- 对于文件夹,从顶层到它的各级文件夹
- loading, error
- 是否仍在请求;服务器拒绝的原因
- refresh()
- 立即重新获取
下一个视图加载时仍显示上一个视图的条目,且 loading 为 true:不会被当作新视图的内容。
使用
上方运行的部件,与其文件中的代码一致:可以放在 DriveProvider 内的任何位置。
import { formatSize, useEntries } from '@rebofiles/react';
export default function Folder({ folder, onOpen }: { folder: string | null; onOpen: (folder: string | null) => void }) {
const { entries, path, loading } = useEntries({ section: 'files', folder });
return (
<div aria-busy={loading}>
<nav className="text-sm">
<button type="button" onClick={() => onOpen(null)}>
My files
</button>
{path.map((step) => (
<button key={step.id} type="button" onClick={() => onOpen(step.id)}>
› {step.name}
</button>
))}
</nav>
<ul>
{entries.map((entry) =>
entry.kind === 'folder' ? (
<li key={entry.id}>
<button type="button" onClick={() => onOpen(entry.id)}>
📁 {entry.name}
</button>
</li>
) : (
<li key={entry.id}>
{entry.name} · {formatSize(entry.versions[0].size, 'en')}
</li>
),
)}
</ul>
</div>
);
}useEntry(id)
读取服务器所说的某个条目的当前状态,以及其上层文件夹。
试一试
保存在此页面中、专为演示创建的网盘:不会发送任何内容。
Hook 的实时返回值
全部返回内容
- entry
- 文件夹或文件,包括其版本
- path
- 其上层文件夹
- trashed
- 是否在回收站中
加载中或不存在时,整个返回值为 null。
使用
上方运行的部件,与其文件中的代码一致:可以放在 DriveProvider 内的任何位置。
import { formatDate, useEntry } from '@rebofiles/react';
export default function Details({ id }: { id: string }) {
const found = useEntry(id);
if (!found) return <p>Loading…</p>;
const { entry, path, trashed } = found;
return (
<div>
<h4>{entry.name}</h4>
<p>
{path.map((folder) => folder.name).join(' / ') || 'My files'}
{trashed && ' · in the trash'}
</p>
<p>
By {entry.owner}, {formatDate(entry.modified, 'en')}
</p>
</div>
);
}useTextSearch(query)
读取内容中包含这些文字的文件,每个附带一段上下文。查询为空时不发出请求。
试一试
保存在此页面中、专为演示创建的网盘:不会发送任何内容。
Hook 的实时返回值
全部返回内容
- results
- 每项为 { entry, snippet }
- loading, error
- 是否仍在请求;服务器拒绝的原因
使用
上方运行的部件,与其文件中的代码一致:可以放在 DriveProvider 内的任何位置。
import { useTextSearch } from '@rebofiles/react';
export default function Found({ query }: { query: string }) {
const { results, loading } = useTextSearch(query);
if (!query.trim()) return <p>Type a word written in a file.</p>;
if (loading) return <p>Looking…</p>;
return (
<ul>
{results.map(({ entry, snippet }) => (
<li key={entry.id}>
<strong>{entry.name}</strong>
<p>…{snippet}…</p>
</li>
))}
{!results.length && <li>Nothing has it written.</li>}
</ul>
);
}useActivity(id, limit?)
读取对某个条目做过的操作、操作者和时间,最新的在前。
试一试
保存在此页面中、专为演示创建的网盘:不会发送任何内容。
Hook 的实时返回值
全部返回内容
- activity
- 每项为 { at, by, action, name }
- loading, error
- 是否仍在请求;服务器拒绝的原因
使用
上方运行的部件,与其文件中的代码一致:可以放在 DriveProvider 内的任何位置。
import { formatDate, useActivity } from '@rebofiles/react';
export default function History({ id }: { id: string }) {
const { activity } = useActivity(id, 10);
return (
<ol>
{activity.map((done, i) => (
<li key={`${done.at}-${activity.length - i}`}>
{done.by} {done.action} “{done.name}” · {formatDate(done.at, 'en')}
</li>
))}
</ol>
);
}useActions()
修改每个修改都是一个 Promise,完成后通知所有 hooks 重新获取。服务器的拒绝是带有消息的 RequestError。
试一试
保存在此页面中、专为演示创建的网盘:不会发送任何内容。
Hook 的实时返回值
全部返回内容
- createFolder, rename, move
- 文件夹和名称
- moveToTrash, restore, deleteForever, emptyTrash
- 回收站
- copy, star, setColor, setFields
- 副本、星标、文件夹颜色、自定义字段
- share, unshare, createLink, removeLink
- 与某人共享,或通过链接
- upload, restoreVersion, review, url
- 文件、版本、通过请求收到的文件
使用
上方运行的部件,与其文件中的代码一致:可以放在 DriveProvider 内的任何位置。
import { useActions, useEntry } from '@rebofiles/react';
export default function Toolbar({ id }: { id: string }) {
const actions = useActions();
const found = useEntry(id);
if (!found) return null;
const { entry, trashed } = found;
return (
<div>
<p>
{entry.name}
{trashed && ' (in the trash)'}
</p>
<button type="button" onClick={() => actions.rename(id, `final-${entry.name}`)}>
Rename
</button>
<button type="button" onClick={() => actions.star(id)}>
Star
</button>
<button type="button" onClick={() => actions.copy(id)}>
Make a copy
</button>
<button type="button" onClick={() => actions.setFields(id, { reviewed: 'yes' })}>
Mark reviewed
</button>
{trashed ? (
<button type="button" onClick={() => actions.restore(id)}>
Restore
</button>
) : (
<button type="button" onClick={() => actions.moveToTrash(id)}>
Move to trash
</button>
)}
</div>
);
}useUploads({ check })
修改文件每次三个、分片上传,显示每个文件的进度、速度和剩余时间。启用 check 时会先在网盘中查找。
试一试
保存在此页面中、专为演示创建的网盘:不会发送任何内容。
Hook 的实时返回值
全部返回内容
- uploads
- 每项为 { name, size, sent, state, remaining }
- summary
- 全部汇总
- upload(files, folder)
- 上传这些文件
- retry, stop, stopAll, decide, dismiss, clear
- 上传面板的操作
使用
上方运行的部件,与其文件中的代码一致:可以放在 DriveProvider 内的任何位置。
import { formatDuration, formatSize, useUploads } from '@rebofiles/react';
/** The queue is the hook's: one call of it, given to what shows it, so that everything shown is of the same files. */
export function UploadsPanel({ queue, folder }: { queue: ReturnType<typeof useUploads>; folder: string | null }) {
const { uploads, summary, upload, stop, retry, clear } = queue;
const send = () => upload([new File([new Uint8Array(24 * 1024 * 1024)], `scan-${uploads.length + 1}.pdf`, { type: 'application/pdf' })], folder);
return (
<div>
<button type="button" onClick={send}>
Upload a scan of 24 MB
</button>
<button type="button" onClick={clear}>
Clear
</button>
<ul>
{uploads.map((u) => (
<li key={u.id}>
{u.name} · {formatSize(u.sent, 'en')} of {formatSize(u.size, 'en')} · {u.state}
{u.state === 'sending' && (
<button type="button" onClick={() => stop(u.id)}>
Stop
</button>
)}
{u.state === 'stopped' && (
<button type="button" onClick={() => retry(u.id)}>
Try again
</button>
)}
</li>
))}
</ul>
{summary.remaining !== undefined && <p>{formatDuration(summary.remaining, 'en')} left</p>}
</div>
);
}
export default function Uploads({ folder }: { folder: string | null }) {
const queue = useUploads();
return <UploadsPanel queue={queue} folder={folder} />;
}useComments(id)
协作对某个条目的评论,每个讨论串下附有回复,以及发表新评论。
试一试
保存在此页面中、专为演示创建的网盘:不会发送任何内容。
Hook 的实时返回值
全部返回内容
- comments
- 每项为 { id, by, text, replyTo, resolved }
- loading, error
- 是否仍在请求;服务器拒绝的原因
- add, edit, resolve, remove
- 发表、编辑、解决、删除
使用
上方运行的部件,与其文件中的代码一致:可以放在 DriveProvider 内的任何位置。
import { useComments } from '@rebofiles/react';
import { useState } from 'react';
export default function Remarks({ id }: { id: string }) {
const { comments, add, resolve } = useComments(id);
const [text, setText] = useState('');
return (
<div>
<ul>
{comments.map((c) => (
<li key={c.id} style={{ marginLeft: c.replyTo ? 16 : 0, opacity: c.resolved ? 0.5 : 1 }}>
<b>{c.by}</b> {c.text}
{!c.replyTo && (
<button type="button" onClick={() => resolve(c.id, !c.resolved)}>
{c.resolved ? 'Reopen' : 'Resolve'}
</button>
)}
</li>
))}
</ul>
<form onSubmit={(e) => (e.preventDefault(), text.trim() && add(text.trim()).then(() => setText('')))}>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="Say something" aria-label="Comment" />
<button type="submit">Comment</button>
</form>
</div>
);
}useNotifications()
协作与其共享、在评论中被提及、通过请求收到文件、文件即将到期:最新的在前,以及新通知的数量。
试一试
保存在此页面中、专为演示创建的网盘:不会发送任何内容。
Hook 的实时返回值
全部返回内容
- notifications
- 每项为 { kind, name, by, at }
- unseen
- 新通知数量
- loading, error
- 是否仍在请求;服务器拒绝的原因
- saw()
- 标记为已读
使用
上方运行的部件,与其文件中的代码一致:可以放在 DriveProvider 内的任何位置。
import { formatDate, useNotifications } from '@rebofiles/react';
export default function Bell() {
const { notifications, unseen, saw } = useNotifications();
return (
<div>
<button type="button" onClick={saw} disabled={!unseen}>
🔔 {unseen ? `${unseen} new` : 'Nothing new'}
</button>
<ul>
{notifications.map((n) => (
<li key={n.id}>
{n.kind === 'shared' ? `${n.by} shared “${n.name}” with you` : n.kind === 'mentioned' ? `${n.by} named you on “${n.name}”` : `“${n.name}” came in`}
{' · '}
{formatDate(n.at, 'en')}
</li>
))}
</ul>
</div>
);
}useRequests()
协作自己发出的文件请求,每个附带文件夹、链接和令牌;以及提醒收件人。
试一试
保存在此页面中、专为演示创建的网盘:不会发送任何内容。
Hook 的实时返回值
全部返回内容
- requests
- 每项为 { folder, link, token }
- loading, error
- 是否仍在请求;服务器拒绝的原因
- remind(folder, link)
- 立即通过服务器邮件发送提醒
使用
上方运行的部件,与其文件中的代码一致:可以放在 DriveProvider 内的任何位置。
import { useRequests } from '@rebofiles/react';
export default function Requests() {
const { requests, remind } = useRequests();
return (
<ul>
{requests.map(({ folder, link }) => (
<li key={link.id}>
Files asked into <b>{folder.name}</b>, until {link.expires.slice(0, 10)}
<button type="button" onClick={() => remind(folder.id, link.id)}>
Remind
</button>
</li>
))}
{!requests.length && <li>No requests for files.</li>}
</ul>
);
}usePeople(query)
协作与输入内容匹配的人,来自你提供给服务器的目录。查询为空时不发出请求。
试一试
保存在此页面中、专为演示创建的网盘:不会发送任何内容。
Hook 的实时返回值
全部返回内容
- people
- 每项为 { id, name, detail }
- loading, error
- 是否仍在请求;服务器拒绝的原因
使用
上方运行的部件,与其文件中的代码一致:可以放在 DriveProvider 内的任何位置。
import { usePeople } from '@rebofiles/react';
export default function Who({ query, onPick }: { query: string; onPick: (id: string) => void }) {
const { people, loading } = usePeople(query);
return (
<ul aria-busy={loading}>
{people.map((person) => (
<li key={person.id}>
<button type="button" onClick={() => onPick(person.id)}>
{person.name} {person.detail && <small>{person.detail}</small>}
</button>
</li>
))}
</ul>
);
}useExpiring(days?)
记录字段 expires 在若干天内(默认 15 天)到期或已过期的文件,最近的在前。适用于会过期的文件:护照、执照、保险。
试一试
保存在此页面中、专为演示创建的网盘:不会发送任何内容。
Hook 的实时返回值
全部返回内容
- files
- 每项为 { entry, expires, days }
- loading, error
- 是否仍在请求;服务器拒绝的原因
使用
上方运行的部件,与其文件中的代码一致:可以放在 DriveProvider 内的任何位置。
import { useExpiring } from '@rebofiles/react';
export default function Expiring({ days }: { days: number }) {
const { files } = useExpiring(days);
return (
<ul>
{files.map(({ entry, days: left }) => (
<li key={entry.id} style={{ color: left < 0 ? 'crimson' : left <= 7 ? 'darkorange' : undefined }}>
{entry.name}: {left < 0 ? `expired ${-left} days ago` : left === 0 ? 'expires today' : `${left} days left`}
</li>
))}
{!files.length && <li>Nothing expires within {days} days.</li>}
</ul>
);
}useWithField(name, value)
记录某字段等于此值的条目,无论在哪里:你某个客户、某处房产或某个案件的文件。
试一试
保存在此页面中、专为演示创建的网盘:不会发送任何内容。
Hook 的实时返回值
全部返回内容
- entries
- 找到的条目
- loading, error
- 是否仍在请求;服务器拒绝的原因
使用
上方运行的部件,与其文件中的代码一致:可以放在 DriveProvider 内的任何位置。
import { useWithField } from '@rebofiles/react';
export default function Record({ client }: { client: string }) {
const { entries } = useWithField('client', client);
return (
<div>
<h4>Files of {client}</h4>
<ul>
{entries.map((entry) => (
<li key={entry.id}>{entry.name}</li>
))}
</ul>
</div>
);
}useSettings()
网盘网盘的设置和个人设置,用于你自己的设置面板。
试一试
保存在此页面中、专为演示创建的网盘:不会发送任何内容。
Hook 的实时返回值
全部返回内容
- drive, mine
- 网盘的设置,以及个人的语言和主题
- canConfigure
- 是否可以修改网盘设置
- save, saveMine
- 分别保存
使用
上方运行的部件,与其文件中的代码一致:可以放在 DriveProvider 内的任何位置。
import { useSettings } from '@rebofiles/react';
export default function Preferences() {
const { mine, drive, canConfigure, saveMine, save } = useSettings();
if (!mine || !drive) return null;
return (
<div>
<label>
Language{' '}
<select value={mine.language ?? drive.language} onChange={(e) => saveMine({ ...mine, language: e.target.value as 'en' | 'es' | 'zh' })}>
<option value="en">English</option>
<option value="es">Español</option>
<option value="zh">中文</option>
</select>
</label>
{canConfigure && (
<label>
Trash kept for{' '}
<select value={drive.trashDays ?? 0} onChange={(e) => save({ trashDays: Number(e.target.value) || null })}>
<option value={7}>7 days</option>
<option value={30}>30 days</option>
<option value={0}>ever</option>
</select>
</label>
)}
</div>
);
}useDrive()
网盘包含服务器每条路由的客户端、每次变更后递增的数字,以及网盘的文字:用于没有专门 hook 的功能。
试一试
保存在此页面中、专为演示创建的网盘:不会发送任何内容。
Hook 的实时返回值
全部返回内容
- client
- 以函数形式提供的每条路由
- revision, changed()
- 何时重新获取;通知所有 hooks
- t, locale, language
- 网盘的文字,使用当前显示的语言
使用
上方运行的部件,与其文件中的代码一致:可以放在 DriveProvider 内的任何位置。
import { formatSize, useDrive } from '@rebofiles/react';
import { useEffect, useState } from 'react';
export default function Space() {
const { client, revision, t, locale } = useDrive();
const [usage, setUsage] = useState<{ used: number; quota: number | null } | null>(null);
// Asked again after every change: `revision` goes up with each.
useEffect(() => void client.usage().then(setUsage), [client, revision]);
if (!usage) return null;
const used = formatSize(usage.used, locale);
return <p>{usage.quota ? t('usedOf', { used, quota: formatSize(usage.quota, locale) }) : t('used', { used })}</p>;
}DriveProvider
客户端与文字,供其下的每个 hook 使用。
import { DriveProvider } from '@rebofiles/react';
<DriveProvider base="/api/files" language="es" headers={async () => ({ authorization: `Bearer ${await token()}` })}>
<YourDrive />
</DriveProvider>没有专门 hook 的功能
已用空间、按 id 获取人名、修改链接、打包、法律保留:客户端都提供,useDrive() 会给出它,以及让每个 hook 重新获取的方法。
import { useEffect, useState } from 'react';
import { useDrive } from '@rebofiles/react';
// What has no hook of its own is the client's: useDrive() gives it, `revision`
// goes up after every change, and `changed()` tells every hook to ask again.
function Space() {
const { client, revision } = useDrive();
const [usage, setUsage] = useState<Awaited<ReturnType<typeof client.usage>> | null>(null);
useEffect(() => void client.usage().then(setUsage), [client, revision]);
return usage && <meter value={usage.used} max={usage.quota ?? usage.used} />;
}
// A change of your own through the client, told to every hook after:
const { client, changed } = useDrive();
await client.updateLink(folder, link, { days: 14 });
changed();通过 client 做的变更,在调用 changed() 之前 hooks 看不到;hooks 提供的操作已经会调用它。客户端的每个函数都在参考中。
搭配 <FileDrive>
放在你的 provider 中的网盘会使用它:网盘中的操作,你的 hooks 能看到;你的 hooks 做的操作,网盘也会显示。
<DriveProvider base="/api/files">
<FileDrive /> {/* the whole drive */}
<RecentSidebar /> {/* your own, with useEntries({ section: 'recent' }) */}
</DriveProvider>完整的网盘
分区、路径、文件夹、带进度的上传、详情、重命名、版本、回收站和搜索:只用 hooks,别无其他。
它就是原样的 apps/playground/src/HooksDrive.tsx,位于 playground 的 /headless.html,并有一个 Chrome 测试完整走一遍。演示页中还有另一个创意套件风格的例子。
// A whole drive built from the hooks of @rebofiles/react and nothing else of
// its interface: its own markup and its own look. The site's guide to the
// hooks shows this file as it is, and a test in Chrome goes through it.
import type { EntryId, FileEntry } from '@rebofiles/core';
import { DriveProvider, type View, formatDate, formatSize, useActions, useActivity, useDrive, useEntries, useEntry, useUploads } from '@rebofiles/react';
import { type FormEvent, useEffect, useRef, useState } from 'react';
type Section = 'files' | 'starred' | 'shared' | 'recent' | 'trash';
export function HooksDrive({ user }: { user?: string }) {
return (
<DriveProvider base="/api/files" headers={(): Record<string, string> => (user ? { 'x-user': user } : {})}>
<Screen />
</DriveProvider>
);
}
function Screen() {
const { t, locale } = useDrive();
const actions = useActions();
const { uploads, upload } = useUploads();
const [section, setSection] = useState<Section>('files');
const [folder, setFolder] = useState<EntryId | null>(null);
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<EntryId | null>(null);
const [problem, setProblem] = useState<string | null>(null);
// What to show: a search, a folder, or one of the sections. The hook asks again after every change.
const view: View = query ? { section: 'search', query } : section === 'files' ? { section: 'files', folder } : { section };
const { entries, path, loading, error } = useEntries(view);
const go = (next: Section, to: EntryId | null = null) => {
setSection(next);
setFolder(to);
setSelected(null);
setQuery('');
};
// Every action of useActions is a promise: a refusal of the server is a RequestError with its message.
const attempt = (work: () => Promise<unknown>) =>
work().then(
() => setProblem(null),
(e: Error) => setProblem(e.message),
);
const newFolder = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const field = event.currentTarget.elements.namedItem('name') as HTMLInputElement;
if (field.value.trim()) void attempt(() => actions.createFolder(field.value.trim(), folder));
field.value = '';
};
const sending = uploads.find((u) => u.state === 'sending');
const sections: Record<Section, string> = { files: t('myFiles'), starred: t('starred'), shared: t('sharedWithMe'), recent: t('recent'), trash: t('trash') };
return (
<div className="hd">
<style>{STYLE}</style>
<header>
<strong>Mini drive</strong>
<input type="search" aria-label={t('search')} placeholder={t('search')} value={query} onChange={(e) => setQuery(e.target.value)} />
</header>
<nav aria-label="Sections">
{(Object.keys(sections) as Section[]).map((s) => (
<button key={s} type="button" aria-current={section === s && !query} onClick={() => go(s)}>
{sections[s]}
</button>
))}
</nav>
<main>
<ol className="hd-path" aria-label="Where">
<li>
<button type="button" onClick={() => go('files')}>
{t('myFiles')}
</button>
</li>
{path.map((step) => (
<li key={step.id}>
<button type="button" onClick={() => go('files', step.id)}>
{step.name}
</button>
</li>
))}
</ol>
{section === 'files' && !query && (
<div className="hd-tools">
<form onSubmit={newFolder}>
<input name="name" aria-label={t('folderName')} placeholder={t('folderName')} />
<button type="submit">{t('newFolder')}</button>
</form>
<label className="hd-upload">
{t('uploadFiles')}
<input type="file" multiple onChange={(e) => e.target.files && upload(e.target.files, folder)} />
</label>
{sending && <progress aria-label={sending.name} value={sending.sent} max={sending.size} />}
</div>
)}
{section === 'trash' && entries.length > 0 && (
<button type="button" onClick={() => attempt(actions.emptyTrash)}>
{t('emptyTrash')}
</button>
)}
{problem && <p role="alert">{problem}</p>}
{error ? (
<p role="alert">{error.message}</p>
) : (
<table aria-busy={loading}>
<thead>
<tr>
<th scope="col">{t('name')}</th>
<th scope="col">{t('modified')}</th>
<th scope="col">{t('size')}</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.id} className={entry.id === selected ? 'hd-on' : undefined}>
<td>
<button type="button" onClick={() => (entry.kind === 'folder' && section !== 'trash' ? go('files', entry.id) : setSelected(entry.id))}>
{entry.kind === 'folder' ? '📁 ' : ''}
{entry.name}
</button>
</td>
<td>{formatDate(entry.modified, locale)}</td>
<td>{entry.kind === 'file' ? formatSize(entry.versions[0].size, locale) : ''}</td>
</tr>
))}
</tbody>
</table>
)}
{!loading && !error && entries.length === 0 && <p>{t('emptyFolder')}</p>}
</main>
{/* One for each entry, so what was typed for one is not kept for the next. */}
{selected && <Details key={selected} id={selected} inTrash={section === 'trash'} attempt={attempt} onGone={() => setSelected(null)} />}
</div>
);
}
/** One entry as the server says it is now, with what may be done to it. */
function Details({ id, inTrash, attempt, onGone }: { id: EntryId; inTrash: boolean; attempt: (work: () => Promise<unknown>) => void; onGone: () => void }) {
const { t, locale } = useDrive();
const actions = useActions();
const found = useEntry(id);
// Whether it is starred: the list of the starred, which asks again after a star like every other hook.
const starred = useEntries({ section: 'starred' }).entries.some((entry) => entry.id === id);
const { activity } = useActivity(id, 10);
const [name, setName] = useState<string | null>(null);
// Closed when what it shows is gone, if it is still open: on a slow connection, the server may answer after another
// section, or this same entry again, was chosen.
const open = useRef(true);
useEffect(() => {
open.current = true;
return () => {
open.current = false;
};
}, []);
const gone = () => open.current && onGone();
if (!found) return null;
const { entry, path } = found;
const file: FileEntry | null = entry.kind === 'file' ? entry : null;
const rename = (event: FormEvent) => {
event.preventDefault();
if (name?.trim()) attempt(() => actions.rename(entry.id, name.trim()).then(() => setName(null)));
};
return (
<aside aria-label={t('details')}>
<h2>{entry.name}</h2>
<p>
{entry.owner} · {path.map((step) => step.name).join(' / ') || t('top')}
</p>
{inTrash ? (
<button type="button" onClick={() => attempt(() => actions.restore(entry.id).then(gone))}>
{t('restore')}
</button>
) : (
<>
<form onSubmit={rename}>
<input aria-label={t('newName')} value={name ?? entry.name} onChange={(e) => setName(e.target.value)} />
<button type="submit">{t('rename')}</button>
</form>
<button type="button" aria-pressed={starred} onClick={() => attempt(() => actions.star(entry.id, !starred))}>
{starred ? t('unstar') : t('star')}
</button>
{file && (
<>
<button type="button" onClick={() => actions.url(file.id).then((url) => window.open(url, '_blank'))}>
{t('download')}
</button>
<button type="button" onClick={() => attempt(() => actions.copy(file.id))}>
{t('makeCopy')}
</button>
</>
)}
<button type="button" onClick={() => attempt(() => actions.moveToTrash(entry.id).then(gone))}>
{t('moveToTrash')}
</button>
</>
)}
{file && (
<>
<h3>{t('versions')}</h3>
<ul>
{file.versions.map((version, i) => (
<li key={version.id}>
{formatDate(version.at, locale)} · {formatSize(version.size, locale)}{' '}
{i === 0 ? (
`(${t('current')})`
) : (
<button type="button" onClick={() => attempt(() => actions.restoreVersion(file.id, version.id))}>
{t('restore')}
</button>
)}
</li>
))}
</ul>
</>
)}
<h3>{t('activity')}</h3>
{activity.length ? (
<ul aria-label={t('activity')}>
{activity.map((done, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: what was done has no id, and two things may be done in the same millisecond: counted from the oldest, as the newest come first
<li key={`${done.at}-${activity.length - i}`}>
{done.by} · {done.action} · {formatDate(done.at, locale)}
</li>
))}
</ul>
) : (
<p>{t('noActivity')}</p>
)}
</aside>
);
}
const STYLE = `
.hd { font: 15px/1.5 Georgia, 'Times New Roman', serif; color: #1d2433; display: grid; grid-template: auto 1fr / 180px 1fr auto; gap: 0 24px; height: 100%; padding: 16px; box-sizing: border-box; background: #fbfaf7; }
.hd header { grid-column: 1 / -1; display: flex; gap: 16px; align-items: center; padding-bottom: 16px; border-bottom: 2px solid #1d2433; margin-bottom: 16px; }
.hd header input { flex: 1; max-width: 360px; }
.hd nav { display: flex; flex-direction: column; gap: 4px; }
.hd nav button[aria-current='true'] { font-weight: 700; text-decoration: underline; }
.hd button, .hd input { font: inherit; color: inherit; }
.hd button { background: none; border: 1px solid #1d2433; padding: 4px 10px; cursor: pointer; text-align: left; }
.hd td button { border: 0; padding: 0; text-decoration: underline; }
.hd-path { display: flex; gap: 8px; list-style: none; padding: 0; margin: 0 0 12px; }
.hd-path li + li::before { content: '›'; margin-right: 8px; }
.hd-path button { border: 0; padding: 0; }
.hd-tools { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
.hd-upload input { margin-left: 8px; }
.hd table { width: 100%; border-collapse: collapse; }
.hd th, .hd td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #d9d4c7; }
.hd tr.hd-on { background: #f1ead8; }
.hd aside { width: 260px; border-left: 2px solid #1d2433; padding-left: 16px; }
.hd aside form { display: flex; gap: 6px; margin: 8px 0; }
.hd aside > button { display: block; margin: 4px 0; }
.hd aside input { min-width: 0; flex: 1; }
.hd [role='alert'] { color: #9b1c1c; }
`;