The collapsed rail was a fixed 72px, which left the cluster with more room on its left than its right. The width now comes from the buttons themselves - the close button's own inset plus the zoom button's right edge - so the margin matches on both sides. That inset is macOS's to choose and has changed between releases, so it is asked for rather than assumed. Measured at startup rather than on demand. The answer arrives on the main thread, and a command waiting for it there deadlocks until the timeout and silently returns the fallback - which is what the first attempt did.
235 lines
9.0 KiB
TypeScript
235 lines
9.0 KiB
TypeScript
import type { Config, Group, WorkApp } from "../types";
|
|
import { Back, Cog, Collapse, Forward, Reload } from "./icons";
|
|
import { Favicon, GROUP_LABEL, ICON_CHROME } from "./ui";
|
|
|
|
interface Props {
|
|
config: Config;
|
|
activeId: string | null;
|
|
collapsed: boolean;
|
|
/** How much has arrived in each app since it was last looked at. */
|
|
unread: Record<string, number>;
|
|
onSelect: (id: string) => void;
|
|
onToggleCollapse: () => void;
|
|
onOpenSettings: () => void;
|
|
onToggleGroup: (g: Group) => void;
|
|
onBack: () => void;
|
|
onForward: () => void;
|
|
onReload: () => void;
|
|
/** Height of the title bar the window's content runs underneath. */
|
|
chrome: number;
|
|
/**
|
|
* How wide the collapsed rail has to be for the traffic lights to keep the
|
|
* same margin on their right as macOS gave them on their left. Measured from
|
|
* the buttons themselves, since that inset is macOS's to choose.
|
|
*/
|
|
rail: number;
|
|
}
|
|
|
|
const PANEL = 240;
|
|
|
|
export default function Nav({
|
|
config, activeId, collapsed, unread,
|
|
onSelect, onToggleCollapse, onOpenSettings, onToggleGroup,
|
|
onBack, onForward, onReload, chrome, rail,
|
|
}: Props) {
|
|
const groups = [...config.groups].sort((a, b) => a.order - b.order);
|
|
const inGroup = (id: string | null) =>
|
|
config.apps.filter((a) => a.groupId === id).sort((a, b) => a.order - b.order);
|
|
const ungrouped = inGroup(null);
|
|
const disabled = !activeId;
|
|
|
|
const shell =
|
|
"flex shrink-0 flex-col overflow-hidden border-r border-slate-200 bg-white " +
|
|
"dark:border-slate-800 dark:bg-slate-900";
|
|
|
|
/* Back, forward, reload and settings. Nothing hides an element here — that
|
|
lives on the right-click menu, where you are already pointing at the thing
|
|
you want gone. */
|
|
const controls = (
|
|
<>
|
|
<button onClick={onBack} disabled={disabled} title="Back (or swipe left)" className={ICON_CHROME}>
|
|
<Back />
|
|
</button>
|
|
<button onClick={onForward} disabled={disabled} title="Forward (or swipe right)" className={ICON_CHROME}>
|
|
<Forward />
|
|
</button>
|
|
<button onClick={onReload} disabled={disabled} title="Reload" className={ICON_CHROME}>
|
|
<Reload />
|
|
</button>
|
|
</>
|
|
);
|
|
|
|
/* Settings lives at the foot of both layouts: it is the thing you reach for
|
|
least, and putting it there leaves the top for what you use constantly. */
|
|
const settingsButton = (
|
|
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
|
|
<Cog />
|
|
</button>
|
|
);
|
|
|
|
/* A count of what arrived while you were elsewhere. Absent, rather than
|
|
zero, when there is nothing — a row of noughts is just noise. */
|
|
const badge = (id: string, active: boolean) => {
|
|
const n = unread[id] ?? 0;
|
|
if (n === 0) return null;
|
|
return (
|
|
<span
|
|
title={`${n} new since you last looked`}
|
|
className={
|
|
"ml-auto min-w-[18px] shrink-0 rounded-full px-1.5 py-0.5 text-center " +
|
|
"font-mono text-[10px] font-semibold tabular-nums " +
|
|
(active ? "bg-white/25 text-white dark:bg-slate-900/20 dark:text-slate-900" : "bg-sky-500 text-white")
|
|
}
|
|
>
|
|
{n > 99 ? "99+" : n}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
// ------------------------------------------------------------ icon rail
|
|
if (collapsed) {
|
|
const railBtn = (app: WorkApp) => {
|
|
const active = app.id === activeId;
|
|
return (
|
|
<button
|
|
key={app.id}
|
|
onClick={() => onSelect(app.id)}
|
|
title={app.name}
|
|
aria-label={app.name}
|
|
className={
|
|
"relative grid size-10 cursor-pointer place-items-center rounded-lg transition-colors " +
|
|
(active
|
|
? "bg-slate-100 dark:bg-slate-800"
|
|
: "opacity-70 hover:bg-slate-100 hover:opacity-100 dark:hover:bg-slate-800")
|
|
}
|
|
>
|
|
{active && <span className="absolute left-0 h-5 w-[3px] rounded-full bg-sky-500" />}
|
|
<Favicon icon={app.icon} name={app.name} size={20} />
|
|
{(unread[app.id] ?? 0) > 0 && (
|
|
/* A fixed circle, inside the button's own bounds. Padding around a
|
|
number makes a pill as soon as there are two digits, and hanging
|
|
it off the corner puts it under the rail's clip. Anything past
|
|
nine reads as "9+" — the exact figure is in the expanded nav. */
|
|
<span
|
|
title={`${unread[app.id]} new since you last looked`}
|
|
className="absolute right-0 top-0 grid size-[15px] place-items-center
|
|
rounded-full bg-sky-500 font-mono text-[9px] font-semibold
|
|
leading-none text-white"
|
|
>
|
|
{unread[app.id] > 9 ? "9+" : unread[app.id]}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<aside className={`${shell} items-center`} style={{ width: rail }}>
|
|
<div data-tauri-drag-region className="w-full shrink-0" style={{ height: chrome }} />
|
|
{/* Only the expander survives the rail's header. Back and forward are
|
|
a two-finger swipe and reload is ⌘R, so a toolbar here would be
|
|
clutter standing in for something nobody asked for. */}
|
|
<div className="flex w-full flex-col items-center border-b border-slate-200 py-2.5 dark:border-slate-800">
|
|
<button onClick={onToggleCollapse} title="Expand" className={ICON_CHROME}>
|
|
<Collapse open={false} />
|
|
</button>
|
|
</div>
|
|
<nav className="flex min-h-0 flex-1 flex-col items-center gap-1 overflow-y-auto py-3">
|
|
{ungrouped.map(railBtn)}
|
|
{ungrouped.length > 0 && groups.length > 0 && (
|
|
<span className="my-1 h-px w-7 bg-slate-200 dark:bg-slate-800" />
|
|
)}
|
|
{groups.map((g, i) => {
|
|
const apps = inGroup(g.id);
|
|
if (apps.length === 0) return null;
|
|
return (
|
|
<div key={g.id} className="flex flex-col items-center gap-1">
|
|
{i > 0 && <span className="my-1 h-px w-7 bg-slate-200 dark:bg-slate-800" />}
|
|
{apps.map(railBtn)}
|
|
</div>
|
|
);
|
|
})}
|
|
</nav>
|
|
<div className="flex w-full flex-col items-center border-t border-slate-200 py-2 dark:border-slate-800">
|
|
{settingsButton}
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
// -------------------------------------------------------------- panel
|
|
const row =
|
|
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-[13px] transition-colors";
|
|
const inactive =
|
|
"text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800";
|
|
const active = "bg-slate-900 text-white dark:bg-white dark:text-slate-900";
|
|
|
|
const appRow = (app: WorkApp) => (
|
|
<li key={app.id}>
|
|
<button
|
|
onClick={() => onSelect(app.id)}
|
|
title={app.url}
|
|
className={`${row} ${app.id === activeId ? active : inactive}`}
|
|
>
|
|
<Favicon icon={app.icon} name={app.name} />
|
|
<span className="truncate">{app.name}</span>
|
|
{badge(app.id, app.id === activeId)}
|
|
</button>
|
|
</li>
|
|
);
|
|
|
|
return (
|
|
<aside className={shell} style={{ width: PANEL }}>
|
|
<div data-tauri-drag-region className="shrink-0" style={{ height: chrome }} />
|
|
<header className="flex items-center gap-0.5 border-b border-slate-200 px-1.5 py-2.5 dark:border-slate-800">
|
|
{controls}
|
|
<button
|
|
onClick={onToggleCollapse}
|
|
title="Collapse"
|
|
className={`${ICON_CHROME} ml-auto`}
|
|
>
|
|
<Collapse open />
|
|
</button>
|
|
</header>
|
|
|
|
<nav className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
|
|
{ungrouped.length > 0 && <ul className="space-y-0.5">{ungrouped.map(appRow)}</ul>}
|
|
|
|
{groups.map((g) => {
|
|
const apps = inGroup(g.id);
|
|
return (
|
|
<section key={g.id} className="pt-3 first:pt-0">
|
|
<button
|
|
onClick={() => onToggleGroup(g)}
|
|
className={`${GROUP_LABEL} flex w-full cursor-pointer items-center gap-1 px-2 pb-1
|
|
hover:text-slate-700 dark:hover:text-slate-200`}
|
|
>
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
className={`size-3 transition-transform ${g.collapsed ? "" : "rotate-90"}`}
|
|
fill="none" stroke="currentColor" strokeWidth="2.5"
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 6l6 6-6 6" />
|
|
</svg>
|
|
<span className="truncate">{g.name}</span>
|
|
<span className="ml-auto font-mono text-[10px] opacity-60">{apps.length}</span>
|
|
</button>
|
|
{!g.collapsed && <ul className="space-y-0.5">{apps.map(appRow)}</ul>}
|
|
</section>
|
|
);
|
|
})}
|
|
|
|
{config.apps.length === 0 && (
|
|
<p className="px-2 py-3 text-[11px] leading-snug text-slate-500 dark:text-slate-400">
|
|
No apps yet. Open Settings to add the first one.
|
|
</p>
|
|
)}
|
|
</nav>
|
|
|
|
<footer className="flex items-center gap-1 border-t border-slate-200 px-1.5 py-2 dark:border-slate-800">
|
|
{settingsButton}
|
|
</footer>
|
|
</aside>
|
|
);
|
|
}
|