Files
kandev/apps/web/components/settings/sidebar-layout-editor-node-item.tsx
T
2026-09-20 00:09:02 +01:00

384 lines
12 KiB
TypeScript

"use client";
import type { HTMLAttributes } from "react";
import { useDroppable } from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
IconChevronDown,
IconChevronUp,
IconGripVertical,
IconPlus,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Switch } from "@kandev/ui/switch";
import type { SidebarLayoutNode } from "@/lib/sidebar/layout-types";
import type { ProjectedSidebarNode, ProjectedShortcut } from "@/lib/sidebar/layout-projection";
import type { ShortcutCatalogEntry } from "@/lib/sidebar/shortcut-catalog";
import {
SidebarShortcutPicker,
ShortcutSectionMoveMenu,
} from "./sidebar-layout-editor-shortcut-picker";
import {
groupDropId,
nodeDragId,
shortcutDragId,
type SidebarLayoutDragData,
} from "./sidebar-layout-editor-dnd";
const SECTION_NAME_LABEL_KEY = "settings:sectionName";
const BUILTIN_LABEL_KEYS: Record<string, string> = {
home: "sidebar:home",
new_task: "sidebar:newTask",
automations: "common:automations",
canvases: "canvases:canvases",
integrations: "common:integrations",
};
function nodeTitle(
node: SidebarLayoutNode,
projected: ProjectedSidebarNode | undefined,
t: (key: string) => string,
): string {
if (projected?.available === false) return projected.label;
if (node.name) return node.name;
return node.destinationId && BUILTIN_LABEL_KEYS[node.destinationId]
? t(BUILTIN_LABEL_KEYS[node.destinationId])
: node.id;
}
function handleProps(
attributes: object,
listeners: object | undefined,
): HTMLAttributes<HTMLSpanElement> {
return { ...attributes, ...(listeners ?? {}) } as HTMLAttributes<HTMLSpanElement>;
}
type NodeEditorProps = {
node: SidebarLayoutNode;
projected?: ProjectedSidebarNode;
sections: SidebarLayoutNode[];
index: number;
total: number;
catalog: ShortcutCatalogEntry[];
loading: boolean;
catalogError: string | null;
canvasError?: string | null;
readOnly: boolean;
pickerOpen: boolean;
query: string;
onQueryChange: (value: string) => void;
onToggle: () => void;
onMove: (position: number) => void;
onRemove: () => void;
onSetNameDraft: (nodeId: string, name: string) => void;
onCommitName: (nodeId: string, name: string) => void;
onOpenPicker: () => void;
onClosePicker: () => void;
onAdd: (entry: ShortcutCatalogEntry) => void;
onRemoveShortcut: (shortcutId: string) => void;
onMoveShortcut: (shortcutId: string, position: number) => void;
onMoveShortcutToSection: (shortcutId: string, destinationNodeId: string) => void;
onFocus: () => void;
t: (key: string, options?: Record<string, unknown>) => string;
};
export function SidebarLayoutNodeEditor(props: NodeEditorProps) {
const { node } = props;
const sortable = useSortable({
id: nodeDragId(node.id),
disabled: props.readOnly,
data: { type: "node", nodeId: node.id } satisfies SidebarLayoutDragData,
});
const style = {
transform: CSS.Transform.toString(sortable.transform),
transition: sortable.transition,
};
return (
<div
ref={sortable.setNodeRef}
style={style}
className="min-w-0 rounded-md border p-3"
data-testid={`sidebar-layout-node-${node.id}`}
>
<SidebarLayoutNodeHeader
{...props}
dragHandleProps={handleProps(sortable.attributes, sortable.listeners)}
/>
<SidebarLayoutShortcutList {...props} />
</div>
);
}
function SidebarLayoutNodeHeader({
node,
projected,
index,
total,
onToggle,
onMove,
onRemove,
onSetNameDraft,
onCommitName,
dragHandleProps,
readOnly,
t,
}: NodeEditorProps & { dragHandleProps: HTMLAttributes<HTMLSpanElement> }) {
const isGroup = node.kind === "shortcuts";
return (
<div className="flex min-w-0 items-center gap-2">
<span
{...dragHandleProps}
className="flex h-7 w-7 shrink-0 cursor-grab items-center justify-center text-muted-foreground active:cursor-grabbing max-md:h-11 max-md:w-11 [@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:w-11"
data-testid={`sidebar-layout-drag-handle-${node.id}`}
aria-label={t("settings:dragToReorder")}
>
<IconGripVertical className="h-4 w-4" aria-hidden="true" />
</span>
{isGroup ? (
<Input
aria-label={t(SECTION_NAME_LABEL_KEY)}
value={node.name ?? ""}
onChange={(event) => onSetNameDraft(node.id, event.target.value)}
onBlur={() => onCommitName(node.id, node.name ?? "")}
disabled={readOnly}
className="min-h-7 min-w-0 flex-1 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11"
/>
) : (
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{nodeTitle(node, projected, t)}
</span>
)}
<Switch
checked={node.visible}
onCheckedChange={onToggle}
disabled={readOnly}
aria-label={t("settings:sidebarToggleVisibility")}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 max-md:size-11 [@media(pointer:coarse)]:size-11"
onClick={() => onMove(Math.max(0, index - 1))}
disabled={readOnly || index === 0}
aria-label={t("settings:moveUp")}
>
<IconChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 max-md:size-11 [@media(pointer:coarse)]:size-11"
onClick={() => onMove(Math.min(total - 1, index + 1))}
disabled={readOnly || index === total - 1}
aria-label={t("settings:moveDown")}
>
<IconChevronDown className="h-4 w-4" />
</Button>
{isGroup && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 max-md:size-11 [@media(pointer:coarse)]:size-11"
onClick={onRemove}
disabled={readOnly}
aria-label={t("settings:remove")}
>
<IconTrash className="h-4 w-4" />
</Button>
)}
</div>
);
}
function SidebarLayoutShortcutList({
node,
projected,
sections,
catalog,
loading,
catalogError,
canvasError,
readOnly,
pickerOpen,
query,
onQueryChange,
onOpenPicker,
onClosePicker,
onAdd,
onRemoveShortcut,
onMoveShortcut,
onMoveShortcutToSection,
onFocus,
t,
}: NodeEditorProps) {
const { setNodeRef } = useDroppable({
id: groupDropId(node.id),
disabled: readOnly,
data: { type: "group", nodeId: node.id } satisfies SidebarLayoutDragData,
});
if (node.kind !== "shortcuts") return null;
const shortcuts = projected?.shortcuts ?? [];
return (
<div ref={setNodeRef} className="mt-3 space-y-2 pl-6">
<SortableContext
items={shortcuts.map((shortcut) => shortcutDragId(node.id, shortcut.id))}
strategy={verticalListSortingStrategy}
>
{shortcuts.map((shortcut, shortcutIndex) => (
<SortableSidebarShortcut
key={shortcut.id}
nodeId={node.id}
shortcut={shortcut}
shortcutIndex={shortcutIndex}
shortcutCount={shortcuts.length}
sections={sections}
readOnly={readOnly}
t={t}
onMoveShortcut={onMoveShortcut}
onRemoveShortcut={onRemoveShortcut}
onMoveShortcutToSection={onMoveShortcutToSection}
/>
))}
</SortableContext>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
className="min-h-7 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11"
onClick={onOpenPicker}
disabled={readOnly}
>
<IconPlus className="mr-2 h-4 w-4" />
{t("settings:addShortcut")}
</Button>
<Button
type="button"
variant="ghost"
className="min-h-11 md:hidden"
onClick={onFocus}
disabled={readOnly}
>
{t("settings:editSection")}
</Button>
</div>
{pickerOpen && !readOnly && (
<SidebarShortcutPicker
catalog={catalog}
loading={loading}
error={catalogError}
canvasError={canvasError}
query={query}
onQueryChange={onQueryChange}
onAdd={onAdd}
onClose={onClosePicker}
existing={shortcuts.map((shortcut) => `${shortcut.target.kind}:${shortcut.target.id}`)}
t={t}
/>
)}
</div>
);
}
function SortableSidebarShortcut({
nodeId,
shortcut,
readOnly,
shortcutIndex,
shortcutCount,
sections,
t,
onMoveShortcut,
onRemoveShortcut,
onMoveShortcutToSection,
}: {
nodeId: string;
shortcut: ProjectedShortcut;
readOnly: boolean;
shortcutIndex: number;
shortcutCount: number;
sections: SidebarLayoutNode[];
t: (key: string, options?: Record<string, unknown>) => string;
onMoveShortcut: (shortcutId: string, position: number) => void;
onRemoveShortcut: (shortcutId: string) => void;
onMoveShortcutToSection: (shortcutId: string, destinationNodeId: string) => void;
}) {
const sortable = useSortable({
id: shortcutDragId(nodeId, shortcut.id),
disabled: readOnly,
data: { type: "shortcut", nodeId, shortcutId: shortcut.id } satisfies SidebarLayoutDragData,
});
const Icon = shortcut.icon;
return (
<div
ref={sortable.setNodeRef}
style={{
transform: CSS.Transform.toString(sortable.transform),
transition: sortable.transition,
}}
className="flex min-w-0 items-center gap-2 rounded-md bg-muted/30 p-2"
>
<span
{...handleProps(sortable.attributes, sortable.listeners)}
className="flex h-7 w-5 shrink-0 cursor-grab items-center justify-center text-muted-foreground active:cursor-grabbing max-md:h-11 max-md:w-11 [@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:w-11"
aria-label={t("settings:dragToReorder")}
>
<IconGripVertical className="h-3.5 w-3.5" aria-hidden="true" />
</span>
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-sm">{shortcut.label}</span>
{!shortcut.available && (
<span className="text-xs text-muted-foreground">{t("common:unavailable")}</span>
)}
{!readOnly && (
<ShortcutSectionMoveMenu
shortcut={shortcut}
currentNodeId={nodeId}
sections={sections}
onMove={(destinationNodeId) => onMoveShortcutToSection(shortcut.id, destinationNodeId)}
t={t}
/>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 max-md:size-11 [@media(pointer:coarse)]:size-11"
onClick={() => onMoveShortcut(shortcut.id, Math.max(0, shortcutIndex - 1))}
disabled={readOnly || shortcutIndex === 0}
aria-label={t("settings:moveUp")}
>
<IconChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 max-md:size-11 [@media(pointer:coarse)]:size-11"
onClick={() => onMoveShortcut(shortcut.id, Math.min(shortcutCount - 1, shortcutIndex + 1))}
disabled={readOnly || shortcutIndex === shortcutCount - 1}
aria-label={t("settings:moveDown")}
>
<IconChevronDown className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 max-md:size-11 [@media(pointer:coarse)]:size-11"
onClick={() => onRemoveShortcut(shortcut.id)}
disabled={readOnly}
aria-label={t("settings:remove")}
>
<IconX className="h-4 w-4" />
</Button>
</div>
);
}