feat(core): add layered side rail preferences

This commit is contained in:
2026-08-20 01:47:04 +02:00
parent 51bf14f376
commit cf16a7b27a
13 changed files with 763 additions and 19 deletions
@@ -0,0 +1,138 @@
import { ArrowDown, ArrowUp, LockKeyhole } from "lucide-react";
import type { NavigationPreferences, PlatformNavItem } from "../types";
import Button from "./Button";
import IconButton from "./IconButton";
import ToggleSwitch from "./ToggleSwitch";
import { usePlatformLanguage } from "../i18n/LanguageContext";
type Scope = "system" | "tenant" | "user";
export default function NavigationPreferenceEditor({
items,
value,
onChange,
scope,
disabled = false
}: {
items: PlatformNavItem[];
value: NavigationPreferences | null;
onChange: (value: NavigationPreferences | null) => void;
scope: Scope;
disabled?: boolean;
}) {
const { translateText } = usePlatformLanguage();
const inheritedScope = scope === "system" ? "module" : scope === "tenant" ? "system" : "tenant";
const inherited = preferenceFromLayer(items, inheritedScope);
const editable = value ?? inherited;
const byId = new Map(items.map((item) => [navigationId(item), item]));
const inheritedIds = [...items]
.sort((left, right) => layerOrder(left, inheritedScope) - layerOrder(right, inheritedScope))
.map(navigationId);
const orderedIds = [
...editable.order.filter((id) => byId.has(id)),
...inheritedIds.filter((id) => !editable.order.includes(id))
];
const hidden = new Set(editable.hidden);
const localLocks = new Set(editable.locked ?? []);
function update(patch: Partial<NavigationPreferences>) {
onChange({ ...editable, ...patch, contract_version: "1" });
}
function move(id: string, offset: -1 | 1) {
const index = orderedIds.indexOf(id);
const target = index + offset;
if (index < 0 || target < 0 || target >= orderedIds.length) return;
const next = [...orderedIds];
[next[index], next[target]] = [next[target], next[index]];
update({ order: next });
}
function setVisible(id: string, visible: boolean) {
const next = new Set(hidden);
if (visible) next.delete(id);
else next.add(id);
update({ order: orderedIds, hidden: [...next] });
}
function setLocked(id: string, locked: boolean) {
const next = new Set(localLocks);
if (locked) next.add(id);
else next.delete(id);
const nextHidden = new Set(hidden);
if (locked) nextHidden.delete(id);
update({ order: orderedIds, hidden: [...nextHidden], locked: [...next] });
}
return (
<div className="navigation-preference-editor" data-navigation-preference-scope={scope}>
<div className="navigation-preference-toolbar">
<p className="muted small-note">
Higher personal settings take precedence over tenant and system order. Locked entries remain visible.
</p>
<Button onClick={() => onChange(null)} disabled={disabled || value === null}>
Use inherited order
</Button>
</div>
<ol className="navigation-preference-list">
{orderedIds.map((id, index) => {
const item = byId.get(id);
if (!item) return null;
const inheritedState = item.navigationLayers?.[inheritedScope];
const ancestorLocked = Boolean(inheritedState?.locked);
const locked = ancestorLocked || localLocks.has(id);
const label = translateText(item.label);
return (
<li key={id} data-navigation-id={id} data-navigation-locked={locked ? "true" : "false"}>
<div className="navigation-preference-order-actions">
<IconButton label={`Move ${label} up`} icon={<ArrowUp size={16} />} onClick={() => move(id, -1)} disabled={disabled || index === 0} />
<IconButton label={`Move ${label} down`} icon={<ArrowDown size={16} />} onClick={() => move(id, 1)} disabled={disabled || index === orderedIds.length - 1} />
</div>
<div className="navigation-preference-label">
<strong>{label}</strong>
<span>{id}</span>
</div>
<ToggleSwitch
label="Visible"
checked={locked || !hidden.has(id)}
disabled={disabled || locked}
help={locked ? `Locked by ${inheritedState?.lock_source ?? scope}` : undefined}
onChange={(visible) => setVisible(id, visible)}
/>
{scope !== "user" && (
<ToggleSwitch
label={<><LockKeyhole size={14} aria-hidden="true" /> Locked</>}
checked={locked}
disabled={disabled || ancestorLocked}
help={ancestorLocked ? `Locked by ${inheritedState?.lock_source}` : "Lower scopes cannot hide this entry."}
onChange={(next) => setLocked(id, next)}
/>
)}
</li>
);
})}
</ol>
</div>
);
}
function navigationId(item: PlatformNavItem): string {
return item.navigationId ?? item.surfaceId ?? item.to;
}
function preferenceFromLayer(
items: PlatformNavItem[],
layer: "module" | "system" | "tenant"
): NavigationPreferences {
const ordered = [...items].sort((left, right) => layerOrder(left, layer) - layerOrder(right, layer));
return {
contract_version: "1",
order: ordered.map(navigationId),
hidden: ordered.filter((item) => item.navigationLayers?.[layer]?.visible === false).map(navigationId),
locked: []
};
}
function layerOrder(item: PlatformNavItem, layer: "module" | "system" | "tenant"): number {
return item.navigationLayers?.[layer]?.order ?? item.order ?? 100;
}