ReboFilesDocs
Empezar3
Qué es ReboFiles
Primeros pasos
Demos para ejecutar
Ponlo en tu app6
Componente de React
Headless: tu propia interfaz
Sin framework
Estilos y temas
Teclado y accesibilidad
Idiomas
Archivos9
Subidas
Vistas previas y el visor
Búsqueda
Cambios en vivo y carpetas grandes
Comentarios
Destacados y campos
Versiones, papelera y espacio
Descargar como .zip
Documentos de Word
El servidor14
La ruta
Frameworks e inicio de sesión
Quién puede hacer qué
Almacenamiento: S3, GCS, Azure y disco
Revisar las subidas
Metadatos: SQLite y Postgres
Esquema de la base de datos
Compartir y enlaces
Actividad y notificaciones
Ganchos, registros y límites
Retención, bloqueos y exportación
Escritorio y otros orígenes
Sincronizar una carpeta
Ir a producción
En equipo1
Asistentes de IA (MCP)
Referencia5
Paquetes
Componentes y hooks
API del cliente
Rutas HTTP
Estado y hoja de ruta
↑↓ para moverte↵ para abrirEsc para cerrar38 páginas

Ponlo en tu app Headless: tu propia interfaz

Headless: tu propia interfaz

El componente está hecho con estos hooks y nada más: tómalos para un drive con tu marcado y tu aspecto, o para una pieza de uno junto al completo.
En vivo

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 })

Leer

Lo 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>
  );
}
Provider

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>
El cliente

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.

Juntos

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>
Ejemplo

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.

HooksDrive.tsx
// 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; }
`;