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

193 lines
6.1 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import type { ShortcutCatalogEntry } from "@/lib/sidebar/shortcut-catalog";
import {
defaultSidebarLayout,
fromApiSidebarLayout,
type SidebarLayout,
} from "@/lib/sidebar/layout-types";
import { materializeSidebarPluginNodes } from "@/lib/sidebar/layout-projection";
import {
setShortcutSectionNameDraft,
type SidebarLayoutOperationError,
} from "@/lib/sidebar/layout-operations";
import type { DraftOperation } from "./sidebar-layout-editor-types";
export function layoutValue(layout: SidebarLayout): string {
return JSON.stringify(layout.nodes);
}
export function validationCopy(code: string, t: (key: string) => string): string {
switch (code) {
case "invalid_group_name":
return t("settings:sidebarSectionNameError");
case "duplicate_target":
return t("settings:sidebarDuplicateShortcutError");
case "limit_reached":
return t("settings:sidebarLayoutLimitError");
default:
return t("settings:sidebarLayoutInvalidError");
}
}
export function operationCode(error: unknown): string {
return error instanceof Error && "code" in error
? String((error as SidebarLayoutOperationError).code)
: "invalid_layout";
}
function sameScopeLayout(
layouts: Record<string, Parameters<typeof fromApiSidebarLayout>[0]> | undefined,
workspaceId: string,
): SidebarLayout {
return fromApiSidebarLayout(layouts?.[workspaceId]);
}
type ScopedDraft = { saved: SidebarLayout; draft: SidebarLayout };
export function useSidebarDraft(catalog: ShortcutCatalogEntry[]) {
const workspaceId = useAppStore((state) => state.workspaces.activeId);
const layouts = useAppStore((state) => state.userSettings.sidebarLayoutsByWorkspace);
const setUserSettings = useAppStore((state) => state.setUserSettings);
const store = useAppStoreApi();
const scopeKey = workspaceId ?? "__none__";
const fallback = useMemo(
() =>
materializeSidebarPluginNodes(
workspaceId ? sameScopeLayout(layouts, workspaceId) : defaultSidebarLayout(),
catalog,
),
[catalog, layouts, workspaceId],
);
const [scopes, setScopes] = useState<Record<string, ScopedDraft>>({});
const stored = scopes[scopeKey];
const saved = stored?.saved ?? fallback;
const draft = stored?.draft ?? fallback;
const dirty = layoutValue(draft) !== layoutValue(saved);
const authoritativeRevision = workspaceId ? layouts?.[workspaceId]?.revision : undefined;
const updateScope = useCallback(
(next: ScopedDraft) => setScopes((current) => ({ ...current, [scopeKey]: next })),
[scopeKey],
);
const setDraft = useCallback(
(next: SidebarLayout | ((current: SidebarLayout) => SidebarLayout)) => {
setScopes((currentScopes) => {
const current = currentScopes[scopeKey] ?? { saved, draft };
const nextDraft = typeof next === "function" ? next(current.draft) : next;
return {
...currentScopes,
[scopeKey]: {
saved: current.saved,
draft: materializeSidebarPluginNodes(nextDraft, catalog),
},
};
});
},
[catalog, draft, saved, scopeKey],
);
const acknowledge = useCallback(
(ackWorkspaceId: string, nextSaved: SidebarLayout, applyDraft: boolean) => {
setScopes((currentScopes) => {
const current = currentScopes[ackWorkspaceId] ?? { saved: nextSaved, draft: nextSaved };
return {
...currentScopes,
[ackWorkspaceId]: {
saved: materializeSidebarPluginNodes(nextSaved, catalog),
draft: applyDraft
? materializeSidebarPluginNodes(nextSaved, catalog)
: materializeSidebarPluginNodes(current.draft, catalog),
},
};
});
},
[catalog],
);
useEffect(() => {
if (!workspaceId || !stored || stored.saved.revision === authoritativeRevision) return;
if (layoutValue(stored.draft) !== layoutValue(stored.saved)) return;
const next = materializeSidebarPluginNodes(sameScopeLayout(layouts, workspaceId), catalog);
updateScope({ saved: next, draft: next });
}, [authoritativeRevision, catalog, layouts, stored, updateScope, workspaceId]);
return {
workspaceId,
scopeKey,
saved,
draft,
dirty,
setDraft,
acknowledge,
setUserSettings,
store,
};
}
export function useSidebarLayoutActions({
draft,
workspaceId,
setDraft,
}: {
draft: SidebarLayout;
workspaceId: string | null;
setDraft: (next: SidebarLayout | ((current: SidebarLayout) => SidebarLayout)) => void;
}) {
const [operationError, setOperationError] = useState<string | null>(null);
const draftRef = useRef(draft);
const workspaceRef = useRef(workspaceId);
const generationsRef = useRef(new Map<string, number>());
const requestGenerationsRef = useRef(new Map<string, number>());
const scopeKey = workspaceId ?? "__none__";
draftRef.current = draft;
workspaceRef.current = workspaceId;
const applyDraftOperation = useCallback(
(operation: DraftOperation): boolean => {
if (draftRef.current.unsupportedVersion) return false;
try {
const next = operation(draftRef.current);
draftRef.current = next;
generationsRef.current.set(scopeKey, (generationsRef.current.get(scopeKey) ?? 0) + 1);
setDraft(next);
setOperationError(null);
return true;
} catch (error) {
setOperationError(operationCode(error));
return false;
}
},
[scopeKey, setDraft],
);
const setNameDraft = useCallback(
(nodeId: string, name: string) => {
applyDraftOperation((current) => setShortcutSectionNameDraft(current, nodeId, name));
},
[applyDraftOperation],
);
const commitName = useCallback(
(rename: DraftOperation) => {
applyDraftOperation(rename);
},
[applyDraftOperation],
);
return {
operationError,
setOperationError,
draftRef,
workspaceRef,
generationsRef,
requestGenerationsRef,
applyDraftOperation,
setNameDraft,
commitName,
};
}