mirror of
https://github.com/hansjone/netx.git
synced 2026-10-16 05:20:44 +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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue