Put it in your app Headless: your own UI
Headless: your own UI
On this page
Try each hook
Each hook running on a drive kept in this page: change something in the demo and see what the hook returns change with it. The code under it is the demo’s, as its file has it.
useEntries({ section: 'files', folder })
ReadWhat a list shows: a folder, Recent, Starred, what came through requests, Shared with me, the trash or a search, asked again after every change.
Try it
…
A drive kept in this page, made for the demo: nothing is sent anywhere.
What the hook returns, live
All it gives you
- entries
- The folders and files, folders first
- path
- For a folder, the folders from the top down to it
- loading, error
- Whether it is still asked; what the server refused
- refresh()
- Ask again now
The last view’s entries stay shown while the next loads, with loading true: not taken for the new one’s.
Use it
The piece running above, as its file has it: it goes anywhere inside a 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)
ReadOne entry as the server says it is now, and the folders above it.
Try it
A drive kept in this page, made for the demo: nothing is sent anywhere.
What the hook returns, live
All it gives you
- entry
- The folder or the file, with its versions
- path
- The folders above it
- trashed
- Whether it is in the trash
The whole answer is null while it loads, or when there is none.
Use it
The piece running above, as its file has it: it goes anywhere inside a 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)
ReadThe files with these words written inside, each with a piece of its text around them. Nothing is asked for an empty query.
Try it
A drive kept in this page, made for the demo: nothing is sent anywhere.
What the hook returns, live
All it gives you
- results
- Each { entry, snippet }
- loading, error
- Whether it is still asked; what the server refused
Use it
The piece running above, as its file has it: it goes anywhere inside a 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?)
ReadWhat was done to an entry, by whom and when, the newest first.
Try it
A drive kept in this page, made for the demo: nothing is sent anywhere.
What the hook returns, live
All it gives you
- activity
- Each { at, by, action, name }
- loading, error
- Whether it is still asked; what the server refused
Use it
The piece running above, as its file has it: it goes anywhere inside a 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()
ChangeEvery change, each a promise, telling every hook to ask again when it is done. A refusal of the server is a RequestError with its message.
Try it
A drive kept in this page, made for the demo: nothing is sent anywhere.
What the hook returns, live
All it gives you
- createFolder, rename, move
- Folders and names
- moveToTrash, restore, deleteForever, emptyTrash
- The trash
- copy, star, setColor, setFields
- A copy, starred, a folder’s colour, fields of your own
- share, unshare, createLink, removeLink
- With someone, or by a link
- upload, restoreVersion, review, url
- Files, their versions, what came through a request
Use it
The piece running above, as its file has it: it goes anywhere inside a 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 })
ChangeFiles sent three at a time, in parts, with how far each has gone, how fast and how long it has left. With check, each looked for in the drive first.
Try it
A drive kept in this page, made for the demo: nothing is sent anywhere.
What the hook returns, live
All it gives you
- uploads
- Each { name, size, sent, state, remaining }
- summary
- All of them together
- upload(files, folder)
- Send these
- retry, stop, stopAll, decide, dismiss, clear
- What a panel of uploads does
Use it
The piece running above, as its file has it: it goes anywhere inside a 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)
TogetherWhat is said of an entry, each thread with its replies under it, and saying more.
Try it
A drive kept in this page, made for the demo: nothing is sent anywhere.
What the hook returns, live
All it gives you
- comments
- Each { id, by, text, replyTo, resolved }
- loading, error
- Whether it is still asked; what the server refused
- add, edit, resolve, remove
- Saying, changing, closing, taking back
Use it
The piece running above, as its file has it: it goes anywhere inside a 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()
TogetherShared with them, named in a comment, a file come through a request, one about to expire: the newest first, and how many are new.
Try it
A drive kept in this page, made for the demo: nothing is sent anywhere.
What the hook returns, live
All it gives you
- notifications
- Each { kind, name, by, at }
- unseen
- How many are new
- loading, error
- Whether it is still asked; what the server refused
- saw()
- Say they were seen
Use it
The piece running above, as its file has it: it goes anywhere inside a 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()
TogetherThe requests for files one has made, each with its folder, its link and its token; and reminding whom one was sent to.
Try it
A drive kept in this page, made for the demo: nothing is sent anywhere.
What the hook returns, live
All it gives you
- requests
- Each { folder, link, token }
- loading, error
- Whether it is still asked; what the server refused
- remind(folder, link)
- Send a reminder now, by your server’s mail
Use it
The piece running above, as its file has it: it goes anywhere inside a 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)
TogetherWho matches what is typed, from your directory, as the server is given it. Nothing is asked for an empty query.
Try it
A drive kept in this page, made for the demo: nothing is sent anywhere.
What the hook returns, live
All it gives you
- people
- Each { id, name, detail }
- loading, error
- Whether it is still asked; what the server refused
Use it
The piece running above, as its file has it: it goes anywhere inside a 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?)
RecordsThe files whose field expires comes within so many days (15 unless said), or passed: the soonest first. For documents that run out: a passport, a licence, an insurance.
Try it
A drive kept in this page, made for the demo: nothing is sent anywhere.
What the hook returns, live
All it gives you
- files
- Each { entry, expires, days }
- loading, error
- Whether it is still asked; what the server refused
Use it
The piece running above, as its file has it: it goes anywhere inside a 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)
RecordsThe entries with a field of this value, wherever they are: the files of one client, one property, one case of yours.
Try it
A drive kept in this page, made for the demo: nothing is sent anywhere.
What the hook returns, live
All it gives you
- entries
- The entries found
- loading, error
- Whether it is still asked; what the server refused
Use it
The piece running above, as its file has it: it goes anywhere inside a 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()
The driveThe drive’s settings and the person’s own, for a panel of settings of yours.
Try it
A drive kept in this page, made for the demo: nothing is sent anywhere.
What the hook returns, live
All it gives you
- drive, mine
- The drive’s, and one’s own language and theme
- canConfigure
- Whether one may change the drive’s
- save, saveMine
- Saving each
Use it
The piece running above, as its file has it: it goes anywhere inside a 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()
The driveThe client with every route of the server, a number that goes up after each change, and the drive’s words: for what has no hook of its own.
Try it
A drive kept in this page, made for the demo: nothing is sent anywhere.
What the hook returns, live
All it gives you
- client
- Every route as a function
- revision, changed()
- When to ask again; telling every hook to
- t, locale, language
- The drive’s words, in the language shown
Use it
The piece running above, as its file has it: it goes anywhere inside a 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
The client and the words, for every hook under it.
import { DriveProvider } from '@rebofiles/react';
<DriveProvider base="/api/files" language="es" headers={async () => ({ authorization: `Bearer ${await token()}` })}>
<YourDrive />
</DriveProvider>What has no hook of its own
The space used, names of people by their ids, a link changed, an archive, a legal hold: the client has each, and useDrive() gives it with what makes every hook ask again.
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();A change made through client is not seen by the hooks until changed() is called; what the hooks give does it already. Every function of the client is in the reference.
With <FileDrive>
A drive inside your provider uses it: what is done in the drive, your hooks see, and what your hooks do, the drive shows.
<DriveProvider base="/api/files">
<FileDrive /> {/* the whole drive */}
<RecentSidebar /> {/* your own, with useEntries({ section: 'recent' }) */}
</DriveProvider>A whole drive
Sections, a path, folders, uploads with their progress, details, renaming, versions, the trash and search: the hooks and nothing else.
It is apps/playground/src/HooksDrive.tsx as it is, at /headless.html in the playground, and a test in Chrome goes through all of it.
// 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; }
`;