Files
kandev/apps/web/lib/sidebar/layout-projection.ts
T
2026-09-20 00:09:02 +01:00

194 lines
5.9 KiB
TypeScript

import {
IconBolt,
IconLayoutGrid,
IconList,
IconPlus,
IconQuestionMark,
} from "@tabler/icons-react";
import type { DestinationIcon } from "@/lib/navigation/types";
import {
DEFAULT_SIDEBAR_NODE_IDS,
targetKey,
type SidebarLayout,
type SidebarLayoutNode,
type SidebarShortcut,
} from "./layout-types";
import type { ShortcutCatalogEntry } from "./shortcut-catalog";
import { catalogEntryKey, unavailableShortcutIcon } from "./shortcut-catalog";
export type ProjectedShortcut = SidebarShortcut & {
label: string;
icon: DestinationIcon;
href?: string;
source: NonNullable<ShortcutCatalogEntry["source"]>;
available: boolean;
};
export type ProjectedSidebarNode = Omit<SidebarLayoutNode, "shortcuts"> & {
label: string;
icon: DestinationIcon;
shortcuts: ProjectedShortcut[];
available?: boolean;
};
export type SidebarLayoutProjection = {
nodes: ProjectedSidebarNode[];
protectedNodeIds: string[];
};
type ProjectionOptions = {
unavailableLabel?: string;
builtinLabels?: Record<string, string>;
};
const BUILTIN_ICONS: Record<string, DestinationIcon> = {
home: IconList,
new_task: IconPlus,
automations: IconBolt,
canvases: IconLayoutGrid,
integrations: IconList,
};
function unavailableShortcut(shortcut: SidebarShortcut, label: string): ProjectedShortcut {
return {
...shortcut,
label,
icon: unavailableShortcutIcon,
source: shortcut.target.kind === "destination" ? "builtin" : shortcut.target.kind,
available: false,
};
}
function projectShortcut(
shortcut: SidebarShortcut,
catalog: Map<string, ShortcutCatalogEntry>,
unavailableLabel: string,
): ProjectedShortcut {
const entry = catalog.get(catalogEntryKey({ target: shortcut.target }));
return entry
? {
...shortcut,
...entry,
source: entry.source ?? "builtin",
icon: entry.icon ?? unavailableShortcutIcon,
}
: unavailableShortcut(shortcut, unavailableLabel);
}
function pluginInsertionIndex(
nodes: SidebarLayoutNode[],
section: ShortcutCatalogEntry["section"],
): number {
if (section === "plugins") {
const newTaskIndex = nodes.findIndex((node) => node.destinationId === "new_task");
return newTaskIndex >= 0 ? newTaskIndex + 1 : 0;
}
if (section === "integrations") {
const integrationsIndex = nodes.findIndex((node) => node.destinationId === "integrations");
return integrationsIndex >= 0 ? integrationsIndex + 1 : nodes.length;
}
return nodes.length;
}
/**
* Adds registered plugin destinations to the editable node list. The section
* comes from the navigation manifest, while the saved node controls visibility
* and ordering after the user has moved it.
*/
export function materializeSidebarPluginNodes(
layout: SidebarLayout,
catalog: ShortcutCatalogEntry[],
): SidebarLayout {
const pluginEntries = catalog.filter(
(entry) => entry.source === "plugin" && entry.target.kind === "destination",
);
if (pluginEntries.length === 0) return layout;
const nodes = layout.nodes.map((node) => {
const entry = pluginEntries.find((item) => item.target.id === node.destinationId);
return entry?.section && node.kind === "plugin"
? { ...node, pluginSection: entry.section }
: node;
});
for (const entry of [...pluginEntries].reverse()) {
if (nodes.some((node) => node.id === entry.target.id)) continue;
const index = pluginInsertionIndex(nodes, entry.section);
nodes.splice(index, 0, {
id: entry.target.id,
kind: "plugin",
visible: true,
destinationId: entry.target.id,
...(entry.section ? { pluginSection: entry.section } : {}),
});
}
return nodes.length === layout.nodes.length &&
nodes.every((node, index) => node === layout.nodes[index])
? layout
: { ...layout, nodes };
}
function projectNodePresentation(
node: SidebarLayoutNode,
catalog: Map<string, ShortcutCatalogEntry>,
unavailableLabel: string,
builtinLabels: Record<string, string>,
): Pick<ProjectedSidebarNode, "label" | "icon" | "available"> {
const builtinIcon = node.destinationId ? BUILTIN_ICONS[node.destinationId] : undefined;
const destination = node.destinationId
? catalog.get(`destination:${node.destinationId}`)
: undefined;
if (node.kind === "plugin" && !destination) {
return { label: unavailableLabel, icon: unavailableShortcutIcon, available: false };
}
const label =
node.name ??
destination?.label ??
(node.destinationId ? builtinLabels[node.destinationId] : undefined) ??
node.id;
const icon = destination?.icon ?? builtinIcon ?? IconQuestionMark;
return { label, icon, available: true };
}
function projectNode(
node: SidebarLayoutNode,
catalog: Map<string, ShortcutCatalogEntry>,
unavailableLabel: string,
builtinLabels: Record<string, string>,
): ProjectedSidebarNode {
const presentation = projectNodePresentation(node, catalog, unavailableLabel, builtinLabels);
const shortcuts = (node.shortcuts ?? []).map((shortcut) =>
projectShortcut(shortcut, catalog, unavailableLabel),
);
return {
...node,
...presentation,
shortcuts,
};
}
export function projectSidebarLayout(
layout: SidebarLayout,
catalog: ShortcutCatalogEntry[],
options: ProjectionOptions = {},
): SidebarLayoutProjection {
const byTarget = new Map(catalog.map((entry) => [catalogEntryKey(entry), entry]));
const nodes = materializeSidebarPluginNodes(layout, catalog).nodes.map((node) =>
projectNode(node, byTarget, options.unavailableLabel ?? node.id, options.builtinLabels ?? {}),
);
return {
nodes,
protectedNodeIds: ["tasks", "inbox", "needs-you-inbox"],
};
}
export function hasShortcut(layout: SidebarLayout, target: SidebarShortcut["target"]): boolean {
return layout.nodes.some((node) =>
node.shortcuts?.some((shortcut) => targetKey(shortcut.target) === targetKey(target)),
);
}
export function canonicalSidebarNodeIds(): readonly string[] {
return DEFAULT_SIDEBAR_NODE_IDS;
}