Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
25.4 kB
"use client";
import { useState, useEffect, useCallback } from "react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Card, Toggle } from "@/shared/components";
import { cn } from "@/shared/utils/cn";
import { useTranslations } from "next-intl";
import {
HIDDEN_SIDEBAR_GROUP_LABELS_SETTING_KEY,
HIDEABLE_SIDEBAR_GROUP_IDS,
normalizeHiddenSidebarGroupLabels,
type HideableSidebarGroupId,
} from "@/shared/constants/sidebarGroupVisibility";
import {
HIDDEN_SIDEBAR_ITEMS_SETTING_KEY,
SIDEBAR_SECTION_ORDER_KEY,
SIDEBAR_ITEM_ORDER_KEY,
SIDEBAR_PRESET_KEY,
SIDEBAR_SETTINGS_UPDATED_EVENT,
SIDEBAR_SECTIONS,
SIDEBAR_PRESETS,
applySectionOrder,
applyItemOrder,
normalizeHiddenSidebarItems,
type HideableSidebarItemId,
type SidebarSectionId,
type SidebarItemOrder,
type SidebarPresetId,
type SidebarSectionDefinition,
type SidebarSectionChild,
type SidebarItemDefinition,
type SidebarItemGroup,
} from "@/shared/constants/sidebarVisibility";
// ─── Sortable section row ──────────────────────────────────────────────────────
interface SortableSectionProps {
section: SidebarSectionDefinition & { title: string };
hiddenSet: Set<HideableSidebarItemId>;
hiddenGroupLabelsSet: Set<HideableSidebarGroupId>;
itemOrder: string[];
onToggleItem: (id: HideableSidebarItemId) => void;
onToggleGroupLabel: (id: HideableSidebarGroupId) => void;
onItemReorder: (sectionId: SidebarSectionId, newOrder: string[]) => void;
getLabel: (key: string, fallback: string) => string;
}
function SortableSection({
section,
hiddenSet,
hiddenGroupLabelsSet,
itemOrder,
onToggleItem,
onToggleGroupLabel,
onItemReorder,
getLabel,
}: SortableSectionProps) {
const tSidebar = useTranslations("sidebar");
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: section.id,
});
const style = { transform: CSS.Transform.toString(transform), transition };
const [expanded, setExpanded] = useState(true);
const allChildren = section.children as SidebarSectionChild[];
const getChildId = (c: SidebarSectionChild) =>
"type" in c && c.type === "group" ? c.id : (c as SidebarItemDefinition).id;
const orderedChildren = applyItemOrder(allChildren, itemOrder);
const childIds = orderedChildren.map(getChildId);
const sensors = useSensors(useSensor(PointerSensor));
const handleItemDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIdx = childIds.indexOf(active.id as string);
const newIdx = childIds.indexOf(over.id as string);
if (oldIdx === -1 || newIdx === -1) return;
onItemReorder(section.id as SidebarSectionId, arrayMove(childIds, oldIdx, newIdx));
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"rounded-lg border border-border bg-surface/40 transition-shadow",
isDragging && "shadow-lg opacity-80"
)}
>
{/* Section header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border/70">
<button
{...listeners}
{...attributes}
className="text-text-muted/40 hover:text-text-muted/80 cursor-grab active:cursor-grabbing touch-none shrink-0"
title={tSidebar("dragReorderSection")}
aria-label={tSidebar("dragReorderSection")}
>
<span className="material-symbols-outlined text-[18px]">drag_indicator</span>
</button>
<button
onClick={() => setExpanded((p) => !p)}
className="flex-1 flex items-center gap-2 text-left"
>
<span className="text-xs font-semibold uppercase tracking-wider text-text-muted/70">
{section.title}
</span>
<span
className={cn(
"material-symbols-outlined text-[14px] text-text-muted/40 transition-transform ml-auto",
expanded && "rotate-180"
)}
>
expand_more
</span>
</button>
</div>
{/* Section children with inner DnD */}
{expanded && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleItemDragEnd}
>
<SortableContext items={childIds} strategy={verticalListSortingStrategy}>
<div className="divide-y divide-border/70">
{orderedChildren.map((child) => {
if ("type" in child && child.type === "group") {
const group = child as SidebarItemGroup;
return (
<SortableChildRow key={group.id} id={group.id}>
<GroupRow
group={group}
hiddenSet={hiddenSet}
hiddenGroupLabelsSet={hiddenGroupLabelsSet}
onToggleItem={onToggleItem}
onToggleGroupLabel={onToggleGroupLabel}
getLabel={getLabel}
/>
</SortableChildRow>
);
}
const item = child as SidebarItemDefinition;
return (
<SortableChildRow key={item.id} id={item.id}>
<ItemRow
item={item}
hiddenSet={hiddenSet}
onToggleItem={onToggleItem}
getLabel={getLabel}
/>
</SortableChildRow>
);
})}
</div>
</SortableContext>
</DndContext>
)}
</div>
);
}
// ─── Sortable child row wrapper ────────────────────────────────────────────────
function SortableChildRow({ id, children }: { id: string; children: React.ReactNode }) {
const tSidebar = useTranslations("sidebar");
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
const style = { transform: CSS.Transform.toString(transform), transition };
return (
<div
ref={setNodeRef}
style={style}
className={cn("flex items-start gap-2", isDragging && "opacity-60")}
>
<button
{...listeners}
{...attributes}
className="mt-3.5 ml-4 text-text-muted/30 hover:text-text-muted/70 cursor-grab active:cursor-grabbing touch-none shrink-0"
title={tSidebar("dragReorderItem")}
aria-label={tSidebar("dragReorderItem")}
>
<span className="material-symbols-outlined text-[14px]">drag_indicator</span>
</button>
<div className="flex-1 min-w-0">{children}</div>
</div>
);
}
// ─── Item row ─────────────────────────────────────────────────────────────────
interface ItemRowProps {
item: SidebarItemDefinition;
hiddenSet: Set<HideableSidebarItemId>;
onToggleItem: (id: HideableSidebarItemId) => void;
getLabel: (key: string, fallback: string) => string;
}
// Items that must always remain visible (safety guard)
const PROTECTED_ITEM_IDS = new Set(["settings-sidebar"]);
function ItemRow({ item, hiddenSet, onToggleItem, getLabel }: ItemRowProps) {
const tSidebar = useTranslations("sidebar");
const isProtected = PROTECTED_ITEM_IDS.has(item.id);
return (
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="flex items-center gap-2 min-w-0">
<span className="material-symbols-outlined text-[16px] text-text-muted/50 shrink-0">
{item.icon}
</span>
<p className="font-medium truncate">{getLabel(item.i18nKey, item.id)}</p>
</div>
{isProtected ? (
<span
className="material-symbols-outlined text-[16px] text-text-muted/40"
title={tSidebar("cannotHide")}
aria-label={tSidebar("alwaysVisible")}
>
lock
</span>
) : (
<Toggle checked={!hiddenSet.has(item.id)} onChange={() => onToggleItem(item.id)} />
)}
</div>
);
}
// ─── Group row (items inside group, no sub-DnD) ───────────────────────────────
interface GroupRowProps {
group: SidebarItemGroup;
hiddenSet: Set<HideableSidebarItemId>;
hiddenGroupLabelsSet: Set<HideableSidebarGroupId>;
onToggleItem: (id: HideableSidebarItemId) => void;
onToggleGroupLabel: (id: HideableSidebarGroupId) => void;
getLabel: (key: string, fallback: string) => string;
}
function GroupRow({
group,
hiddenSet,
hiddenGroupLabelsSet,
onToggleItem,
onToggleGroupLabel,
getLabel,
}: GroupRowProps) {
const [open, setOpen] = useState(true);
const groupId = group.id as HideableSidebarGroupId;
const canToggleSeparator = HIDEABLE_SIDEBAR_GROUP_IDS.includes(groupId);
const separatorVisible = !hiddenGroupLabelsSet.has(groupId);
const separatorLabel = getLabel("groupSeparatorLabel", "Separator");
return (
<div className="w-full">
<div className="flex items-center gap-2 px-4 py-2.5 hover:bg-black/5 dark:hover:bg-white/5 transition-colors">
<button
onClick={() => setOpen((p) => !p)}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
>
<span
className={cn(
"material-symbols-outlined text-[12px] text-text-muted/40 transition-transform",
open && "rotate-90"
)}
>
chevron_right
</span>
<span className="truncate text-[10px] font-semibold uppercase tracking-widest text-text-muted/50">
{getLabel(group.titleKey, group.titleFallback)}
</span>
</button>
<span className="text-xs text-text-muted/40">
{group.items.filter((i) => !hiddenSet.has(i.id)).length}/{group.items.length}
</span>
{canToggleSeparator && (
<div className="flex items-center gap-2 border-l border-border/60 pl-3">
<span className="text-[10px] font-medium text-text-muted/50">{separatorLabel}</span>
<Toggle
size="sm"
checked={separatorVisible}
onChange={() => onToggleGroupLabel(groupId)}
/>
</div>
)}
</div>
{open && (
<div className="divide-y divide-border/50 pl-2">
{group.items.map((item) => (
<div key={item.id} className="flex items-center justify-between gap-4 px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<span className="material-symbols-outlined text-[14px] text-text-muted/40 shrink-0">
{item.icon}
</span>
<p className="text-sm font-medium truncate">{getLabel(item.i18nKey, item.id)}</p>
</div>
<Toggle
size="sm"
checked={!hiddenSet.has(item.id)}
onChange={() => onToggleItem(item.id)}
/>
</div>
))}
</div>
)}
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function SidebarTab() {
const t = useTranslations("settings");
const tSidebar = useTranslations("sidebar");
const getLabel = useCallback(
(key: string, fallback: string) =>
typeof tSidebar.has === "function" && tSidebar.has(key) ? tSidebar(key) : fallback,
[tSidebar]
);
const getSettingsLabel = useCallback(
(key: string, fallback: string) =>
typeof t.has === "function" && t.has(key) ? t(key) : fallback,
[t]
);
const [loading, setLoading] = useState(true);
const [hiddenSidebarItems, setHiddenSidebarItems] = useState<HideableSidebarItemId[]>([]);
const [hiddenSidebarGroupLabels, setHiddenSidebarGroupLabels] = useState<
HideableSidebarGroupId[]
>([]);
const [sectionOrder, setSectionOrder] = useState<SidebarSectionId[]>([]);
const [itemOrder, setItemOrder] = useState<SidebarItemOrder>({});
const [activePreset, setActivePreset] = useState<SidebarPresetId | null>(null);
const [confirmPreset, setConfirmPreset] = useState<SidebarPresetId | null>(null);
const [showDebug, setShowDebug] = useState(false);
useEffect(() => {
fetch("/api/settings")
.then((r) => r.json())
.then((data) => {
setHiddenSidebarItems(
normalizeHiddenSidebarItems(data?.[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY])
);
setHiddenSidebarGroupLabels(
normalizeHiddenSidebarGroupLabels(data?.[HIDDEN_SIDEBAR_GROUP_LABELS_SETTING_KEY])
);
setSectionOrder(
Array.isArray(data?.[SIDEBAR_SECTION_ORDER_KEY]) ? data[SIDEBAR_SECTION_ORDER_KEY] : []
);
setItemOrder(
data?.[SIDEBAR_ITEM_ORDER_KEY] && typeof data[SIDEBAR_ITEM_ORDER_KEY] === "object"
? data[SIDEBAR_ITEM_ORDER_KEY]
: {}
);
setActivePreset(data?.[SIDEBAR_PRESET_KEY] ?? null);
setShowDebug(data?.debugMode === true);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const patch = async (updates: Record<string, unknown>) => {
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (res.ok) {
window.dispatchEvent(new CustomEvent(SIDEBAR_SETTINGS_UPDATED_EVENT, { detail: updates }));
} else {
console.error("Failed to update sidebar settings:", res.statusText);
}
} catch (err) {
console.error("Error updating sidebar settings:", err);
}
};
const toggleItem = (id: HideableSidebarItemId) => {
// Protected items can never be hidden
if (PROTECTED_ITEM_IDS.has(id)) return;
const next = hiddenSidebarItems.includes(id)
? hiddenSidebarItems.filter((x) => x !== id)
: [...hiddenSidebarItems, id];
setHiddenSidebarItems(next);
// Any manual change → custom mode
setActivePreset(null);
patch({ [HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]: next, [SIDEBAR_PRESET_KEY]: null });
};
const hiddenSet = new Set(hiddenSidebarItems);
const hiddenGroupLabelsSet = new Set(hiddenSidebarGroupLabels);
const toggleGroupLabel = (id: HideableSidebarGroupId) => {
const next = hiddenSidebarGroupLabels.includes(id)
? hiddenSidebarGroupLabels.filter((x) => x !== id)
: [...hiddenSidebarGroupLabels, id];
setHiddenSidebarGroupLabels(next);
setActivePreset(null);
patch({ [HIDDEN_SIDEBAR_GROUP_LABELS_SETTING_KEY]: next, [SIDEBAR_PRESET_KEY]: null });
};
const visibleSections = SIDEBAR_SECTIONS.filter((s) => s.visibility !== "debug" || showDebug).map(
(s) => ({ ...s, title: getLabel(s.titleKey, s.titleFallback) })
);
const orderedSections = applySectionOrder(visibleSections, sectionOrder);
const sectionIds = orderedSections.map((s) => s.id);
const sensors = useSensors(useSensor(PointerSensor));
const handleSectionDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIdx = sectionIds.indexOf(active.id as SidebarSectionId);
const newIdx = sectionIds.indexOf(over.id as SidebarSectionId);
if (oldIdx === -1 || newIdx === -1) return;
const newOrder = arrayMove(sectionIds, oldIdx, newIdx) as SidebarSectionId[];
setSectionOrder(newOrder);
setActivePreset(null);
patch({ [SIDEBAR_SECTION_ORDER_KEY]: newOrder, [SIDEBAR_PRESET_KEY]: null });
};
const handleItemReorder = (sectionId: SidebarSectionId, newOrder: string[]) => {
const next = { ...itemOrder, [sectionId]: newOrder };
setItemOrder(next);
setActivePreset(null);
patch({ [SIDEBAR_ITEM_ORDER_KEY]: next, [SIDEBAR_PRESET_KEY]: null });
};
const applyPreset = (presetId: SidebarPresetId) => {
const preset = SIDEBAR_PRESETS.find((p) => p.id === presetId);
if (!preset) return;
// Ensure protected items are never hidden, even if a preset includes them
const safeHidden = preset.hiddenItems.filter((id) => !PROTECTED_ITEM_IDS.has(id));
setHiddenSidebarItems(safeHidden);
setHiddenSidebarGroupLabels([]);
setSectionOrder([]);
setItemOrder({});
setActivePreset(presetId);
setConfirmPreset(null);
patch({
[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]: safeHidden,
[HIDDEN_SIDEBAR_GROUP_LABELS_SETTING_KEY]: [],
[SIDEBAR_SECTION_ORDER_KEY]: [],
[SIDEBAR_ITEM_ORDER_KEY]: {},
[SIDEBAR_PRESET_KEY]: presetId,
});
};
const resetToDefault = () => applyPreset("all");
const presetLabels: Record<SidebarPresetId, string> = {
all: getSettingsLabel("presetAll", "All"),
minimal: getSettingsLabel("presetMinimal", "Minimal"),
developer: getSettingsLabel("presetDeveloper", "Developer"),
admin: getSettingsLabel("presetAdmin", "Admin"),
};
const presetDescriptions: Record<SidebarPresetId, string> = {
all: getSettingsLabel("presetAllDesc", "Show everything"),
minimal: getSettingsLabel("presetMinimalDesc", "Core pages only"),
developer: getSettingsLabel("presetDeveloperDesc", "Dev & proxy tools"),
admin: getSettingsLabel("presetAdminDesc", "Monitoring & audit"),
};
return (
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
view_sidebar
</span>
</div>
<div>
<h3 className="text-lg font-semibold">
{getSettingsLabel("settingsSidebarTitle", "Sidebar Customization")}
</h3>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"settingsSidebarDesc",
"Control which items appear in the sidebar and their order"
)}
</p>
</div>
</div>
<div className="flex flex-col gap-6">
{/* Presets */}
<div>
<div className="mb-3">
<p className="font-medium">{getSettingsLabel("sidebarPresets", "Presets")}</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"sidebarPresetsDesc",
"Start from a role-based layout. Any change after applying a preset switches to Custom."
)}
</p>
</div>
{/* Active preset badge */}
<div className="mb-3 flex items-center gap-2 text-sm">
<span className="text-text-muted">
{getSettingsLabel("activePresetLabel", "Active:")}
</span>
<span
className={cn(
"px-2 py-0.5 rounded-full text-xs font-medium",
activePreset
? "bg-primary/10 text-primary"
: "bg-surface border border-border text-text-muted"
)}
>
{activePreset
? presetLabels[activePreset]
: getSettingsLabel("presetCustom", "Custom")}
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{SIDEBAR_PRESETS.map((preset) => {
const isActive = activePreset === preset.id;
return (
<button
key={preset.id}
disabled={loading}
onClick={() => {
if (activePreset === preset.id) return;
if (
activePreset !== null ||
hiddenSidebarItems.length > 0 ||
hiddenSidebarGroupLabels.length > 0 ||
sectionOrder.length > 0
) {
setConfirmPreset(preset.id);
} else {
applyPreset(preset.id);
}
}}
className={cn(
"flex flex-col items-center gap-1.5 p-3 rounded-lg border transition-colors disabled:opacity-60",
isActive
? "border-primary bg-primary/10 text-primary"
: "border-border hover:border-primary/40 bg-surface/40 text-text-main"
)}
>
<span
className="material-symbols-outlined text-[22px]"
style={isActive ? { fontVariationSettings: "'FILL' 1" } : {}}
aria-hidden="true"
>
{preset.icon}
</span>
<span className="text-sm font-semibold">{presetLabels[preset.id]}</span>
<span
className={cn(
"text-[10px] text-center",
isActive ? "text-primary/70" : "text-text-muted"
)}
>
{presetDescriptions[preset.id]}
</span>
</button>
);
})}
</div>
{/* Confirm preset dialog */}
{confirmPreset && (
<div className="mt-3 p-3 rounded-lg border border-amber-500/30 bg-amber-500/5 flex items-center gap-3">
<span className="material-symbols-outlined text-amber-500 text-[18px] shrink-0">
warning
</span>
<p className="text-sm flex-1">
{getSettingsLabel(
"presetConfirmWarning",
`Applying "${presetLabels[confirmPreset]}" will replace your current visibility and order settings.`
)}
</p>
<div className="flex gap-2 shrink-0">
<button
onClick={() => setConfirmPreset(null)}
className="px-3 py-1.5 text-sm rounded-md border border-border hover:bg-surface/80 transition-colors"
>
{getSettingsLabel("cancelLabel", "Cancel")}
</button>
<button
onClick={() => applyPreset(confirmPreset)}
className="px-3 py-1.5 text-sm rounded-md bg-primary text-white hover:bg-primary/90 transition-colors"
>
{getSettingsLabel("applyLabel", "Apply")}
</button>
</div>
</div>
)}
</div>
{/* Visibility & order */}
<div>
<div className="mb-3 flex items-start justify-between gap-4">
<div>
<p className="font-medium">
{getSettingsLabel("sidebarOrder", "Visibility & Order")}
</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"sidebarOrderDesc",
"Toggle items on/off and drag to reorder sections and their entries."
)}
</p>
</div>
<button
onClick={resetToDefault}
disabled={loading}
className="shrink-0 text-sm text-text-muted hover:text-text-main border border-border rounded-md px-3 py-1.5 hover:bg-surface/80 transition-colors disabled:opacity-50"
>
{getSettingsLabel("resetDefault", "Reset to default")}
</button>
</div>
<div className="flex flex-col gap-3">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleSectionDragEnd}
>
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
{orderedSections.map((section) => (
<SortableSection
key={section.id}
section={section}
hiddenSet={hiddenSet}
hiddenGroupLabelsSet={hiddenGroupLabelsSet}
itemOrder={itemOrder[section.id as SidebarSectionId] ?? []}
onToggleItem={toggleItem}
onToggleGroupLabel={toggleGroupLabel}
onItemReorder={handleItemReorder}
getLabel={getLabel}
/>
))}
</SortableContext>
</DndContext>
</div>
<p className="mt-3 text-xs text-text-muted">
{getSettingsLabel(
"sidebarVisibilityHint",
"A sidebar section hides automatically when all of its entries are hidden"
)}
</p>
</div>
</div>
</Card>
);
}