Files
work-app/src/components/Nav.tsx
T
Vincent 899f0b41c4 Remember window and tab; fill saved passwords; match the title bar
The window's position and size survive a restart, and so does the app
you were last in.

Saving a password was only half of it - a saved password you cannot get
back out is not saved in any useful sense. Right-click now offers "Fill
saved password" wherever a page has a password field. Only on an explicit
click, only for the host on screen, and only into that page's own fields,
never on load. The value is set through the native setter and followed by
input and change events, because assigning .value is invisible to React
and Angular and the site would submit an empty field.

The title bar is painted the nav's colour rather than its own grey, so
the two read as one surface in either theme. Doing that revealed that the
window's content had always run the full height underneath it - the
opaque bar was hiding it - so the shell is now inset by the measured
title bar height, or the nav lands on the traffic lights.

The rail's unread badge is a fixed circle inside the button's bounds.
Padding around a number made a pill at two digits, and hanging it off the
corner put it under the rail's clip; past nine it reads "9+", with the
figure itself in the expanded nav.
2026-09-01 19:17:38 +02:00

232 lines
8.7 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;
}
/**
* Wide enough that the macOS traffic lights fit inside the rail rather than
* spilling over the page. Everything else about the rail follows from that.
*/
const RAIL = 72;
const PANEL = 240;
export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL);
export default function Nav({
config, activeId, collapsed, unread,
onSelect, onToggleCollapse, onOpenSettings, onToggleGroup,
onBack, onForward, onReload,
}: 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 }}>
{/* 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 }}>
<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>
);
}