Ponlo en tu app Headless: tu propia interfaz
Headless: tu propia interfaz
En esta página
Prueba cada hook
Cada hook funcionando sobre un drive guardado en esta página: cambia algo en la demo y mira cómo cambia lo que devuelve el hook. El código de abajo es el de la demo, tal como está en su archivo.
useEntries({ section: 'files', folder })
LeerLo que muestra una lista: una carpeta, Recientes, Destacados, lo recibido por solicitudes, Compartido conmigo, la papelera o una búsqueda, pedido de nuevo tras cada cambio.
Pruébalo
…
Un drive guardado en esta página, hecho para la demo: no se envía nada a ningún sitio.
Lo que devuelve el hook, en vivo
Todo lo que te da
- entries
- Las carpetas y archivos, primero las carpetas
- path
- En una carpeta, las carpetas desde arriba hasta ella
- loading, error
- Si aún se está pidiendo; lo que el servidor rechazó
- refresh()
- Pedir de nuevo ahora
Las entradas de la vista anterior se quedan mientras carga la siguiente, con loading en true: no se toman por las de la nueva.
Úsalo
La pieza que corre arriba, tal como está en su archivo: va en cualquier lugar dentro de un 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)
LeerUna entrada tal como el servidor dice que está ahora, y las carpetas por encima.
Pruébalo
Un drive guardado en esta página, hecho para la demo: no se envía nada a ningún sitio.
Lo que devuelve el hook, en vivo
Todo lo que te da
- entry
- La carpeta o el archivo, con sus versiones
- path
- Las carpetas por encima
- trashed
- Si está en la papelera
La respuesta entera es null mientras carga, o si no existe.
Úsalo
La pieza que corre arriba, tal como está en su archivo: va en cualquier lugar dentro de un 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)
LeerLos archivos con estas palabras escritas dentro, cada uno con un trozo de su texto. Una búsqueda vacía no pide nada.
Pruébalo
Un drive guardado en esta página, hecho para la demo: no se envía nada a ningún sitio.
Lo que devuelve el hook, en vivo
Todo lo que te da
- results
- Cada { entry, snippet }
- loading, error
- Si aún se está pidiendo; lo que el servidor rechazó
Úsalo
La pieza que corre arriba, tal como está en su archivo: va en cualquier lugar dentro de un 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?)
LeerLo que se hizo a una entrada, quién y cuándo, lo más nuevo primero.
Pruébalo
Un drive guardado en esta página, hecho para la demo: no se envía nada a ningún sitio.
Lo que devuelve el hook, en vivo
Todo lo que te da
- activity
- Cada { at, by, action, name }
- loading, error
- Si aún se está pidiendo; lo que el servidor rechazó
Úsalo
La pieza que corre arriba, tal como está en su archivo: va en cualquier lugar dentro de un 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()
CambiarCada cambio, cada uno una promesa, que avisa a todos los hooks al terminar. Un rechazo del servidor es un RequestError con su mensaje.
Pruébalo
Un drive guardado en esta página, hecho para la demo: no se envía nada a ningún sitio.
Lo que devuelve el hook, en vivo
Todo lo que te da
- createFolder, rename, move
- Carpetas y nombres
- moveToTrash, restore, deleteForever, emptyTrash
- La papelera
- copy, star, setColor, setFields
- Una copia, destacado, el color de una carpeta, campos propios
- share, unshare, createLink, removeLink
- Con alguien, o por un enlace
- upload, restoreVersion, review, url
- Archivos, sus versiones, lo recibido por una solicitud
Úsalo
La pieza que corre arriba, tal como está en su archivo: va en cualquier lugar dentro de un 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 })
CambiarArchivos enviados de tres en tres, en partes, con cuánto lleva cada uno, a qué velocidad y cuánto le queda. Con check, cada uno se busca antes en el drive.
Pruébalo
Un drive guardado en esta página, hecho para la demo: no se envía nada a ningún sitio.
Lo que devuelve el hook, en vivo
Todo lo que te da
- uploads
- Cada { name, size, sent, state, remaining }
- summary
- Todos juntos
- upload(files, folder)
- Enviar estos
- retry, stop, stopAll, decide, dismiss, clear
- Lo que hace un panel de subidas
Úsalo
La pieza que corre arriba, tal como está en su archivo: va en cualquier lugar dentro de un 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)
JuntosLo que se dice de una entrada, cada hilo con sus respuestas debajo, y decir más.
Pruébalo
Un drive guardado en esta página, hecho para la demo: no se envía nada a ningún sitio.
Lo que devuelve el hook, en vivo
Todo lo que te da
- comments
- Cada { id, by, text, replyTo, resolved }
- loading, error
- Si aún se está pidiendo; lo que el servidor rechazó
- add, edit, resolve, remove
- Decir, cambiar, cerrar, quitar
Úsalo
La pieza que corre arriba, tal como está en su archivo: va en cualquier lugar dentro de un 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()
JuntosCompartido con ellos, nombrados en un comentario, un archivo llegado por una solicitud, uno por vencer: lo más nuevo primero, y cuántas son nuevas.
Pruébalo
Un drive guardado en esta página, hecho para la demo: no se envía nada a ningún sitio.
Lo que devuelve el hook, en vivo
Todo lo que te da
- notifications
- Cada { kind, name, by, at }
- unseen
- Cuántas son nuevas
- loading, error
- Si aún se está pidiendo; lo que el servidor rechazó
- saw()
- Decir que se vieron
Úsalo
La pieza que corre arriba, tal como está en su archivo: va en cualquier lugar dentro de un 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()
JuntosLas solicitudes de archivos que uno ha hecho, cada una con su carpeta, su enlace y su token; y recordar a quien se envió.
Pruébalo
Un drive guardado en esta página, hecho para la demo: no se envía nada a ningún sitio.
Lo que devuelve el hook, en vivo
Todo lo que te da
- requests
- Cada { folder, link, token }
- loading, error
- Si aún se está pidiendo; lo que el servidor rechazó
- remind(folder, link)
- Enviar un recordatorio ahora, por el correo de tu servidor
Úsalo
La pieza que corre arriba, tal como está en su archivo: va en cualquier lugar dentro de un 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)
JuntosQuién coincide con lo escrito, de tu directorio, tal como se le da al servidor. Una búsqueda vacía no pide nada.
Pruébalo
Un drive guardado en esta página, hecho para la demo: no se envía nada a ningún sitio.
Lo que devuelve el hook, en vivo
Todo lo que te da
- people
- Cada { id, name, detail }
- loading, error
- Si aún se está pidiendo; lo que el servidor rechazó
Úsalo
La pieza que corre arriba, tal como está en su archivo: va en cualquier lugar dentro de un 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?)
RegistrosLos archivos cuyo campo expires llega en tantos días (15 si no se dice), o ya pasó: los más próximos primero. Para documentos que vencen: un pasaporte, una licencia, un seguro.
Pruébalo
Un drive guardado en esta página, hecho para la demo: no se envía nada a ningún sitio.
Lo que devuelve el hook, en vivo
Todo lo que te da
- files
- Cada { entry, expires, days }
- loading, error
- Si aún se está pidiendo; lo que el servidor rechazó
Úsalo
La pieza que corre arriba, tal como está en su archivo: va en cualquier lugar dentro de un 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)
RegistrosLas entradas con un campo de este valor, estén donde estén: los archivos de un cliente, una propiedad, un caso tuyo.
Pruébalo
Un drive guardado en esta página, hecho para la demo: no se envía nada a ningún sitio.
Lo que devuelve el hook, en vivo
Todo lo que te da
- entries
- Las entradas encontradas
- loading, error
- Si aún se está pidiendo; lo que el servidor rechazó
Úsalo
La pieza que corre arriba, tal como está en su archivo: va en cualquier lugar dentro de un 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()
El driveLos ajustes del drive y los de la persona, para un panel de ajustes tuyo.
Pruébalo
Un drive guardado en esta página, hecho para la demo: no se envía nada a ningún sitio.
Lo que devuelve el hook, en vivo
Todo lo que te da
- drive, mine
- Los del drive, y el idioma y tema propios
- canConfigure
- Si uno puede cambiar los del drive
- save, saveMine
- Guardar cada uno
Úsalo
La pieza que corre arriba, tal como está en su archivo: va en cualquier lugar dentro de un 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()
El driveEl cliente con cada ruta del servidor, un número que sube tras cada cambio, y las palabras del drive: para lo que no tiene hook propio.
Pruébalo
Un drive guardado en esta página, hecho para la demo: no se envía nada a ningún sitio.
Lo que devuelve el hook, en vivo
Todo lo que te da
- client
- Cada ruta como una función
- revision, changed()
- Cuándo pedir de nuevo; avisar a todos los hooks
- t, locale, language
- Las palabras del drive, en el idioma mostrado
Úsalo
La pieza que corre arriba, tal como está en su archivo: va en cualquier lugar dentro de un 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
El cliente y las palabras, para cada hook que tenga debajo.
import { DriveProvider } from '@rebofiles/react';
<DriveProvider base="/api/files" language="es" headers={async () => ({ authorization: `Bearer ${await token()}` })}>
<YourDrive />
</DriveProvider>Lo que no tiene hook propio
El espacio usado, los nombres de las personas por su id, un enlace cambiado, un archivo comprimido, una retención legal: el cliente tiene cada uno, y useDrive() lo da con lo que hace que todos los hooks pidan de nuevo.
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();Un cambio hecho con client no lo ven los hooks hasta llamar a changed(); lo que dan los hooks ya lo hace. Cada función del cliente está en la referencia.
Con <FileDrive>
Un drive dentro de tu provider lo usa: lo que se hace en el drive lo ven tus hooks, y lo que hacen tus hooks lo muestra el drive.
<DriveProvider base="/api/files">
<FileDrive /> {/* the whole drive */}
<RecentSidebar /> {/* your own, with useEntries({ section: 'recent' }) */}
</DriveProvider>Un drive entero
Secciones, una ruta, carpetas, subidas con su progreso, detalles, renombrar, versiones, la papelera y la búsqueda: los hooks y nada más.
Es apps/playground/src/HooksDrive.tsx tal cual, en /headless.html del playground, y un test en Chrome lo recorre entero. La demo tiene otro, con aspecto de suite creativa.
// 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; }
`;