ReboFiles文档
开始3
什么是 ReboFiles
快速开始
可运行的演示
集成到你的应用6
React 组件
Headless:你自己的界面
不使用框架
样式与主题
键盘与无障碍
语言
文件9
上传
预览与查看器
搜索
实时更改与大文件夹
评论
星标与字段
版本、回收站与空间
打包下载 .zip
Word 文档
服务器14
路由
框架与登录
谁可以做什么
存储:S3、GCS、Azure 与磁盘
检查上传
元数据:SQLite 与 Postgres
数据库结构
共享与链接
动态与通知
钩子、日志与限制
保留、法律保留与导出
桌面端与其他来源
同步文件夹
上线生产环境
协作1
AI 助手(MCP)
参考5
包
组件与 hooks
客户端 API
HTTP 路由
状态与路线图
↑↓ 移动↵ 打开Esc 关闭38 个页面

集成到你的应用 Headless:你自己的界面

Headless:你自己的界面

组件完全由这些 hooks 构建而成:用它们搭建使用你自己标记和外观的网盘,或者在完整网盘旁边放上其中一部分。
实时

逐个试用 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>
  );
}
Provider

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 测试完整走一遍。演示页中还有另一个创意套件风格的例子。

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; }
`;