Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import { useEffect, useState, type ReactNode } from 'react';
import { usePublicConfig } from '../api/hooks';
import { useI18n } from '../i18n';
import { useWorkspace } from '../state/workspace';
import { useSession } from '../state/session';
import { useToasts } from '../state/toasts';
import { goTo, workspacePath } from '../state/route';
import { Menu, useMenu } from './Menu';
import { SearchIcon } from './Icons';
interface TopBarProps {
onOpenAccount(): void;
onOpenPeople(): void;
onOpenKeys(): void;
onOpenWorkspace(): void;
onOpenBilling(): void;
sidebarOpen: boolean;
onToggleSidebar(): void;
}
export function TopBar({
onOpenAccount,
onOpenPeople,
onOpenKeys,
onOpenWorkspace,
onOpenBilling,
sidebarOpen,
onToggleSidebar,
}: TopBarProps): ReactNode {
const ws = useWorkspace();
const session = useSession();
const toasts = useToasts();
const { t } = useI18n();
// An install under its own licence has no billing to open — the dialog is
// still worth reaching for what it counts, so the entry stays and says so.
const metered = usePublicConfig().data?.metered ?? true;
const accountMenu = useMenu<null>();
const workspaceMenu = useMenu<null>();
// Debounce the search box: every keystroke is a fresh SQL query otherwise.
const { setSearch } = ws;
const [draft, setDraft] = useState(ws.search);
useEffect(() => {
const handle = window.setTimeout(() => setSearch(draft.trim()), 250);
return () => window.clearTimeout(handle);
}, [draft, setSearch]);
return (
<header className="top">
{/* Only rendered wide enough to matter by CSS: below 980px the sidebar
becomes a drawer and this is the only way back to it. */}
<button
className="iconbtn sidebar-toggle"
onClick={onToggleSidebar}
aria-expanded={sidebarOpen}
aria-controls="sidebar"
aria-label={t('sidebar.tablesAndViews')}
title={t('sidebar.tablesAndViews')}
>
☰
</button>
<div className="wordmark">
Well<span>formed</span>
</div>
{/* The workspace is the first thing on screen, because on a shared
account it is the thing you most need to be sure of. A visitor has
nothing to switch to, so it is a label rather than a menu. */}
{session.visiting ? (
<span className="wsbutton static" title={`/w/${session.workspace?.slug ?? ''}`}>
{session.workspace?.name ?? t('topbar.workspace')}
<span className="tag">{t('topbar.demoReadOnly')}</span>
</span>
) : (
<button
className="wsbutton"
onClick={(e) => workspaceMenu.open(e, null)}
title={`/w/${session.workspace?.slug ?? ''}`}
>
<span className="wsname">{session.workspace?.name ?? t('topbar.workspace')}</span> ▾
</button>
)}
<div className="searchbox">
<SearchIcon />
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={t('topbar.searchIn', ws.table?.label.toLowerCase() ?? '')}
aria-label={t('topbar.searchCurrentTable')}
onKeyDown={(e) => {
if (e.key === 'Escape') setDraft('');
}}
/>
{draft && (
<button onClick={() => setDraft('')} aria-label={t('topbar.clearSearch')}>
×
</button>
)}
</div>
<div className="grow" />
{/* Undo is a write, so it is not offered to someone who cannot write.
Both halves carry a word and a tooltip naming what they will do; the
words fold away on a narrow screen, the glyphs and titles do not. */}
{!session.visiting && (
<>
<button
className="iconbtn"
onClick={() => void ws.undo.undo().catch((e: unknown) => toasts.fail(e))}
disabled={!ws.undo.canUndo}
title={
ws.undo.nextUndoLabel
? t('topbar.undoAction', ws.undo.nextUndoLabel)
: t('topbar.nothingToUndo')
}
aria-label={
ws.undo.nextUndoLabel
? t('topbar.undoAction', ws.undo.nextUndoLabel)
: t('common.undo')
}
>
↶ <span className="lbl">{t('common.undo')}</span>
</button>
<button
className="iconbtn"
onClick={() => void ws.undo.redo().catch((e: unknown) => toasts.fail(e))}
disabled={!ws.undo.canRedo}
title={
ws.undo.nextRedoLabel
? t('topbar.redoAction', ws.undo.nextRedoLabel)
: t('topbar.nothingToRedo')
}
aria-label={
ws.undo.nextRedoLabel
? t('topbar.redoAction', ws.undo.nextRedoLabel)
: t('topbar.redo')
}
>
↷ <span className="lbl">{t('topbar.redo')}</span>
</button>
</>
)}
<button
className={'iconbtn' + (ws.showAudit ? ' active' : '')}
onClick={() => ws.setShowAudit(!ws.showAudit)}
aria-pressed={ws.showAudit}
title={t('topbar.auditTrail')}
aria-label={t('topbar.auditTrail')}
>
◔ <span className="lbl">{t('topbar.auditTrail')}</span>
</button>
{session.visiting ? (
<a className="btn primary" href="/signup">
{t('topbar.createYourOwn')}
</a>
) : (
<button
className="iconbtn"
onClick={(e) => accountMenu.open(e, null)}
title={session.account?.name ?? t('topbar.account')}
aria-label={session.account?.name ?? t('topbar.account')}
>
{/* Two bare chevrons side by side say nothing about which is which,
so the account keeps an initial where its name will not fit. */}
<span className="initial" aria-hidden="true">
{(session.account?.name ?? 'A').trim().charAt(0).toUpperCase()}
</span>
<span className="lbl">{session.account?.name ?? t('topbar.account')}</span> ▾
</button>
)}
{workspaceMenu.state && (
<Menu
x={workspaceMenu.state.x}
y={workspaceMenu.state.y}
label={t('topbar.workspace')}
onClose={workspaceMenu.close}
items={[
{ label: t('topbar.workspaceSettings'), onSelect: onOpenWorkspace },
{ label: t('topbar.people'), onSelect: onOpenPeople },
{ label: t('topbar.agentKeys'), onSelect: onOpenKeys },
...(session.can('admin')
? [
{
label: metered ? t('topbar.planAndBilling') : t('topbar.usage'),
onSelect: onOpenBilling,
},
]
: []),
'separator',
// Switching is a real navigation: two workspaces can then be open
// in two tabs, and no cached row can cross between them.
...session.workspaces
.filter((w) => w.slug !== session.workspace?.slug)
.map((w) => ({
label: t('topbar.switchTo', w.name),
// Names are not unique; the address is. Showing it is the only
// way to tell two workspaces called "Client Work" apart.
hint: w.slug,
onSelect: () => goTo(workspacePath(w.slug)),
})),
{ label: t('topbar.allWorkspaces'), onSelect: () => goTo('/') },
]}
/>
)}
{accountMenu.state && (
<Menu
x={accountMenu.state.x}
y={accountMenu.state.y}
label={t('topbar.account')}
onClose={accountMenu.close}
items={[
{ label: t('topbar.yourAccount'), onSelect: onOpenAccount },
'separator',
{
label: t('chrome.signOut'),
onSelect: () =>
void session
.signOut()
.then(() => goTo('/'))
.catch((e: unknown) => toasts.fail(e)),
},
]}
/>
)}
</header>
);
}