mirror of
https://github.com/hansjone/netx.git
synced 2026-10-14 21:03:15 +08:00
Let users pick and order their workbench module tiles.
Per-user layout lives in the new app_user.prefs JSON column, read via /v1/auth/me and written via PUT /v1/auth/me/prefs/workbench. The workbench gets an in-place edit mode (gear -> drag to reorder, x to remove, tray to add back, reset/done). Reset stores empty prefs so registry changes still apply; ids of modules the user can't currently see are kept on save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
313706595d
commit
2307f19779
16 changed files with 906 additions and 23 deletions
|
|
@ -28,6 +28,8 @@ export type ModuleDefinition = {
|
|||
adminOnly?: boolean;
|
||||
/** Hide from workbench launcher; still used for module window registration. */
|
||||
workbenchHidden?: boolean;
|
||||
/** When true and not listed in the user's order, start in the "available" tray. */
|
||||
defaultHidden?: boolean;
|
||||
};
|
||||
|
||||
export const MODULES: readonly ModuleDefinition[] = [
|
||||
|
|
@ -159,6 +161,13 @@ export function modulesInSection(section: WorkbenchSection): ModuleDefinition[]
|
|||
return MODULES.filter((m) => m.section === section);
|
||||
}
|
||||
|
||||
/** Operations-area tiles users can show/hide/reorder (monitoring folded into ops). */
|
||||
export function customizableModules(): ModuleDefinition[] {
|
||||
return MODULES.filter(
|
||||
(m) => (m.section === "operations" || m.section === "monitoring") && !m.workbenchHidden,
|
||||
);
|
||||
}
|
||||
|
||||
export function isWorkbenchPath(pathname: string): boolean {
|
||||
return pathname === "/" || pathname === "/workbench";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,11 +21,15 @@ export type AuthUser = {
|
|||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type UserPrefs = Record<string, unknown>;
|
||||
|
||||
type AuthState = {
|
||||
ready: boolean;
|
||||
token: string | null;
|
||||
user: AuthUser | null;
|
||||
scopes: string[];
|
||||
prefs: UserPrefs;
|
||||
setPrefs: (prefs: UserPrefs) => void;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshMe: () => Promise<void>;
|
||||
|
|
@ -35,24 +39,33 @@ type AuthState = {
|
|||
|
||||
const AuthContext = createContext<AuthState | null>(null);
|
||||
|
||||
function asPrefs(value: unknown): UserPrefs {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as UserPrefs) : {};
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
// token is opaque for UI; cookie session means we only care about user presence.
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [scopes, setScopes] = useState<string[]>([]);
|
||||
const [prefs, setPrefs] = useState<UserPrefs>({});
|
||||
|
||||
const refreshMe = useCallback(async () => {
|
||||
clearAuthToken(); // drop any legacy localStorage tokens
|
||||
try {
|
||||
const data = await apiGet<{ user: AuthUser; scopes?: string[] }>("/v1/auth/me");
|
||||
const data = await apiGet<{ user: AuthUser; scopes?: string[]; prefs?: UserPrefs }>(
|
||||
"/v1/auth/me",
|
||||
);
|
||||
setToken("cookie");
|
||||
setUser(data.user);
|
||||
setScopes(data.scopes || data.user.scopes || []);
|
||||
setPrefs(asPrefs(data.prefs));
|
||||
} catch {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setScopes([]);
|
||||
setPrefs({});
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -64,7 +77,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
}, [refreshMe]);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const data = await apiPost<{ user: AuthUser }>("/v1/auth/login", {
|
||||
const data = await apiPost<{ user: AuthUser; prefs?: UserPrefs }>("/v1/auth/login", {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
|
@ -72,6 +85,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
setToken("cookie");
|
||||
setUser(data.user);
|
||||
setScopes(data.user.scopes || []);
|
||||
setPrefs(asPrefs(data.prefs));
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
|
|
@ -84,6 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
setToken(null);
|
||||
setUser(null);
|
||||
setScopes([]);
|
||||
setPrefs({});
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthState>(
|
||||
|
|
@ -92,13 +107,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
token,
|
||||
user,
|
||||
scopes,
|
||||
prefs,
|
||||
setPrefs,
|
||||
login,
|
||||
logout,
|
||||
refreshMe,
|
||||
isAdmin: user?.role === "admin",
|
||||
hasScope: (scope: string) => scopes.includes(scope) || user?.role === "admin",
|
||||
}),
|
||||
[ready, token, user, scopes, login, logout, refreshMe],
|
||||
[ready, token, user, scopes, prefs, login, logout, refreshMe],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useMemo, type ReactNode } from "react";
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@heroui/react";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { WorkbenchModules, type DisplaySection } from "./components/WorkbenchModules";
|
||||
import { WorkbenchModules } from "./components/WorkbenchModules";
|
||||
import { ConsoleIcon } from "./components/ConsoleIcon";
|
||||
import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../../config/modules";
|
||||
import { openOrFocusModule } from "../../utils/moduleWindows";
|
||||
|
|
@ -331,15 +331,11 @@ export function WorkbenchPage() {
|
|||
) as Record<WorkbenchSection, ModuleDefinition[]>;
|
||||
}, [isAdmin, hasScope]);
|
||||
|
||||
const displaySections = useMemo((): DisplaySection[] => {
|
||||
const ops = [...visibleBySection.monitoring, ...visibleBySection.operations];
|
||||
const system = visibleBySection.system;
|
||||
const sections: DisplaySection[] = [
|
||||
{ key: "operations", mods: ops },
|
||||
{ key: "system", mods: system },
|
||||
];
|
||||
return sections.filter((s) => s.mods.length > 0);
|
||||
}, [visibleBySection]);
|
||||
const opsCandidates = useMemo(
|
||||
() => [...visibleBySection.monitoring, ...visibleBySection.operations],
|
||||
[visibleBySection],
|
||||
);
|
||||
const systemMods = visibleBySection.system;
|
||||
|
||||
const railTasks = useMemo(() => {
|
||||
const items = opsQuery.data?.items ?? [];
|
||||
|
|
@ -501,7 +497,7 @@ export function WorkbenchPage() {
|
|||
<div className="wb-runtime__foot"><span>{t("workbench.kpi.uptime")}</span><strong>{metrics ? formatAgeSec(metrics.uptime_sec) : metricsPlaceholder}</strong></div>
|
||||
</section>
|
||||
<div className="wb-body">
|
||||
<WorkbenchModules sections={displaySections} />
|
||||
<WorkbenchModules opsCandidates={opsCandidates} systemMods={systemMods} />
|
||||
<aside className="wb-side" aria-label={t("workbench.sidePanel")}>
|
||||
<section className="wb-rail wb-panel wb-rail--tasks">
|
||||
<div className="wb-rail__head">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
type Props = { kind: "arrow" | "search" | "refresh" | "activity" | "check" | "close" };
|
||||
type Props = { kind: "arrow" | "search" | "refresh" | "activity" | "check" | "close" | "settings" };
|
||||
|
||||
export function ConsoleIcon({ kind }: Props) {
|
||||
return (
|
||||
|
|
@ -9,6 +9,12 @@ export function ConsoleIcon({ kind }: Props) {
|
|||
{kind === "activity" && <path d="M3 12h4l3-7 4 14 3-7h4" />}
|
||||
{kind === "check" && <><circle cx="12" cy="12" r="9" /><path d="m8 12 3 3 5-6" /></>}
|
||||
{kind === "close" && <path d="m6 6 12 12M6 18 18 6" />}
|
||||
{kind === "settings" && (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.05 5.05l1.56 1.56M17.39 17.39l1.56 1.56M5.05 18.95l1.56-1.56M17.39 6.61l1.56-1.56" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,258 @@
|
|||
import { useCallback, useEffect, useMemo, useState, type DragEvent } from "react";
|
||||
import { Button } from "@heroui/react";
|
||||
import type { ModuleDefinition } from "../../../config/modules";
|
||||
import { useI18n } from "../../../i18n";
|
||||
import { useToast } from "../../../hooks/useToast";
|
||||
import { openOrFocusModule } from "../../../utils/moduleWindows";
|
||||
import { useWorkbenchLayout } from "../useWorkbenchLayout";
|
||||
import { ConsoleIcon } from "./ConsoleIcon";
|
||||
import { WorkbenchCardIcon } from "./WorkbenchCardIcon";
|
||||
|
||||
export type DisplaySection = { key: "operations" | "system"; mods: ModuleDefinition[] };
|
||||
type Props = {
|
||||
/** Permission-filtered customizable modules (monitoring + operations). */
|
||||
opsCandidates: ModuleDefinition[];
|
||||
systemMods: ModuleDefinition[];
|
||||
};
|
||||
|
||||
function draftFromLayout(shown: ModuleDefinition[], available: ModuleDefinition[]) {
|
||||
return {
|
||||
order: shown.map((m) => m.moduleId),
|
||||
hidden: available.map((m) => m.moduleId),
|
||||
};
|
||||
}
|
||||
|
||||
/** Primary modules as a grid of tiles, system admin as a quiet link row. */
|
||||
export function WorkbenchModules({ sections }: { sections: DisplaySection[] }) {
|
||||
export function WorkbenchModules({ opsCandidates, systemMods }: Props) {
|
||||
const { t } = useI18n();
|
||||
const { showError } = useToast();
|
||||
const { shown, available, save, reset, layoutPrefs } = useWorkbenchLayout(opsCandidates);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draftOrder, setDraftOrder] = useState<string[]>([]);
|
||||
const [draftHidden, setDraftHidden] = useState<string[]>([]);
|
||||
const [dragId, setDragId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const byId = useMemo(() => new Map(opsCandidates.map((m) => [m.moduleId, m])), [opsCandidates]);
|
||||
|
||||
const enterEdit = useCallback(() => {
|
||||
const d = draftFromLayout(shown, available);
|
||||
setDraftOrder(d.order);
|
||||
setDraftHidden(d.hidden);
|
||||
setEditing(true);
|
||||
}, [shown, available]);
|
||||
|
||||
const exitEdit = useCallback(() => {
|
||||
setEditing(false);
|
||||
setDragId(null);
|
||||
setDraftOrder([]);
|
||||
setDraftHidden([]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
exitEdit();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [editing, exitEdit]);
|
||||
|
||||
const draftShown = useMemo(
|
||||
() => draftOrder.map((id) => byId.get(id)).filter((m): m is ModuleDefinition => Boolean(m)),
|
||||
[draftOrder, byId],
|
||||
);
|
||||
const draftAvailable = useMemo(
|
||||
() => draftHidden.map((id) => byId.get(id)).filter((m): m is ModuleDefinition => Boolean(m)),
|
||||
[draftHidden, byId],
|
||||
);
|
||||
|
||||
const displayOps = editing ? draftShown : shown;
|
||||
const canCustomize = opsCandidates.length > 0;
|
||||
|
||||
const removeTile = (moduleId: string) => {
|
||||
setDraftOrder((prev) => prev.filter((id) => id !== moduleId));
|
||||
setDraftHidden((prev) => (prev.includes(moduleId) ? prev : [...prev, moduleId]));
|
||||
};
|
||||
|
||||
const addTile = (moduleId: string) => {
|
||||
setDraftHidden((prev) => prev.filter((id) => id !== moduleId));
|
||||
setDraftOrder((prev) => (prev.includes(moduleId) ? prev : [...prev, moduleId]));
|
||||
};
|
||||
|
||||
const onDragStart = (moduleId: string) => (e: DragEvent) => {
|
||||
setDragId(moduleId);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", moduleId);
|
||||
};
|
||||
|
||||
const onDragOver = (overId: string) => (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (!dragId || dragId === overId) return;
|
||||
setDraftOrder((prev) => {
|
||||
const from = prev.indexOf(dragId);
|
||||
const to = prev.indexOf(overId);
|
||||
if (from < 0 || to < 0 || from === to) return prev;
|
||||
const next = [...prev];
|
||||
next.splice(from, 1);
|
||||
next.splice(to, 0, dragId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragId(null);
|
||||
};
|
||||
|
||||
const defaults = useMemo(
|
||||
() => ({
|
||||
order: opsCandidates.filter((m) => !m.defaultHidden).map((m) => m.moduleId),
|
||||
hidden: opsCandidates.filter((m) => m.defaultHidden).map((m) => m.moduleId),
|
||||
}),
|
||||
[opsCandidates],
|
||||
);
|
||||
|
||||
const onDone = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const sameAsDefault =
|
||||
draftOrder.join(",") === defaults.order.join(",") &&
|
||||
[...draftHidden].sort().join(",") === [...defaults.hidden].sort().join(",");
|
||||
if (sameAsDefault) {
|
||||
// Store nothing so later registry changes (new modules, reordering) still apply.
|
||||
await reset();
|
||||
} else {
|
||||
// Keep entries for modules this user can't see right now (e.g. scope temporarily
|
||||
// revoked) so their choice survives until the permission comes back.
|
||||
const keepOrder = (layoutPrefs?.order ?? []).filter((id) => !byId.has(id));
|
||||
const keepHidden = (layoutPrefs?.hidden ?? []).filter((id) => !byId.has(id));
|
||||
await save([...draftOrder, ...keepOrder], [...draftHidden, ...keepHidden]);
|
||||
}
|
||||
exitEdit();
|
||||
} catch {
|
||||
showError(t("workbench.customizeSaveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
setDraftOrder(defaults.order);
|
||||
setDraftHidden(defaults.hidden);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="wb-modules wb-panel" aria-label={t("workbench.modules")}>
|
||||
{sections.map((section) => (
|
||||
<div key={section.key} className={`wb-section wb-section--${section.key}`} role="group" aria-label={t(`workbench.${section.key}`)}>
|
||||
{section.mods.map((mod) => (
|
||||
<section
|
||||
className={`wb-modules wb-panel${editing ? " wb-modules--editing" : ""}`}
|
||||
aria-label={t("workbench.modules")}
|
||||
>
|
||||
{canCustomize ? (
|
||||
<div className="wb-modules__toolbar">
|
||||
{!editing ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="wb-modules__gear"
|
||||
aria-label={t("workbench.customize")}
|
||||
onPress={enterEdit}
|
||||
>
|
||||
<ConsoleIcon kind="settings" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canCustomize || displayOps.length > 0 ? (
|
||||
<div className="wb-section wb-section--operations" role="group" aria-label={t("workbench.operations")}>
|
||||
{displayOps.length === 0 && !editing ? (
|
||||
<div className="wb-modules__empty">
|
||||
<Button type="button" variant="ghost" className="wb-modules__empty-btn" onPress={enterEdit}>
|
||||
{t("workbench.customizeEmpty")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
displayOps.map((mod) => (
|
||||
<div
|
||||
key={mod.moduleId}
|
||||
className={`wb-tile-wrap${editing ? " wb-tile-wrap--edit" : ""}${dragId === mod.moduleId ? " is-dragging" : ""}`}
|
||||
draggable={editing}
|
||||
onDragStart={editing ? onDragStart(mod.moduleId) : undefined}
|
||||
onDragOver={editing ? onDragOver(mod.moduleId) : undefined}
|
||||
onDrop={editing ? onDrop : undefined}
|
||||
onDragEnd={() => setDragId(null)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`wb-tile wb-tile--${mod.iconTone}`}
|
||||
aria-label={
|
||||
editing
|
||||
? t(mod.labelKey)
|
||||
: `${t(mod.labelKey)} · ${t("workbench.openModule")}`
|
||||
}
|
||||
onPress={() => {
|
||||
if (editing) return;
|
||||
openOrFocusModule({ moduleId: mod.moduleId, path: mod.path });
|
||||
}}
|
||||
>
|
||||
<WorkbenchCardIcon tone={mod.iconTone} kind={mod.iconKind} />
|
||||
<span className="wb-tile__label">{t(mod.labelKey)}</span>
|
||||
</Button>
|
||||
{editing ? (
|
||||
<button
|
||||
type="button"
|
||||
className="wb-tile__remove"
|
||||
aria-label={`${t(mod.labelKey)} ×`}
|
||||
onClick={() => removeTile(mod.moduleId)}
|
||||
>
|
||||
<ConsoleIcon kind="close" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{editing && draftAvailable.length > 0 ? (
|
||||
<div className="wb-modules__tray" role="group" aria-label={t("workbench.customizeAvailable")}>
|
||||
<div className="wb-modules__tray-label">{t("workbench.customizeAvailable")}</div>
|
||||
<div className="wb-modules__tray-grid">
|
||||
{draftAvailable.map((mod) => (
|
||||
<Button
|
||||
key={mod.moduleId}
|
||||
variant="ghost"
|
||||
className={`wb-tile wb-tile--${mod.iconTone} wb-tile--available`}
|
||||
aria-label={t(mod.labelKey)}
|
||||
onPress={() => addTile(mod.moduleId)}
|
||||
>
|
||||
<WorkbenchCardIcon tone={mod.iconTone} kind={mod.iconKind} />
|
||||
<span className="wb-tile__label">{t(mod.labelKey)}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{editing ? (
|
||||
<div className="wb-modules__actions">
|
||||
<Button type="button" variant="ghost" size="sm" onPress={onReset} isDisabled={saving}>
|
||||
{t("workbench.customizeReset")}
|
||||
</Button>
|
||||
<Button type="button" variant="primary" size="sm" onPress={() => void onDone()} isDisabled={saving}>
|
||||
{t("workbench.customizeDone")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{systemMods.length > 0 ? (
|
||||
<div className="wb-section wb-section--system" role="group" aria-label={t("workbench.system")}>
|
||||
{systemMods.map((mod) => (
|
||||
<Button
|
||||
key={mod.moduleId}
|
||||
variant="ghost"
|
||||
|
|
@ -27,7 +265,7 @@ export function WorkbenchModules({ sections }: { sections: DisplaySection[] }) {
|
|||
</Button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
125
web/src/features/workbench/useWorkbenchLayout.ts
Normal file
125
web/src/features/workbench/useWorkbenchLayout.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import type { ModuleDefinition } from "../../config/modules";
|
||||
import { apiPut } from "../../services/api";
|
||||
import { useAuth, type UserPrefs } from "../auth/AuthContext";
|
||||
|
||||
export type WorkbenchLayoutPrefs = {
|
||||
order: string[];
|
||||
hidden: string[];
|
||||
};
|
||||
|
||||
const LS_PREFIX = "netx.wb.layout.";
|
||||
|
||||
function readLocalLayout(userId: string): WorkbenchLayoutPrefs | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${LS_PREFIX}${userId}`);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const order = (parsed as { order?: unknown }).order;
|
||||
const hidden = (parsed as { hidden?: unknown }).hidden;
|
||||
if (!Array.isArray(order) || !Array.isArray(hidden)) return null;
|
||||
return {
|
||||
order: order.filter((x): x is string => typeof x === "string"),
|
||||
hidden: hidden.filter((x): x is string => typeof x === "string"),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocalLayout(userId: string, layout: WorkbenchLayoutPrefs): void {
|
||||
try {
|
||||
localStorage.setItem(`${LS_PREFIX}${userId}`, JSON.stringify(layout));
|
||||
} catch {
|
||||
// ignore quota / private mode
|
||||
}
|
||||
}
|
||||
|
||||
function asWorkbenchPrefs(value: unknown): WorkbenchLayoutPrefs | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const order = (value as { order?: unknown }).order;
|
||||
const hidden = (value as { hidden?: unknown }).hidden;
|
||||
if (!Array.isArray(order) && !Array.isArray(hidden)) return null;
|
||||
return {
|
||||
order: Array.isArray(order) ? order.filter((x): x is string => typeof x === "string") : [],
|
||||
hidden: Array.isArray(hidden) ? hidden.filter((x): x is string => typeof x === "string") : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Pure layout resolver — permission-filtered `all` in registry order. */
|
||||
export function resolveLayout(
|
||||
all: ModuleDefinition[],
|
||||
prefs: WorkbenchLayoutPrefs | null | undefined,
|
||||
): { shown: ModuleDefinition[]; available: ModuleDefinition[] } {
|
||||
const byId = new Map(all.map((m) => [m.moduleId, m]));
|
||||
const prefOrder = prefs?.order ?? [];
|
||||
const hiddenSet = new Set((prefs?.hidden ?? []).filter((id) => byId.has(id)));
|
||||
const orderIds = prefOrder.filter((id) => byId.has(id));
|
||||
const inOrder = new Set(orderIds);
|
||||
|
||||
const ranked: ModuleDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const id of orderIds) {
|
||||
const mod = byId.get(id);
|
||||
if (!mod || seen.has(id)) continue;
|
||||
ranked.push(mod);
|
||||
seen.add(id);
|
||||
}
|
||||
for (const mod of all) {
|
||||
if (seen.has(mod.moduleId)) continue;
|
||||
ranked.push(mod);
|
||||
seen.add(mod.moduleId);
|
||||
}
|
||||
|
||||
const shown: ModuleDefinition[] = [];
|
||||
const available: ModuleDefinition[] = [];
|
||||
for (const mod of ranked) {
|
||||
const hide =
|
||||
hiddenSet.has(mod.moduleId) || (Boolean(mod.defaultHidden) && !inOrder.has(mod.moduleId));
|
||||
if (hide) available.push(mod);
|
||||
else shown.push(mod);
|
||||
}
|
||||
return { shown, available };
|
||||
}
|
||||
|
||||
export function useWorkbenchLayout(all: ModuleDefinition[]) {
|
||||
const { user, prefs, setPrefs, ready } = useAuth();
|
||||
const userId = user?.id ?? "";
|
||||
|
||||
const layoutPrefs = useMemo(() => {
|
||||
// After auth is ready, server prefs win (missing workbench => default).
|
||||
// Before ready, fall back to localStorage to avoid a layout flash.
|
||||
if (ready) return asWorkbenchPrefs(prefs?.workbench);
|
||||
return userId ? readLocalLayout(userId) : null;
|
||||
}, [ready, prefs, userId]);
|
||||
|
||||
const { shown, available } = useMemo(
|
||||
() => resolveLayout(all, layoutPrefs),
|
||||
[all, layoutPrefs],
|
||||
);
|
||||
|
||||
const persist = useCallback(
|
||||
async (next: WorkbenchLayoutPrefs) => {
|
||||
const res = await apiPut<{ prefs: UserPrefs }>("/v1/auth/me/prefs/workbench", next);
|
||||
const merged =
|
||||
res.prefs && typeof res.prefs === "object" ? res.prefs : { ...prefs, workbench: next };
|
||||
setPrefs(merged);
|
||||
if (userId) writeLocalLayout(userId, next);
|
||||
},
|
||||
[prefs, setPrefs, userId],
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
async (order: string[], hidden: string[]) => {
|
||||
await persist({ order, hidden });
|
||||
},
|
||||
[persist],
|
||||
);
|
||||
|
||||
const reset = useCallback(async () => {
|
||||
await persist({ order: [], hidden: [] });
|
||||
}, [persist]);
|
||||
|
||||
return { shown, available, save, reset, layoutPrefs };
|
||||
}
|
||||
|
|
@ -149,6 +149,12 @@ const workbench = {
|
|||
apiKeys: "API Keys",
|
||||
sessions: "Login sessions",
|
||||
},
|
||||
customize: "Customize modules",
|
||||
customizeDone: "Done",
|
||||
customizeReset: "Reset",
|
||||
customizeAvailable: "Available",
|
||||
customizeEmpty: "Add modules",
|
||||
customizeSaveFailed: "Save failed",
|
||||
};
|
||||
|
||||
export default workbench;
|
||||
|
|
|
|||
|
|
@ -149,6 +149,12 @@ const workbench = {
|
|||
apiKeys: "API Key",
|
||||
sessions: "登录会话",
|
||||
},
|
||||
customize: "自定义功能区",
|
||||
customizeDone: "完成",
|
||||
customizeReset: "恢复默认",
|
||||
customizeAvailable: "可添加",
|
||||
customizeEmpty: "添加功能",
|
||||
customizeSaveFailed: "保存失败",
|
||||
};
|
||||
|
||||
export default workbench;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,53 @@
|
|||
|
||||
/* Modules: one row of primary tiles, then a quiet row of admin links. */
|
||||
.wb-modules { padding: var(--wb-pad); display: flex; flex-direction: column; gap: 14px; }
|
||||
.wb-modules__toolbar { display: flex; justify-content: flex-end; margin: -4px 0 -6px; min-height: 28px; }
|
||||
.app--shell .app-main .wb-modules__gear {
|
||||
width: 28px; height: 28px; min-height: 0; padding: 0; margin: 0;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 0; border-radius: var(--nx-radius-sm); background: transparent;
|
||||
color: var(--nx-text-faint); box-shadow: none;
|
||||
}
|
||||
.app--shell .app-main .wb-modules__gear:hover { background: rgb(255 255 255 / .06); color: var(--nx-text); }
|
||||
.wb-modules__gear svg { width: 16px; height: 16px; }
|
||||
.wb-modules__empty {
|
||||
grid-column: 1 / -1; min-height: var(--wb-tile-h);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.app--shell .app-main .wb-modules__empty-btn {
|
||||
height: auto; min-height: 40px; padding: 8px 16px; border: 1px dashed var(--nx-line);
|
||||
border-radius: 12px; background: transparent; color: var(--nx-text-muted); box-shadow: none;
|
||||
}
|
||||
.app--shell .app-main .wb-modules__empty-btn:hover { background: rgb(255 255 255 / .04); color: var(--nx-text); border-color: var(--nx-line-strong); }
|
||||
.wb-tile-wrap { position: relative; min-width: 0; }
|
||||
.wb-modules--editing .wb-tile-wrap--edit .wb-tile { border-color: var(--nx-line); cursor: grab; }
|
||||
.wb-modules--editing .wb-tile-wrap--edit.is-dragging .wb-tile { opacity: .55; cursor: grabbing; }
|
||||
.wb-modules--editing .wb-tile-wrap--edit .wb-tile:hover { transform: none; }
|
||||
.app--shell .app-main .wb-tile__remove {
|
||||
position: absolute; top: 4px; right: 4px; z-index: 2;
|
||||
width: 22px; height: 22px; padding: 0; margin: 0;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 1px solid var(--nx-line); border-radius: 999px;
|
||||
background: rgb(15 23 42 / .72); color: var(--nx-text-muted); cursor: pointer;
|
||||
}
|
||||
.app--shell .app-main .wb-tile__remove:hover { color: var(--nx-text); border-color: var(--nx-line-strong); }
|
||||
.wb-tile__remove svg { width: 12px; height: 12px; }
|
||||
.wb-modules__tray { display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
|
||||
.wb-modules__tray-label { font-size: 12px; font-weight: 500; color: var(--nx-text-muted); }
|
||||
.wb-modules__tray-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(max(88px, 16%), 1fr));
|
||||
grid-auto-rows: calc(var(--wb-tile-h) * .85); gap: 10px;
|
||||
}
|
||||
.app--shell .app-main .wb-tile--available {
|
||||
opacity: .55; border-style: dashed; border-color: var(--nx-line); min-height: 0;
|
||||
background: rgb(255 255 255 / .02);
|
||||
}
|
||||
.app--shell .app-main .wb-tile--available:hover {
|
||||
opacity: .85; transform: none;
|
||||
border-color: var(--nx-line-strong);
|
||||
background: rgb(255 255 255 / .05);
|
||||
}
|
||||
.wb-modules__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 4px; }
|
||||
/* Fixed-size tiles fill rows in order; the panel's spare height is room for new modules. */
|
||||
.wb-section--operations { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(88px, 16%), 1fr)); grid-auto-rows: var(--wb-tile-h); gap: 12px; }
|
||||
.app--shell .app-main .wb-tile {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue