@open-mercato/ui 0.5.1-develop.2975.ccbadc8198 → 0.5.1-develop.2996.ce62fd491c
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/AppShell.js +274 -697
- package/dist/backend/AppShell.js.map +3 -3
- package/dist/backend/CrudForm.js +1 -1
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/crud/CollapsibleZoneLayout.js +23 -3
- package/dist/backend/crud/CollapsibleZoneLayout.js.map +2 -2
- package/dist/backend/section-page/SectionNav.js +10 -8
- package/dist/backend/section-page/SectionNav.js.map +2 -2
- package/dist/backend/section-page/SectionPage.js +2 -2
- package/dist/backend/section-page/SectionPage.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +1303 -0
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +7 -0
- package/dist/backend/sidebar/customization-helpers.js +150 -0
- package/dist/backend/sidebar/customization-helpers.js.map +7 -0
- package/dist/primitives/switch.js +1 -2
- package/dist/primitives/switch.js.map +2 -2
- package/jest.setup.ts +13 -0
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +245 -732
- package/src/backend/CrudForm.tsx +1 -1
- package/src/backend/__tests__/AppShell.test.tsx +1 -1
- package/src/backend/__tests__/CollapsibleZoneLayout.test.tsx +101 -0
- package/src/backend/__tests__/CrudForm.navigation.test.tsx +42 -0
- package/src/backend/__tests__/SidebarCustomizationEditor.test.tsx +200 -0
- package/src/backend/crud/CollapsibleZoneLayout.tsx +28 -3
- package/src/backend/section-page/SectionNav.tsx +14 -10
- package/src/backend/section-page/SectionPage.tsx +15 -10
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +1562 -0
- package/src/backend/sidebar/customization-helpers.ts +203 -0
- package/src/primitives/switch.tsx +1 -2
|
@@ -0,0 +1,1303 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { ChevronUp, ChevronDown, GripVertical, RotateCcw, Trash2, Plus, Search, AlertTriangle } from "lucide-react";
|
|
5
|
+
import { DndContext, closestCenter, PointerSensor, KeyboardSensor, useSensor, useSensors } from "@dnd-kit/core";
|
|
6
|
+
import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
|
|
7
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
8
|
+
import Image from "next/image";
|
|
9
|
+
import { resolveInjectedIcon } from "../injection/resolveInjectedIcon.js";
|
|
10
|
+
import { useT, useLocale } from "@open-mercato/shared/lib/i18n/context";
|
|
11
|
+
import { Button } from "../../primitives/button.js";
|
|
12
|
+
import { IconButton } from "../../primitives/icon-button.js";
|
|
13
|
+
import { Input } from "../../primitives/input.js";
|
|
14
|
+
import { Switch } from "../../primitives/switch.js";
|
|
15
|
+
import { Card, CardContent, CardHeader, CardTitle } from "../../primitives/card.js";
|
|
16
|
+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../../primitives/dialog.js";
|
|
17
|
+
import { Tag } from "../../primitives/tag.js";
|
|
18
|
+
import {
|
|
19
|
+
Select,
|
|
20
|
+
SelectContent,
|
|
21
|
+
SelectItem,
|
|
22
|
+
SelectTrigger,
|
|
23
|
+
SelectValue
|
|
24
|
+
} from "../../primitives/select.js";
|
|
25
|
+
import { apiCall } from "../utils/apiCall.js";
|
|
26
|
+
import { flash } from "../FlashMessages.js";
|
|
27
|
+
import { Page, PageBody } from "../Page.js";
|
|
28
|
+
import { useBackendChrome } from "../BackendChromeProvider.js";
|
|
29
|
+
import { useConfirmDialog } from "../confirm-dialog/index.js";
|
|
30
|
+
import { useGuardedMutation } from "../injection/useGuardedMutation.js";
|
|
31
|
+
import {
|
|
32
|
+
applyCustomizationDraft,
|
|
33
|
+
applyItemOrder,
|
|
34
|
+
cloneSidebarGroups,
|
|
35
|
+
collectSidebarDefaults,
|
|
36
|
+
filterMainSidebarGroups,
|
|
37
|
+
mergeGroupOrder,
|
|
38
|
+
resolveGroupKey,
|
|
39
|
+
resolveItemKey
|
|
40
|
+
} from "./customization-helpers.js";
|
|
41
|
+
const VARIANTS_API_DEFAULT = "/api/auth/sidebar/variants";
|
|
42
|
+
const PREFERENCES_API_DEFAULT = "/api/auth/sidebar/preferences";
|
|
43
|
+
const REFRESH_SIDEBAR_EVENT = "om:refresh-sidebar";
|
|
44
|
+
const NEW_VARIANT_KEY = "__new__";
|
|
45
|
+
function formatVariantApiError(call, t) {
|
|
46
|
+
const detail = call.result?.error;
|
|
47
|
+
if (typeof detail === "string" && detail.length > 0 && call.status >= 400 && call.status < 500) {
|
|
48
|
+
return detail;
|
|
49
|
+
}
|
|
50
|
+
if (typeof detail === "string" && detail.length > 0) {
|
|
51
|
+
return `${t("appShell.sidebarCustomizationSaveError")} (${call.status}: ${detail})`;
|
|
52
|
+
}
|
|
53
|
+
return `${t("appShell.sidebarCustomizationSaveError")} (${call.status})`;
|
|
54
|
+
}
|
|
55
|
+
function findItemByKey(items, targetKey) {
|
|
56
|
+
for (const item of items) {
|
|
57
|
+
if (resolveItemKey(item) === targetKey) return item;
|
|
58
|
+
if (item.children && item.children.length > 0) {
|
|
59
|
+
const found = findItemByKey(item.children, targetKey);
|
|
60
|
+
if (found) return found;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function collectDescendantKeys(item) {
|
|
66
|
+
const out = [];
|
|
67
|
+
const walk = (node) => {
|
|
68
|
+
if (!node.children) return;
|
|
69
|
+
for (const child of node.children) {
|
|
70
|
+
out.push(resolveItemKey(child));
|
|
71
|
+
walk(child);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
walk(item);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
function parseDraftFromSettings(rawSettings, baseSnapshot) {
|
|
78
|
+
const responseOrder = Array.isArray(rawSettings?.groupOrder) ? rawSettings.groupOrder.map((id) => typeof id === "string" ? id.trim() : "").filter((id) => id.length > 0) : [];
|
|
79
|
+
const responseGroupLabels = {};
|
|
80
|
+
if (rawSettings?.groupLabels && typeof rawSettings.groupLabels === "object") {
|
|
81
|
+
for (const [key, value] of Object.entries(rawSettings.groupLabels)) {
|
|
82
|
+
if (typeof value !== "string") continue;
|
|
83
|
+
const trimmedKey = key.trim();
|
|
84
|
+
if (!trimmedKey) continue;
|
|
85
|
+
responseGroupLabels[trimmedKey] = value;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const responseItemLabels = {};
|
|
89
|
+
if (rawSettings?.itemLabels && typeof rawSettings.itemLabels === "object") {
|
|
90
|
+
for (const [key, value] of Object.entries(rawSettings.itemLabels)) {
|
|
91
|
+
if (typeof value !== "string") continue;
|
|
92
|
+
const trimmedKey = key.trim();
|
|
93
|
+
if (!trimmedKey) continue;
|
|
94
|
+
responseItemLabels[trimmedKey] = value;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const responseHiddenItems = Array.isArray(rawSettings?.hiddenItems) ? rawSettings.hiddenItems.map((itemId) => typeof itemId === "string" ? itemId.trim() : "").filter((itemId) => itemId.length > 0) : [];
|
|
98
|
+
const responseItemOrder = {};
|
|
99
|
+
if (rawSettings?.itemOrder && typeof rawSettings.itemOrder === "object") {
|
|
100
|
+
for (const [groupKey, list] of Object.entries(rawSettings.itemOrder)) {
|
|
101
|
+
if (!Array.isArray(list)) continue;
|
|
102
|
+
const trimmedGroup = groupKey.trim();
|
|
103
|
+
if (!trimmedGroup) continue;
|
|
104
|
+
const seen = /* @__PURE__ */ new Set();
|
|
105
|
+
const values = [];
|
|
106
|
+
for (const itemKey of list) {
|
|
107
|
+
if (typeof itemKey !== "string") continue;
|
|
108
|
+
const trimmedItem = itemKey.trim();
|
|
109
|
+
if (!trimmedItem || seen.has(trimmedItem)) continue;
|
|
110
|
+
seen.add(trimmedItem);
|
|
111
|
+
values.push(trimmedItem);
|
|
112
|
+
}
|
|
113
|
+
if (values.length > 0) responseItemOrder[trimmedGroup] = values;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const currentIds = baseSnapshot.map((group) => resolveGroupKey(group));
|
|
117
|
+
const order = mergeGroupOrder(responseOrder, currentIds);
|
|
118
|
+
const { itemDefaults } = collectSidebarDefaults(baseSnapshot);
|
|
119
|
+
const hiddenItemIds = {};
|
|
120
|
+
for (const itemId of responseHiddenItems) {
|
|
121
|
+
if (!itemDefaults.has(itemId)) continue;
|
|
122
|
+
hiddenItemIds[itemId] = true;
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
order,
|
|
126
|
+
groupLabels: responseGroupLabels,
|
|
127
|
+
itemLabels: responseItemLabels,
|
|
128
|
+
hiddenItemIds,
|
|
129
|
+
itemOrder: responseItemOrder
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function emptyDraftFor(baseSnapshot) {
|
|
133
|
+
return {
|
|
134
|
+
order: baseSnapshot.map((group) => resolveGroupKey(group)),
|
|
135
|
+
groupLabels: {},
|
|
136
|
+
itemLabels: {},
|
|
137
|
+
hiddenItemIds: {},
|
|
138
|
+
itemOrder: {}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function SidebarCustomizationEditor({
|
|
142
|
+
onSaved,
|
|
143
|
+
onCanceled,
|
|
144
|
+
variantsApiPath = VARIANTS_API_DEFAULT,
|
|
145
|
+
preferencesApiPath = PREFERENCES_API_DEFAULT,
|
|
146
|
+
groups: groupsProp
|
|
147
|
+
}) {
|
|
148
|
+
const t = useT();
|
|
149
|
+
const locale = useLocale();
|
|
150
|
+
const localeLabel = (locale || "").toUpperCase();
|
|
151
|
+
const { payload: chromePayload, isLoading: chromeIsLoading } = useBackendChrome();
|
|
152
|
+
const groupsFromChrome = chromePayload?.groups;
|
|
153
|
+
const sourceGroups = groupsProp ?? groupsFromChrome ?? [];
|
|
154
|
+
const { confirm: confirmDialog, ConfirmDialogElement } = useConfirmDialog();
|
|
155
|
+
const [loading, setLoading] = React.useState(true);
|
|
156
|
+
const [saving, setSaving] = React.useState(false);
|
|
157
|
+
const [deleting, setDeleting] = React.useState(false);
|
|
158
|
+
const [error, setError] = React.useState(null);
|
|
159
|
+
const [variants, setVariants] = React.useState([]);
|
|
160
|
+
const [selectedVariantId, setSelectedVariantId] = React.useState(null);
|
|
161
|
+
const [variantName, setVariantName] = React.useState("");
|
|
162
|
+
const [draft, setDraft] = React.useState(null);
|
|
163
|
+
const [previewGroups, setPreviewGroups] = React.useState([]);
|
|
164
|
+
const [dirty, setDirty] = React.useState(false);
|
|
165
|
+
const [availableRoleTargets, setAvailableRoleTargets] = React.useState([]);
|
|
166
|
+
const [selectedRoleIds, setSelectedRoleIds] = React.useState([]);
|
|
167
|
+
const [canApplyToRoles, setCanApplyToRoles] = React.useState(false);
|
|
168
|
+
const [addDialogOpen, setAddDialogOpen] = React.useState(false);
|
|
169
|
+
const [addDialogName, setAddDialogName] = React.useState("");
|
|
170
|
+
const baseSnapshotRef = React.useRef(null);
|
|
171
|
+
const hasInitializedRef = React.useRef(false);
|
|
172
|
+
const { runMutation, retryLastMutation } = useGuardedMutation({
|
|
173
|
+
contextId: "sidebar-customization",
|
|
174
|
+
blockedMessage: t("appShell.sidebarCustomizationSaveError")
|
|
175
|
+
});
|
|
176
|
+
const buildMutationContext = React.useCallback(
|
|
177
|
+
(operation, variantId) => ({
|
|
178
|
+
formId: "sidebar-customization",
|
|
179
|
+
variantId: variantId ?? null,
|
|
180
|
+
operation,
|
|
181
|
+
retryLastMutation
|
|
182
|
+
}),
|
|
183
|
+
[retryLastMutation]
|
|
184
|
+
);
|
|
185
|
+
const isNewVariant = selectedVariantId === null;
|
|
186
|
+
const selectedVariant = React.useMemo(
|
|
187
|
+
() => selectedVariantId ? variants.find((v) => v.id === selectedVariantId) ?? null : null,
|
|
188
|
+
[selectedVariantId, variants]
|
|
189
|
+
);
|
|
190
|
+
const updateDraft = React.useCallback((updater) => {
|
|
191
|
+
setDraft((prev) => {
|
|
192
|
+
if (!prev) return prev;
|
|
193
|
+
const next = updater(prev);
|
|
194
|
+
if (baseSnapshotRef.current) {
|
|
195
|
+
setPreviewGroups(applyCustomizationDraft(baseSnapshotRef.current, next));
|
|
196
|
+
}
|
|
197
|
+
return next;
|
|
198
|
+
});
|
|
199
|
+
setDirty(true);
|
|
200
|
+
}, []);
|
|
201
|
+
const buildBaseSnapshot = React.useCallback(() => {
|
|
202
|
+
return filterMainSidebarGroups(cloneSidebarGroups(sourceGroups));
|
|
203
|
+
}, [sourceGroups]);
|
|
204
|
+
const loadVariantsList = React.useCallback(async () => {
|
|
205
|
+
const url = `${variantsApiPath}?_=${Date.now()}`;
|
|
206
|
+
const call = await apiCall(url, { cache: "no-store" });
|
|
207
|
+
if (!call.ok) {
|
|
208
|
+
throw new Error("list-failed");
|
|
209
|
+
}
|
|
210
|
+
return call.result?.variants ?? [];
|
|
211
|
+
}, [variantsApiPath]);
|
|
212
|
+
const loadRolesPayload = React.useCallback(async () => {
|
|
213
|
+
const call = await apiCall(preferencesApiPath);
|
|
214
|
+
if (!call.ok) {
|
|
215
|
+
return { canApplyToRoles: false, roles: [] };
|
|
216
|
+
}
|
|
217
|
+
const data = call.result ?? null;
|
|
218
|
+
const can = data?.canApplyToRoles === true;
|
|
219
|
+
const roles = Array.isArray(data?.roles) ? data.roles.filter((r) => typeof r?.id === "string" && typeof r?.name === "string").map((r) => ({ id: r.id, name: r.name, hasPreference: r.hasPreference === true })) : [];
|
|
220
|
+
return { canApplyToRoles: can, roles };
|
|
221
|
+
}, [preferencesApiPath]);
|
|
222
|
+
const selectVariantInternal = React.useCallback((variant, list) => {
|
|
223
|
+
const baseSnapshot = baseSnapshotRef.current ?? buildBaseSnapshot();
|
|
224
|
+
baseSnapshotRef.current = baseSnapshot;
|
|
225
|
+
if (variant) {
|
|
226
|
+
const initialDraft = parseDraftFromSettings(variant.settings, baseSnapshot);
|
|
227
|
+
setSelectedVariantId(variant.id);
|
|
228
|
+
setVariantName(variant.name);
|
|
229
|
+
setDraft(initialDraft);
|
|
230
|
+
setPreviewGroups(applyCustomizationDraft(baseSnapshot, initialDraft));
|
|
231
|
+
} else {
|
|
232
|
+
const empty = emptyDraftFor(baseSnapshot);
|
|
233
|
+
setSelectedVariantId(null);
|
|
234
|
+
const usedNumbers = /* @__PURE__ */ new Set();
|
|
235
|
+
for (const v of list) {
|
|
236
|
+
if (v.name === "My preferences") usedNumbers.add(1);
|
|
237
|
+
const match = v.name.match(/^My preferences\s+(\d+)$/);
|
|
238
|
+
if (match) usedNumbers.add(Number.parseInt(match[1], 10));
|
|
239
|
+
}
|
|
240
|
+
let next = 1;
|
|
241
|
+
while (usedNumbers.has(next)) next += 1;
|
|
242
|
+
const suggestion = next === 1 ? "My preferences" : `My preferences ${next}`;
|
|
243
|
+
setVariantName(suggestion);
|
|
244
|
+
setDraft(empty);
|
|
245
|
+
setPreviewGroups(applyCustomizationDraft(baseSnapshot, empty));
|
|
246
|
+
}
|
|
247
|
+
setDirty(false);
|
|
248
|
+
}, [buildBaseSnapshot]);
|
|
249
|
+
React.useEffect(() => {
|
|
250
|
+
if (hasInitializedRef.current) return;
|
|
251
|
+
if (sourceGroups.length === 0) return;
|
|
252
|
+
hasInitializedRef.current = true;
|
|
253
|
+
async function init() {
|
|
254
|
+
setLoading(true);
|
|
255
|
+
setError(null);
|
|
256
|
+
try {
|
|
257
|
+
const [list, rolesPayload] = await Promise.all([
|
|
258
|
+
loadVariantsList(),
|
|
259
|
+
loadRolesPayload()
|
|
260
|
+
]);
|
|
261
|
+
setVariants(list);
|
|
262
|
+
setCanApplyToRoles(rolesPayload.canApplyToRoles);
|
|
263
|
+
setAvailableRoleTargets(rolesPayload.roles);
|
|
264
|
+
const active = list.find((v) => v.isActive);
|
|
265
|
+
const initial = active ?? list[0] ?? null;
|
|
266
|
+
selectVariantInternal(initial, list);
|
|
267
|
+
setSelectedRoleIds([]);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
console.error("Failed to load sidebar variants", err);
|
|
270
|
+
setError(t("appShell.sidebarCustomizationLoadError"));
|
|
271
|
+
} finally {
|
|
272
|
+
setLoading(false);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
void init();
|
|
276
|
+
}, [sourceGroups.length]);
|
|
277
|
+
const toggleRoleSelection = React.useCallback((roleId) => {
|
|
278
|
+
setSelectedRoleIds((prev) => prev.includes(roleId) ? prev.filter((id) => id !== roleId) : [...prev, roleId]);
|
|
279
|
+
setDirty(true);
|
|
280
|
+
}, []);
|
|
281
|
+
const createNewVariant = React.useCallback(async (proposedName) => {
|
|
282
|
+
if (saving || deleting) return false;
|
|
283
|
+
if (dirty && selectedVariantId !== null) {
|
|
284
|
+
const proceed = await confirmDialog({
|
|
285
|
+
title: t("appShell.sidebarCustomizationSwitchConfirmTitle", "Discard unsaved changes?"),
|
|
286
|
+
text: t("appShell.sidebarCustomizationSwitchConfirmText", "You have unsaved changes for the current variant. Switching will discard them."),
|
|
287
|
+
confirmText: t("appShell.sidebarCustomizationSwitchConfirmYes", "Discard and switch"),
|
|
288
|
+
cancelText: t("common.cancel", "Cancel"),
|
|
289
|
+
variant: "destructive"
|
|
290
|
+
});
|
|
291
|
+
if (!proceed) return false;
|
|
292
|
+
}
|
|
293
|
+
setSaving(true);
|
|
294
|
+
setError(null);
|
|
295
|
+
try {
|
|
296
|
+
const baseSnapshot = baseSnapshotRef.current ?? buildBaseSnapshot();
|
|
297
|
+
baseSnapshotRef.current = baseSnapshot;
|
|
298
|
+
const groupOrder = baseSnapshot.map((g) => resolveGroupKey(g));
|
|
299
|
+
const trimmed = (proposedName ?? "").trim();
|
|
300
|
+
const call = await runMutation({
|
|
301
|
+
operation: () => apiCall(variantsApiPath, {
|
|
302
|
+
method: "POST",
|
|
303
|
+
headers: { "content-type": "application/json" },
|
|
304
|
+
body: JSON.stringify({
|
|
305
|
+
// If name is omitted, server auto-names ("My preferences", "My preferences 2", …).
|
|
306
|
+
name: trimmed.length > 0 ? trimmed : void 0,
|
|
307
|
+
settings: { groupOrder, groupLabels: {}, itemLabels: {}, hiddenItems: [], itemOrder: {} },
|
|
308
|
+
isActive: true
|
|
309
|
+
})
|
|
310
|
+
}),
|
|
311
|
+
context: buildMutationContext("createVariant"),
|
|
312
|
+
mutationPayload: { name: trimmed.length > 0 ? trimmed : null }
|
|
313
|
+
});
|
|
314
|
+
if (!call.ok) {
|
|
315
|
+
setError(formatVariantApiError(call, t));
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
const created = call.result?.variant ?? null;
|
|
319
|
+
let nextList;
|
|
320
|
+
try {
|
|
321
|
+
nextList = await loadVariantsList();
|
|
322
|
+
} catch {
|
|
323
|
+
nextList = variants;
|
|
324
|
+
}
|
|
325
|
+
if (created && !nextList.some((v) => v.id === created.id)) {
|
|
326
|
+
nextList = [...nextList, created];
|
|
327
|
+
}
|
|
328
|
+
setVariants(nextList);
|
|
329
|
+
if (created) {
|
|
330
|
+
const fresh = nextList.find((v) => v.id === created.id) ?? created;
|
|
331
|
+
selectVariantInternal(fresh, nextList);
|
|
332
|
+
}
|
|
333
|
+
flash(t("appShell.sidebarCustomizationVariantCreated", "Variant created."), "success");
|
|
334
|
+
return true;
|
|
335
|
+
} catch (err) {
|
|
336
|
+
console.error("Failed to create sidebar variant", err);
|
|
337
|
+
setError(t("appShell.sidebarCustomizationSaveError"));
|
|
338
|
+
return false;
|
|
339
|
+
} finally {
|
|
340
|
+
setSaving(false);
|
|
341
|
+
}
|
|
342
|
+
}, [saving, deleting, dirty, selectedVariantId, confirmDialog, t, buildBaseSnapshot, variantsApiPath, loadVariantsList, selectVariantInternal, variants, runMutation, buildMutationContext]);
|
|
343
|
+
const handleVariantSwitch = React.useCallback(async (key) => {
|
|
344
|
+
if (saving || deleting) return;
|
|
345
|
+
if (key === selectedVariantId) return;
|
|
346
|
+
if (key === NEW_VARIANT_KEY && isNewVariant) return;
|
|
347
|
+
if (dirty) {
|
|
348
|
+
const proceed = await confirmDialog({
|
|
349
|
+
title: t("appShell.sidebarCustomizationSwitchConfirmTitle", "Discard unsaved changes?"),
|
|
350
|
+
text: t("appShell.sidebarCustomizationSwitchConfirmText", "You have unsaved changes for the current variant. Switching will discard them."),
|
|
351
|
+
confirmText: t("appShell.sidebarCustomizationSwitchConfirmYes", "Discard and switch"),
|
|
352
|
+
cancelText: t("common.cancel", "Cancel"),
|
|
353
|
+
variant: "destructive"
|
|
354
|
+
});
|
|
355
|
+
if (!proceed) return;
|
|
356
|
+
}
|
|
357
|
+
if (key === NEW_VARIANT_KEY) {
|
|
358
|
+
selectVariantInternal(null, variants);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const next = variants.find((v) => v.id === key) ?? null;
|
|
362
|
+
selectVariantInternal(next, variants);
|
|
363
|
+
}, [saving, deleting, selectedVariantId, isNewVariant, dirty, confirmDialog, t, variants, selectVariantInternal]);
|
|
364
|
+
const moveGroup = React.useCallback((groupId, offset) => {
|
|
365
|
+
updateDraft((draft2) => {
|
|
366
|
+
const order = [...draft2.order];
|
|
367
|
+
const index = order.indexOf(groupId);
|
|
368
|
+
if (index === -1) return draft2;
|
|
369
|
+
const nextIndex = Math.max(0, Math.min(order.length - 1, index + offset));
|
|
370
|
+
if (nextIndex === index) return draft2;
|
|
371
|
+
order.splice(index, 1);
|
|
372
|
+
order.splice(nextIndex, 0, groupId);
|
|
373
|
+
return { ...draft2, order };
|
|
374
|
+
});
|
|
375
|
+
}, [updateDraft]);
|
|
376
|
+
const dndSensors = useSensors(
|
|
377
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
378
|
+
useSensor(KeyboardSensor)
|
|
379
|
+
);
|
|
380
|
+
const handleItemDragEnd = React.useCallback((groupKey, currentItemKeys) => (event) => {
|
|
381
|
+
const { active, over } = event;
|
|
382
|
+
if (!over || active.id === over.id) return;
|
|
383
|
+
const fromId = String(active.id);
|
|
384
|
+
const toId = String(over.id);
|
|
385
|
+
updateDraft((draft2) => {
|
|
386
|
+
const baseOrder = draft2.itemOrder?.[groupKey]?.length ? [...draft2.itemOrder[groupKey]] : [...currentItemKeys];
|
|
387
|
+
const fromIndex = baseOrder.indexOf(fromId);
|
|
388
|
+
const toIndex = baseOrder.indexOf(toId);
|
|
389
|
+
if (fromIndex === -1 || toIndex === -1) return draft2;
|
|
390
|
+
const nextOrder = arrayMove(baseOrder, fromIndex, toIndex);
|
|
391
|
+
return {
|
|
392
|
+
...draft2,
|
|
393
|
+
itemOrder: { ...draft2.itemOrder ?? {}, [groupKey]: nextOrder }
|
|
394
|
+
};
|
|
395
|
+
});
|
|
396
|
+
}, [updateDraft]);
|
|
397
|
+
const setGroupLabel = React.useCallback((groupId, value) => {
|
|
398
|
+
updateDraft((draft2) => {
|
|
399
|
+
const next = { ...draft2.groupLabels };
|
|
400
|
+
if (value.trim().length === 0) delete next[groupId];
|
|
401
|
+
else next[groupId] = value;
|
|
402
|
+
return { ...draft2, groupLabels: next };
|
|
403
|
+
});
|
|
404
|
+
}, [updateDraft]);
|
|
405
|
+
const setItemLabel = React.useCallback((itemId, value) => {
|
|
406
|
+
updateDraft((draft2) => {
|
|
407
|
+
const next = { ...draft2.itemLabels };
|
|
408
|
+
if (value.trim().length === 0) delete next[itemId];
|
|
409
|
+
else next[itemId] = value;
|
|
410
|
+
return { ...draft2, itemLabels: next };
|
|
411
|
+
});
|
|
412
|
+
}, [updateDraft]);
|
|
413
|
+
const setItemHidden = React.useCallback((itemId, hidden) => {
|
|
414
|
+
updateDraft((draft2) => {
|
|
415
|
+
const next = { ...draft2.hiddenItemIds };
|
|
416
|
+
const apply = (id) => {
|
|
417
|
+
if (hidden) next[id] = true;
|
|
418
|
+
else delete next[id];
|
|
419
|
+
};
|
|
420
|
+
apply(itemId);
|
|
421
|
+
if (baseSnapshotRef.current) {
|
|
422
|
+
for (const group of baseSnapshotRef.current) {
|
|
423
|
+
const target = findItemByKey(group.items, itemId);
|
|
424
|
+
if (!target) continue;
|
|
425
|
+
for (const descendantKey of collectDescendantKeys(target)) apply(descendantKey);
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return { ...draft2, hiddenItemIds: next };
|
|
430
|
+
});
|
|
431
|
+
}, [updateDraft]);
|
|
432
|
+
const reset = React.useCallback(() => {
|
|
433
|
+
if (!baseSnapshotRef.current) return;
|
|
434
|
+
if (selectedVariant) {
|
|
435
|
+
const initialDraft = parseDraftFromSettings(selectedVariant.settings, baseSnapshotRef.current);
|
|
436
|
+
setDraft(initialDraft);
|
|
437
|
+
setPreviewGroups(applyCustomizationDraft(baseSnapshotRef.current, initialDraft));
|
|
438
|
+
} else {
|
|
439
|
+
const empty = emptyDraftFor(baseSnapshotRef.current);
|
|
440
|
+
setDraft(empty);
|
|
441
|
+
setPreviewGroups(applyCustomizationDraft(baseSnapshotRef.current, empty));
|
|
442
|
+
}
|
|
443
|
+
setDirty(false);
|
|
444
|
+
}, [selectedVariant]);
|
|
445
|
+
const cancel = React.useCallback(() => {
|
|
446
|
+
onCanceled?.();
|
|
447
|
+
}, [onCanceled]);
|
|
448
|
+
const submitAddDialog = React.useCallback(async () => {
|
|
449
|
+
const ok = await createNewVariant(addDialogName);
|
|
450
|
+
if (ok) {
|
|
451
|
+
setAddDialogOpen(false);
|
|
452
|
+
setAddDialogName("");
|
|
453
|
+
}
|
|
454
|
+
}, [createNewVariant, addDialogName]);
|
|
455
|
+
const sanitizeSettingsPayload = React.useCallback(() => {
|
|
456
|
+
if (!draft || !baseSnapshotRef.current) return null;
|
|
457
|
+
const baseGroups = baseSnapshotRef.current;
|
|
458
|
+
const { groupDefaults, itemDefaults } = collectSidebarDefaults(baseGroups);
|
|
459
|
+
const sanitizedGroupLabels = {};
|
|
460
|
+
for (const [key, value] of Object.entries(draft.groupLabels)) {
|
|
461
|
+
const trimmed = value.trim();
|
|
462
|
+
const base = groupDefaults.get(key);
|
|
463
|
+
if (!trimmed || !base) continue;
|
|
464
|
+
if (trimmed !== base) sanitizedGroupLabels[key] = trimmed;
|
|
465
|
+
}
|
|
466
|
+
const sanitizedItemLabels = {};
|
|
467
|
+
for (const [itemId, value] of Object.entries(draft.itemLabels)) {
|
|
468
|
+
const trimmed = value.trim();
|
|
469
|
+
const base = itemDefaults.get(itemId);
|
|
470
|
+
if (!trimmed || !base) continue;
|
|
471
|
+
if (trimmed !== base) sanitizedItemLabels[itemId] = trimmed;
|
|
472
|
+
}
|
|
473
|
+
const sanitizedHiddenItems = [];
|
|
474
|
+
for (const [itemId, hidden] of Object.entries(draft.hiddenItemIds)) {
|
|
475
|
+
if (!hidden) continue;
|
|
476
|
+
if (!itemDefaults.has(itemId)) continue;
|
|
477
|
+
sanitizedHiddenItems.push(itemId);
|
|
478
|
+
}
|
|
479
|
+
const groupKeys = /* @__PURE__ */ new Set();
|
|
480
|
+
for (const group of baseGroups) groupKeys.add(resolveGroupKey(group));
|
|
481
|
+
const sanitizedItemOrder = {};
|
|
482
|
+
for (const [groupKey, list] of Object.entries(draft.itemOrder ?? {})) {
|
|
483
|
+
if (!groupKeys.has(groupKey)) continue;
|
|
484
|
+
const seen = /* @__PURE__ */ new Set();
|
|
485
|
+
const values = [];
|
|
486
|
+
for (const itemKey of list) {
|
|
487
|
+
if (seen.has(itemKey)) continue;
|
|
488
|
+
if (!itemDefaults.has(itemKey)) continue;
|
|
489
|
+
seen.add(itemKey);
|
|
490
|
+
values.push(itemKey);
|
|
491
|
+
}
|
|
492
|
+
if (values.length > 0) sanitizedItemOrder[groupKey] = values;
|
|
493
|
+
}
|
|
494
|
+
return {
|
|
495
|
+
groupOrder: draft.order,
|
|
496
|
+
groupLabels: sanitizedGroupLabels,
|
|
497
|
+
itemLabels: sanitizedItemLabels,
|
|
498
|
+
hiddenItems: sanitizedHiddenItems,
|
|
499
|
+
itemOrder: sanitizedItemOrder
|
|
500
|
+
};
|
|
501
|
+
}, [draft]);
|
|
502
|
+
const save = React.useCallback(async () => {
|
|
503
|
+
const settings = sanitizeSettingsPayload();
|
|
504
|
+
if (!settings) return;
|
|
505
|
+
setSaving(true);
|
|
506
|
+
setError(null);
|
|
507
|
+
try {
|
|
508
|
+
const trimmedName = variantName.trim();
|
|
509
|
+
const isCurrentlyActive = selectedVariant?.isActive ?? false;
|
|
510
|
+
let savedVariant = null;
|
|
511
|
+
if (isNewVariant) {
|
|
512
|
+
const call = await runMutation({
|
|
513
|
+
operation: () => apiCall(variantsApiPath, {
|
|
514
|
+
method: "POST",
|
|
515
|
+
headers: { "content-type": "application/json" },
|
|
516
|
+
body: JSON.stringify({
|
|
517
|
+
name: trimmedName.length > 0 ? trimmedName : void 0,
|
|
518
|
+
settings,
|
|
519
|
+
// New variants are activated by default — there's only one active per scope,
|
|
520
|
+
// others get auto-deactivated server-side.
|
|
521
|
+
isActive: true
|
|
522
|
+
})
|
|
523
|
+
}),
|
|
524
|
+
context: buildMutationContext("saveVariant"),
|
|
525
|
+
mutationPayload: { name: trimmedName.length > 0 ? trimmedName : null, isActive: true }
|
|
526
|
+
});
|
|
527
|
+
if (!call.ok) {
|
|
528
|
+
setError(formatVariantApiError(call, t));
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
savedVariant = call.result?.variant ?? null;
|
|
532
|
+
} else if (selectedVariantId) {
|
|
533
|
+
const call = await runMutation({
|
|
534
|
+
operation: () => apiCall(`${variantsApiPath}/${encodeURIComponent(selectedVariantId)}`, {
|
|
535
|
+
method: "PUT",
|
|
536
|
+
headers: { "content-type": "application/json" },
|
|
537
|
+
body: JSON.stringify({
|
|
538
|
+
name: trimmedName.length > 0 ? trimmedName : void 0,
|
|
539
|
+
settings,
|
|
540
|
+
isActive: isCurrentlyActive
|
|
541
|
+
})
|
|
542
|
+
}),
|
|
543
|
+
context: buildMutationContext("saveVariant", selectedVariantId),
|
|
544
|
+
mutationPayload: {
|
|
545
|
+
id: selectedVariantId,
|
|
546
|
+
name: trimmedName.length > 0 ? trimmedName : null,
|
|
547
|
+
isActive: isCurrentlyActive
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
if (!call.ok) {
|
|
551
|
+
setError(formatVariantApiError(call, t));
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
savedVariant = call.result?.variant ?? null;
|
|
555
|
+
}
|
|
556
|
+
const preferencesPayload = {
|
|
557
|
+
groupOrder: settings.groupOrder,
|
|
558
|
+
groupLabels: settings.groupLabels,
|
|
559
|
+
itemLabels: settings.itemLabels,
|
|
560
|
+
hiddenItems: settings.hiddenItems,
|
|
561
|
+
itemOrder: settings.itemOrder
|
|
562
|
+
};
|
|
563
|
+
if (canApplyToRoles) {
|
|
564
|
+
const applyToRolesPayload = [...selectedRoleIds];
|
|
565
|
+
const clearRoleIdsPayload = availableRoleTargets.filter((role) => role.hasPreference && !selectedRoleIds.includes(role.id)).map((role) => role.id);
|
|
566
|
+
preferencesPayload.applyToRoles = applyToRolesPayload;
|
|
567
|
+
preferencesPayload.clearRoleIds = clearRoleIdsPayload;
|
|
568
|
+
}
|
|
569
|
+
const preferencesCall = await runMutation({
|
|
570
|
+
operation: () => apiCall(preferencesApiPath, {
|
|
571
|
+
method: "PUT",
|
|
572
|
+
headers: { "content-type": "application/json" },
|
|
573
|
+
body: JSON.stringify(preferencesPayload)
|
|
574
|
+
}),
|
|
575
|
+
context: buildMutationContext("savePreferences", selectedVariantId),
|
|
576
|
+
mutationPayload: preferencesPayload
|
|
577
|
+
});
|
|
578
|
+
if (!preferencesCall.ok) {
|
|
579
|
+
setError(formatVariantApiError(preferencesCall, t));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
window.dispatchEvent(new Event(REFRESH_SIDEBAR_EVENT));
|
|
584
|
+
} catch {
|
|
585
|
+
}
|
|
586
|
+
const [list, rolesPayload] = await Promise.all([
|
|
587
|
+
loadVariantsList(),
|
|
588
|
+
loadRolesPayload()
|
|
589
|
+
]);
|
|
590
|
+
const mergedList = savedVariant && !list.some((v) => v.id === savedVariant.id) ? [...list, savedVariant] : list;
|
|
591
|
+
setVariants(mergedList);
|
|
592
|
+
setCanApplyToRoles(rolesPayload.canApplyToRoles);
|
|
593
|
+
setAvailableRoleTargets(rolesPayload.roles);
|
|
594
|
+
if (savedVariant) {
|
|
595
|
+
const fresh = mergedList.find((v) => v.id === savedVariant.id) ?? savedVariant;
|
|
596
|
+
selectVariantInternal(fresh, mergedList);
|
|
597
|
+
} else {
|
|
598
|
+
const active = mergedList.find((v) => v.isActive) ?? mergedList[0] ?? null;
|
|
599
|
+
selectVariantInternal(active, mergedList);
|
|
600
|
+
}
|
|
601
|
+
flash(
|
|
602
|
+
isNewVariant ? t("appShell.sidebarCustomizationVariantCreated", "Variant created.") : t("appShell.sidebarCustomizationVariantSaved", "Variant saved."),
|
|
603
|
+
"success"
|
|
604
|
+
);
|
|
605
|
+
onSaved?.();
|
|
606
|
+
} catch (err) {
|
|
607
|
+
console.error("Failed to save sidebar variant", err);
|
|
608
|
+
setError(t("appShell.sidebarCustomizationSaveError"));
|
|
609
|
+
} finally {
|
|
610
|
+
setSaving(false);
|
|
611
|
+
}
|
|
612
|
+
}, [draft, variantName, isNewVariant, selectedVariant, selectedVariantId, variantsApiPath, preferencesApiPath, canApplyToRoles, selectedRoleIds, availableRoleTargets, t, sanitizeSettingsPayload, loadVariantsList, loadRolesPayload, selectVariantInternal, onSaved, runMutation, buildMutationContext]);
|
|
613
|
+
const toggleActive = React.useCallback(async (next) => {
|
|
614
|
+
if (!selectedVariant || saving || deleting) return;
|
|
615
|
+
setError(null);
|
|
616
|
+
try {
|
|
617
|
+
const call = await runMutation({
|
|
618
|
+
operation: () => apiCall(`${variantsApiPath}/${encodeURIComponent(selectedVariant.id)}`, {
|
|
619
|
+
method: "PUT",
|
|
620
|
+
headers: { "content-type": "application/json" },
|
|
621
|
+
body: JSON.stringify({ isActive: next })
|
|
622
|
+
}),
|
|
623
|
+
context: buildMutationContext("toggleVariantActive", selectedVariant.id),
|
|
624
|
+
mutationPayload: { id: selectedVariant.id, isActive: next }
|
|
625
|
+
});
|
|
626
|
+
if (!call.ok) {
|
|
627
|
+
setError(t("appShell.sidebarCustomizationSaveError"));
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
try {
|
|
631
|
+
window.dispatchEvent(new Event(REFRESH_SIDEBAR_EVENT));
|
|
632
|
+
} catch {
|
|
633
|
+
}
|
|
634
|
+
const list = await loadVariantsList();
|
|
635
|
+
setVariants(list);
|
|
636
|
+
const fresh = list.find((v) => v.id === selectedVariant.id) ?? selectedVariant;
|
|
637
|
+
selectVariantInternal(fresh, list);
|
|
638
|
+
} catch (err) {
|
|
639
|
+
console.error("Failed to toggle variant active state", err);
|
|
640
|
+
setError(t("appShell.sidebarCustomizationSaveError"));
|
|
641
|
+
}
|
|
642
|
+
}, [selectedVariant, saving, deleting, variantsApiPath, t, loadVariantsList, selectVariantInternal, runMutation, buildMutationContext]);
|
|
643
|
+
const deleteVariant = React.useCallback(async () => {
|
|
644
|
+
if (!selectedVariant) return;
|
|
645
|
+
const proceed = await confirmDialog({
|
|
646
|
+
title: t("appShell.sidebarCustomizationDeleteVariantTitle", "Delete variant?"),
|
|
647
|
+
text: t(
|
|
648
|
+
"appShell.sidebarCustomizationDeleteVariantText",
|
|
649
|
+
"This variant will be removed from your library."
|
|
650
|
+
),
|
|
651
|
+
confirmText: t("appShell.sidebarCustomizationDeleteVariantConfirm", "Delete variant"),
|
|
652
|
+
cancelText: t("common.cancel", "Cancel"),
|
|
653
|
+
variant: "destructive"
|
|
654
|
+
});
|
|
655
|
+
if (!proceed) return;
|
|
656
|
+
setDeleting(true);
|
|
657
|
+
setError(null);
|
|
658
|
+
try {
|
|
659
|
+
const call = await runMutation({
|
|
660
|
+
operation: () => apiCall(`${variantsApiPath}/${encodeURIComponent(selectedVariant.id)}`, { method: "DELETE" }),
|
|
661
|
+
context: buildMutationContext("deleteVariant", selectedVariant.id),
|
|
662
|
+
mutationPayload: { id: selectedVariant.id }
|
|
663
|
+
});
|
|
664
|
+
if (!call.ok) {
|
|
665
|
+
setError(t("appShell.sidebarCustomizationSaveError"));
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
window.dispatchEvent(new Event(REFRESH_SIDEBAR_EVENT));
|
|
670
|
+
} catch {
|
|
671
|
+
}
|
|
672
|
+
const list = await loadVariantsList();
|
|
673
|
+
setVariants(list);
|
|
674
|
+
const fallback = list[0] ?? null;
|
|
675
|
+
selectVariantInternal(fallback, list);
|
|
676
|
+
} catch (err) {
|
|
677
|
+
console.error("Failed to delete variant", err);
|
|
678
|
+
setError(t("appShell.sidebarCustomizationSaveError"));
|
|
679
|
+
} finally {
|
|
680
|
+
setDeleting(false);
|
|
681
|
+
}
|
|
682
|
+
}, [selectedVariant, confirmDialog, t, variantsApiPath, loadVariantsList, selectVariantInternal, runMutation, buildMutationContext]);
|
|
683
|
+
const isBusy = saving || deleting;
|
|
684
|
+
if (loading && !draft) {
|
|
685
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
686
|
+
ConfirmDialogElement,
|
|
687
|
+
/* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
|
|
688
|
+
/* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
|
|
689
|
+
/* @__PURE__ */ jsx("div", { className: "h-7 w-64 animate-pulse rounded bg-muted" }),
|
|
690
|
+
/* @__PURE__ */ jsx("div", { className: "h-4 w-96 animate-pulse rounded bg-muted/60" })
|
|
691
|
+
] }),
|
|
692
|
+
/* @__PURE__ */ jsx("div", { className: "h-64 animate-pulse rounded-lg border bg-muted/30" })
|
|
693
|
+
] })
|
|
694
|
+
] });
|
|
695
|
+
}
|
|
696
|
+
if (!draft || !baseSnapshotRef.current) {
|
|
697
|
+
const stillLoading = loading || chromeIsLoading || sourceGroups.length === 0;
|
|
698
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
699
|
+
ConfirmDialogElement,
|
|
700
|
+
/* @__PURE__ */ jsx("div", { className: "rounded-lg border border-dashed bg-muted/30 p-6 text-sm text-muted-foreground", children: stillLoading ? t("appShell.sidebarCustomizationLoading", "Loading\u2026") : error ?? t("appShell.sidebarCustomizationLoadError") })
|
|
701
|
+
] });
|
|
702
|
+
}
|
|
703
|
+
const baseGroupsForDefaults = baseSnapshotRef.current;
|
|
704
|
+
const baseGroupMap = /* @__PURE__ */ new Map();
|
|
705
|
+
for (const group of baseGroupsForDefaults) {
|
|
706
|
+
baseGroupMap.set(resolveGroupKey(group), group);
|
|
707
|
+
}
|
|
708
|
+
const orderedGroupIds = mergeGroupOrder(draft.order, Array.from(baseGroupMap.keys()));
|
|
709
|
+
const totalGroups = orderedGroupIds.length;
|
|
710
|
+
const selectValue = isNewVariant ? NEW_VARIANT_KEY : selectedVariantId ?? NEW_VARIANT_KEY;
|
|
711
|
+
const showVariantPicker = variants.length > 0 || isNewVariant;
|
|
712
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
713
|
+
ConfirmDialogElement,
|
|
714
|
+
/* @__PURE__ */ jsx(
|
|
715
|
+
Dialog,
|
|
716
|
+
{
|
|
717
|
+
open: addDialogOpen,
|
|
718
|
+
onOpenChange: (next) => {
|
|
719
|
+
if (!next) {
|
|
720
|
+
setAddDialogOpen(false);
|
|
721
|
+
setAddDialogName("");
|
|
722
|
+
}
|
|
723
|
+
},
|
|
724
|
+
children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-md", children: [
|
|
725
|
+
/* @__PURE__ */ jsxs(DialogHeader, { children: [
|
|
726
|
+
/* @__PURE__ */ jsx(DialogTitle, { children: t("appShell.sidebarCustomizationAddDialogTitle", "Add new variant") }),
|
|
727
|
+
/* @__PURE__ */ jsx(DialogDescription, { children: t("appShell.sidebarCustomizationAddDialogDescription", "Choose a name for the new sidebar variant. Leave blank to auto-name it.") })
|
|
728
|
+
] }),
|
|
729
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1.5", children: [
|
|
730
|
+
/* @__PURE__ */ jsx("label", { className: "text-xs font-medium uppercase tracking-wider text-muted-foreground/70", children: t("appShell.sidebarCustomizationVariantNameLabel", "Variant name") }),
|
|
731
|
+
/* @__PURE__ */ jsx(
|
|
732
|
+
Input,
|
|
733
|
+
{
|
|
734
|
+
autoFocus: true,
|
|
735
|
+
value: addDialogName,
|
|
736
|
+
onChange: (event) => setAddDialogName(event.target.value),
|
|
737
|
+
onKeyDown: (event) => {
|
|
738
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
739
|
+
event.preventDefault();
|
|
740
|
+
void submitAddDialog();
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
placeholder: t("appShell.sidebarCustomizationVariantNamePlaceholder", "My preferences"),
|
|
744
|
+
disabled: saving
|
|
745
|
+
}
|
|
746
|
+
)
|
|
747
|
+
] }),
|
|
748
|
+
/* @__PURE__ */ jsxs(DialogFooter, { className: "mt-2", children: [
|
|
749
|
+
/* @__PURE__ */ jsx(
|
|
750
|
+
Button,
|
|
751
|
+
{
|
|
752
|
+
type: "button",
|
|
753
|
+
variant: "outline",
|
|
754
|
+
onClick: () => {
|
|
755
|
+
setAddDialogOpen(false);
|
|
756
|
+
setAddDialogName("");
|
|
757
|
+
},
|
|
758
|
+
disabled: saving,
|
|
759
|
+
children: t("appShell.sidebarCustomizationCancel")
|
|
760
|
+
}
|
|
761
|
+
),
|
|
762
|
+
/* @__PURE__ */ jsx(
|
|
763
|
+
Button,
|
|
764
|
+
{
|
|
765
|
+
type: "button",
|
|
766
|
+
onClick: () => {
|
|
767
|
+
void submitAddDialog();
|
|
768
|
+
},
|
|
769
|
+
disabled: saving,
|
|
770
|
+
children: saving ? t("appShell.sidebarCustomizationCreating", "Creating\u2026") : t("appShell.sidebarCustomizationCreateVariant", "Create variant")
|
|
771
|
+
}
|
|
772
|
+
)
|
|
773
|
+
] })
|
|
774
|
+
] })
|
|
775
|
+
}
|
|
776
|
+
),
|
|
777
|
+
/* @__PURE__ */ jsxs(Page, { children: [
|
|
778
|
+
/* @__PURE__ */ jsxs("header", { className: "space-y-1", children: [
|
|
779
|
+
/* @__PURE__ */ jsx("h1", { className: "text-xl sm:text-2xl font-semibold leading-tight", children: t("appShell.sidebarCustomizationHeading") }),
|
|
780
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t("appShell.sidebarCustomizationHint", { locale: localeLabel }) })
|
|
781
|
+
] }),
|
|
782
|
+
error ? /* @__PURE__ */ jsx("div", { className: "rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm text-destructive", children: error }) : null,
|
|
783
|
+
/* @__PURE__ */ jsxs(PageBody, { className: "grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,360px)]", children: [
|
|
784
|
+
/* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
|
|
785
|
+
(() => {
|
|
786
|
+
const showRolesCard = canApplyToRoles && availableRoleTargets.length > 0;
|
|
787
|
+
if (!showVariantPicker && !showRolesCard) return null;
|
|
788
|
+
return /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs(CardContent, { className: "flex flex-col gap-6", children: [
|
|
789
|
+
showVariantPicker ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-4", children: [
|
|
790
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1.5", children: [
|
|
791
|
+
/* @__PURE__ */ jsx("label", { className: "text-xs font-medium uppercase tracking-wider text-muted-foreground/70", children: t("appShell.sidebarCustomizationVariantNameLabel", "Variant name") }),
|
|
792
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-stretch gap-2", children: [
|
|
793
|
+
/* @__PURE__ */ jsxs("div", { className: "relative flex flex-1 items-stretch", children: [
|
|
794
|
+
/* @__PURE__ */ jsx(
|
|
795
|
+
Input,
|
|
796
|
+
{
|
|
797
|
+
value: variantName,
|
|
798
|
+
onChange: (event) => {
|
|
799
|
+
setVariantName(event.target.value);
|
|
800
|
+
setDirty(true);
|
|
801
|
+
},
|
|
802
|
+
placeholder: t("appShell.sidebarCustomizationVariantNamePlaceholder", "My preferences"),
|
|
803
|
+
disabled: isBusy,
|
|
804
|
+
className: "w-full pr-10"
|
|
805
|
+
}
|
|
806
|
+
),
|
|
807
|
+
/* @__PURE__ */ jsxs(
|
|
808
|
+
Select,
|
|
809
|
+
{
|
|
810
|
+
value: selectValue,
|
|
811
|
+
onValueChange: (value) => {
|
|
812
|
+
void handleVariantSwitch(value);
|
|
813
|
+
},
|
|
814
|
+
disabled: isBusy || loading,
|
|
815
|
+
children: [
|
|
816
|
+
/* @__PURE__ */ jsx(
|
|
817
|
+
SelectTrigger,
|
|
818
|
+
{
|
|
819
|
+
className: "pointer-events-none absolute inset-0 h-full w-full justify-end border-0 bg-transparent px-3 shadow-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0 [&>span]:hidden [&>svg]:pointer-events-auto",
|
|
820
|
+
"aria-label": t("appShell.sidebarCustomizationVariantPickerLabel", "Pick variant"),
|
|
821
|
+
children: /* @__PURE__ */ jsx(SelectValue, {})
|
|
822
|
+
}
|
|
823
|
+
),
|
|
824
|
+
/* @__PURE__ */ jsx(SelectContent, { children: variants.length > 0 ? variants.map((variant) => /* @__PURE__ */ jsx(SelectItem, { value: variant.id, children: variant.name }, variant.id)) : /* @__PURE__ */ jsx(SelectItem, { value: NEW_VARIANT_KEY, disabled: true, children: t("appShell.sidebarCustomizationVariantsEmpty", "No saved variants yet") }) })
|
|
825
|
+
]
|
|
826
|
+
}
|
|
827
|
+
)
|
|
828
|
+
] }),
|
|
829
|
+
/* @__PURE__ */ jsxs(
|
|
830
|
+
Button,
|
|
831
|
+
{
|
|
832
|
+
type: "button",
|
|
833
|
+
onClick: () => {
|
|
834
|
+
setAddDialogName("");
|
|
835
|
+
setAddDialogOpen(true);
|
|
836
|
+
},
|
|
837
|
+
disabled: isBusy,
|
|
838
|
+
title: t("appShell.sidebarCustomizationVariantNew", "Add new variant"),
|
|
839
|
+
children: [
|
|
840
|
+
/* @__PURE__ */ jsx(Plus, { className: "size-4" }),
|
|
841
|
+
t("appShell.sidebarCustomizationCreateNew", "Create new")
|
|
842
|
+
]
|
|
843
|
+
}
|
|
844
|
+
)
|
|
845
|
+
] })
|
|
846
|
+
] }),
|
|
847
|
+
isNewVariant ? /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t("appShell.sidebarCustomizationVariantNewHint", "Saving will create a new variant. If you leave the name blank, it will be auto-named.") }) : null,
|
|
848
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
849
|
+
/* @__PURE__ */ jsx(
|
|
850
|
+
Switch,
|
|
851
|
+
{
|
|
852
|
+
checked: selectedVariant?.isActive ?? isNewVariant,
|
|
853
|
+
onCheckedChange: (next) => {
|
|
854
|
+
if (isNewVariant) return;
|
|
855
|
+
void toggleActive(next === true);
|
|
856
|
+
},
|
|
857
|
+
disabled: isBusy || isNewVariant,
|
|
858
|
+
"aria-label": t("appShell.sidebarCustomizationVariantActiveLabel", "Active")
|
|
859
|
+
}
|
|
860
|
+
),
|
|
861
|
+
/* @__PURE__ */ jsx("span", { className: "text-xs font-medium uppercase tracking-wider text-muted-foreground/70", children: t("appShell.sidebarCustomizationVariantActiveLabel", "Active") })
|
|
862
|
+
] })
|
|
863
|
+
] }) : null,
|
|
864
|
+
showVariantPicker && showRolesCard ? /* @__PURE__ */ jsx("div", { className: "-mx-6 border-t", "aria-hidden": true }) : null,
|
|
865
|
+
showRolesCard ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3", children: [
|
|
866
|
+
/* @__PURE__ */ jsxs("div", { className: "space-y-1", children: [
|
|
867
|
+
/* @__PURE__ */ jsx("h3", { className: "text-base font-semibold leading-none text-foreground", children: t("appShell.sidebarApplyToRolesTitle") }),
|
|
868
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t("appShell.sidebarApplyToRolesDescription") })
|
|
869
|
+
] }),
|
|
870
|
+
/* @__PURE__ */ jsx("div", { className: "flex flex-col gap-1.5 max-w-sm", children: availableRoleTargets.map((role) => {
|
|
871
|
+
const checked = selectedRoleIds.includes(role.id);
|
|
872
|
+
const willClear = role.hasPreference && !checked;
|
|
873
|
+
return /* @__PURE__ */ jsxs(
|
|
874
|
+
"label",
|
|
875
|
+
{
|
|
876
|
+
className: "flex cursor-pointer items-center gap-3 rounded-lg border bg-background px-3 py-2 text-sm transition-colors hover:bg-muted",
|
|
877
|
+
children: [
|
|
878
|
+
/* @__PURE__ */ jsx(
|
|
879
|
+
Switch,
|
|
880
|
+
{
|
|
881
|
+
checked,
|
|
882
|
+
onCheckedChange: () => toggleRoleSelection(role.id),
|
|
883
|
+
disabled: isBusy
|
|
884
|
+
}
|
|
885
|
+
),
|
|
886
|
+
/* @__PURE__ */ jsx("span", { className: "flex-1 truncate font-medium text-foreground", children: role.name }),
|
|
887
|
+
role.hasPreference ? /* @__PURE__ */ jsxs(Tag, { variant: willClear ? "error" : "info", dot: !willClear, children: [
|
|
888
|
+
willClear ? /* @__PURE__ */ jsx(AlertTriangle, { className: "size-3", "aria-hidden": true }) : null,
|
|
889
|
+
willClear ? t("appShell.sidebarRoleWillClear") : t("appShell.sidebarRoleHasPreset")
|
|
890
|
+
] }) : null
|
|
891
|
+
]
|
|
892
|
+
},
|
|
893
|
+
role.id
|
|
894
|
+
);
|
|
895
|
+
}) })
|
|
896
|
+
] }) : null,
|
|
897
|
+
/* @__PURE__ */ jsx("div", { className: "-mx-6 border-t", "aria-hidden": true }),
|
|
898
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-between gap-3", children: [
|
|
899
|
+
selectedVariant ? /* @__PURE__ */ jsxs(
|
|
900
|
+
Button,
|
|
901
|
+
{
|
|
902
|
+
type: "button",
|
|
903
|
+
variant: "outline",
|
|
904
|
+
onClick: () => {
|
|
905
|
+
void deleteVariant();
|
|
906
|
+
},
|
|
907
|
+
disabled: isBusy,
|
|
908
|
+
className: "text-destructive hover:text-destructive",
|
|
909
|
+
children: [
|
|
910
|
+
/* @__PURE__ */ jsx(Trash2, { className: "size-4" }),
|
|
911
|
+
deleting ? t("appShell.sidebarCustomizationDeleteVariantInProgress", "Deleting\u2026") : t("appShell.sidebarCustomizationDeleteVariant", "Delete variant")
|
|
912
|
+
]
|
|
913
|
+
}
|
|
914
|
+
) : /* @__PURE__ */ jsx("span", {}),
|
|
915
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
916
|
+
/* @__PURE__ */ jsx(
|
|
917
|
+
Button,
|
|
918
|
+
{
|
|
919
|
+
type: "button",
|
|
920
|
+
variant: "ghost",
|
|
921
|
+
onClick: reset,
|
|
922
|
+
disabled: isBusy || !dirty,
|
|
923
|
+
children: t("appShell.sidebarCustomizationReset")
|
|
924
|
+
}
|
|
925
|
+
),
|
|
926
|
+
/* @__PURE__ */ jsx(
|
|
927
|
+
Button,
|
|
928
|
+
{
|
|
929
|
+
type: "button",
|
|
930
|
+
variant: "outline",
|
|
931
|
+
onClick: cancel,
|
|
932
|
+
disabled: isBusy || !dirty,
|
|
933
|
+
children: t("appShell.sidebarCustomizationCancel")
|
|
934
|
+
}
|
|
935
|
+
),
|
|
936
|
+
/* @__PURE__ */ jsx(
|
|
937
|
+
Button,
|
|
938
|
+
{
|
|
939
|
+
type: "button",
|
|
940
|
+
onClick: save,
|
|
941
|
+
disabled: isBusy || !isNewVariant && !dirty,
|
|
942
|
+
children: saving ? isNewVariant ? t("appShell.sidebarCustomizationCreating", "Creating\u2026") : t("appShell.sidebarCustomizationSaving") : isNewVariant ? t("appShell.sidebarCustomizationCreateVariant", "Create variant") : t("appShell.sidebarCustomizationSave")
|
|
943
|
+
}
|
|
944
|
+
)
|
|
945
|
+
] })
|
|
946
|
+
] })
|
|
947
|
+
] }) });
|
|
948
|
+
})(),
|
|
949
|
+
/* @__PURE__ */ jsxs(Card, { children: [
|
|
950
|
+
/* @__PURE__ */ jsxs(CardHeader, { children: [
|
|
951
|
+
/* @__PURE__ */ jsx(CardTitle, { className: "text-base", children: t("appShell.sidebarCustomizationOrderHeading", "Order & visibility") }),
|
|
952
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t("appShell.sidebarCustomizationOrderDescription", "Reorder groups, rename them, and toggle individual items on or off.") })
|
|
953
|
+
] }),
|
|
954
|
+
/* @__PURE__ */ jsx(CardContent, { className: "space-y-3", children: orderedGroupIds.map((groupId, index) => {
|
|
955
|
+
const baseGroup = baseGroupMap.get(groupId);
|
|
956
|
+
if (!baseGroup) return null;
|
|
957
|
+
const placeholder = baseGroup.defaultName ?? baseGroup.name;
|
|
958
|
+
const value = draft.groupLabels[groupId] ?? "";
|
|
959
|
+
const trimmedValue = value.trim();
|
|
960
|
+
const isGroupModified = trimmedValue.length > 0 && trimmedValue !== placeholder;
|
|
961
|
+
return /* @__PURE__ */ jsxs("div", { className: "rounded-lg border bg-background", children: [
|
|
962
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-start gap-3 border-b px-4 py-3", children: [
|
|
963
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-1 flex-col gap-1.5", children: [
|
|
964
|
+
/* @__PURE__ */ jsx("label", { className: "text-xs font-medium uppercase tracking-wider text-muted-foreground/70", children: t("appShell.sidebarCustomizationGroupLabel") }),
|
|
965
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
966
|
+
/* @__PURE__ */ jsx(
|
|
967
|
+
Input,
|
|
968
|
+
{
|
|
969
|
+
value,
|
|
970
|
+
onChange: (event) => setGroupLabel(groupId, event.target.value),
|
|
971
|
+
placeholder,
|
|
972
|
+
disabled: isBusy,
|
|
973
|
+
className: "flex-1"
|
|
974
|
+
}
|
|
975
|
+
),
|
|
976
|
+
isGroupModified ? /* @__PURE__ */ jsx(
|
|
977
|
+
IconButton,
|
|
978
|
+
{
|
|
979
|
+
type: "button",
|
|
980
|
+
variant: "ghost",
|
|
981
|
+
size: "sm",
|
|
982
|
+
onClick: () => setGroupLabel(groupId, ""),
|
|
983
|
+
disabled: isBusy,
|
|
984
|
+
"aria-label": t("appShell.sidebarCustomizationResetField", "Reset to default"),
|
|
985
|
+
title: t("appShell.sidebarCustomizationResetField", "Reset to default"),
|
|
986
|
+
children: /* @__PURE__ */ jsx(RotateCcw, { className: "size-3.5" })
|
|
987
|
+
}
|
|
988
|
+
) : null
|
|
989
|
+
] }),
|
|
990
|
+
isGroupModified ? /* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
|
|
991
|
+
t("appShell.sidebarCustomizationDefault", "Default:"),
|
|
992
|
+
" ",
|
|
993
|
+
/* @__PURE__ */ jsx("span", { className: "font-medium text-foreground/80", children: placeholder })
|
|
994
|
+
] }) : null
|
|
995
|
+
] }),
|
|
996
|
+
/* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-1 mt-[26px]", children: [
|
|
997
|
+
/* @__PURE__ */ jsx(
|
|
998
|
+
IconButton,
|
|
999
|
+
{
|
|
1000
|
+
type: "button",
|
|
1001
|
+
variant: "outline",
|
|
1002
|
+
size: "sm",
|
|
1003
|
+
className: "text-muted-foreground hover:text-foreground",
|
|
1004
|
+
onClick: () => moveGroup(groupId, -1),
|
|
1005
|
+
disabled: index === 0 || isBusy,
|
|
1006
|
+
"aria-label": t("appShell.sidebarCustomizationMoveUp"),
|
|
1007
|
+
title: t("appShell.sidebarCustomizationMoveUp"),
|
|
1008
|
+
children: /* @__PURE__ */ jsx(ChevronUp, { className: "size-4" })
|
|
1009
|
+
}
|
|
1010
|
+
),
|
|
1011
|
+
/* @__PURE__ */ jsx(
|
|
1012
|
+
IconButton,
|
|
1013
|
+
{
|
|
1014
|
+
type: "button",
|
|
1015
|
+
variant: "outline",
|
|
1016
|
+
size: "sm",
|
|
1017
|
+
className: "text-muted-foreground hover:text-foreground",
|
|
1018
|
+
onClick: () => moveGroup(groupId, 1),
|
|
1019
|
+
disabled: index === totalGroups - 1 || isBusy,
|
|
1020
|
+
"aria-label": t("appShell.sidebarCustomizationMoveDown"),
|
|
1021
|
+
title: t("appShell.sidebarCustomizationMoveDown"),
|
|
1022
|
+
children: /* @__PURE__ */ jsx(ChevronDown, { className: "size-4" })
|
|
1023
|
+
}
|
|
1024
|
+
)
|
|
1025
|
+
] })
|
|
1026
|
+
] }),
|
|
1027
|
+
/* @__PURE__ */ jsx("div", { className: "flex flex-col divide-y", children: /* @__PURE__ */ jsx(
|
|
1028
|
+
ItemRows,
|
|
1029
|
+
{
|
|
1030
|
+
items: baseGroup.items,
|
|
1031
|
+
draft,
|
|
1032
|
+
saving: isBusy,
|
|
1033
|
+
onLabelChange: setItemLabel,
|
|
1034
|
+
onHiddenChange: setItemHidden,
|
|
1035
|
+
t,
|
|
1036
|
+
groupKey: groupId,
|
|
1037
|
+
sensors: dndSensors,
|
|
1038
|
+
onDragEnd: handleItemDragEnd(groupId, baseGroup.items.map((item) => resolveItemKey(item)))
|
|
1039
|
+
}
|
|
1040
|
+
) })
|
|
1041
|
+
] }, groupId);
|
|
1042
|
+
}) })
|
|
1043
|
+
] })
|
|
1044
|
+
] }),
|
|
1045
|
+
/* @__PURE__ */ jsx("aside", { className: "hidden lg:block", children: /* @__PURE__ */ jsx("div", { className: "sticky top-6", children: /* @__PURE__ */ jsxs("div", { className: "relative", children: [
|
|
1046
|
+
/* @__PURE__ */ jsx("span", { className: "absolute left-1/2 top-0 z-10 -translate-x-1/2 -translate-y-1/2 rounded-md bg-accent-indigo px-3 py-1 text-xs font-semibold uppercase tracking-wider text-accent-indigo-foreground shadow-sm", children: t("appShell.sidebarCustomizationPreview", "Preview") }),
|
|
1047
|
+
/* @__PURE__ */ jsx(
|
|
1048
|
+
SidebarPreview,
|
|
1049
|
+
{
|
|
1050
|
+
groups: previewGroups,
|
|
1051
|
+
productName: t("appShell.productName", "Open Mercato"),
|
|
1052
|
+
pickFirstActive: true
|
|
1053
|
+
}
|
|
1054
|
+
)
|
|
1055
|
+
] }) }) })
|
|
1056
|
+
] })
|
|
1057
|
+
] })
|
|
1058
|
+
] });
|
|
1059
|
+
}
|
|
1060
|
+
function ItemRow({ item, draft, saving, onLabelChange, onHiddenChange, t, depth, dragHandle, ancestorHidden = false }) {
|
|
1061
|
+
const itemKey = resolveItemKey(item);
|
|
1062
|
+
const placeholder = item.defaultTitle ?? item.title;
|
|
1063
|
+
const value = draft.itemLabels[itemKey] ?? "";
|
|
1064
|
+
const trimmedValue = value.trim();
|
|
1065
|
+
const isModified = trimmedValue.length > 0 && trimmedValue !== placeholder;
|
|
1066
|
+
const hidden = draft.hiddenItemIds[itemKey] === true;
|
|
1067
|
+
const effectivelyDimmed = hidden || ancestorHidden;
|
|
1068
|
+
return /* @__PURE__ */ jsxs(
|
|
1069
|
+
"div",
|
|
1070
|
+
{
|
|
1071
|
+
className: "flex items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/40",
|
|
1072
|
+
style: depth ? { paddingLeft: 16 + depth * 24 } : void 0,
|
|
1073
|
+
children: [
|
|
1074
|
+
dragHandle ?? (depth > 0 ? /* @__PURE__ */ jsx("span", { className: "w-4 shrink-0", "aria-hidden": true }) : null),
|
|
1075
|
+
/* @__PURE__ */ jsxs("div", { className: `min-w-0 flex-1 flex flex-col gap-1.5 ${effectivelyDimmed ? "opacity-60" : ""}`, children: [
|
|
1076
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
1077
|
+
/* @__PURE__ */ jsx("div", { className: "min-w-0 flex-1", children: /* @__PURE__ */ jsx(
|
|
1078
|
+
Input,
|
|
1079
|
+
{
|
|
1080
|
+
value,
|
|
1081
|
+
onChange: (event) => onLabelChange(itemKey, event.target.value),
|
|
1082
|
+
placeholder,
|
|
1083
|
+
disabled: saving
|
|
1084
|
+
}
|
|
1085
|
+
) }),
|
|
1086
|
+
isModified ? /* @__PURE__ */ jsx(
|
|
1087
|
+
IconButton,
|
|
1088
|
+
{
|
|
1089
|
+
type: "button",
|
|
1090
|
+
variant: "ghost",
|
|
1091
|
+
size: "sm",
|
|
1092
|
+
onClick: () => onLabelChange(itemKey, ""),
|
|
1093
|
+
disabled: saving,
|
|
1094
|
+
"aria-label": t("appShell.sidebarCustomizationResetField", "Reset to default"),
|
|
1095
|
+
title: t("appShell.sidebarCustomizationResetField", "Reset to default"),
|
|
1096
|
+
children: /* @__PURE__ */ jsx(RotateCcw, { className: "size-3.5" })
|
|
1097
|
+
}
|
|
1098
|
+
) : null
|
|
1099
|
+
] }),
|
|
1100
|
+
isModified ? /* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
|
|
1101
|
+
t("appShell.sidebarCustomizationDefault", "Default:"),
|
|
1102
|
+
" ",
|
|
1103
|
+
/* @__PURE__ */ jsx("span", { className: "font-medium text-foreground/80", children: placeholder })
|
|
1104
|
+
] }) : null
|
|
1105
|
+
] }),
|
|
1106
|
+
/* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-2 pt-1.5", children: [
|
|
1107
|
+
hidden ? /* @__PURE__ */ jsx("span", { className: "rounded-full border border-border bg-muted px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground", children: t("appShell.sidebarCustomizationHiddenBadge", "Hidden") }) : null,
|
|
1108
|
+
/* @__PURE__ */ jsx(
|
|
1109
|
+
Switch,
|
|
1110
|
+
{
|
|
1111
|
+
checked: !hidden,
|
|
1112
|
+
onCheckedChange: (next) => onHiddenChange(itemKey, next !== true),
|
|
1113
|
+
disabled: saving || ancestorHidden,
|
|
1114
|
+
"aria-label": t("appShell.sidebarCustomizationShowItem"),
|
|
1115
|
+
title: ancestorHidden ? t("appShell.sidebarCustomizationParentHiddenHint", "Parent is hidden \u2014 show parent first.") : void 0
|
|
1116
|
+
}
|
|
1117
|
+
)
|
|
1118
|
+
] })
|
|
1119
|
+
]
|
|
1120
|
+
}
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
function SortableItemRow({ id, ...rowProps }) {
|
|
1124
|
+
const t = useT();
|
|
1125
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging, setActivatorNodeRef } = useSortable({ id });
|
|
1126
|
+
const style = {
|
|
1127
|
+
transform: CSS.Transform.toString(transform),
|
|
1128
|
+
transition,
|
|
1129
|
+
opacity: isDragging ? 0.5 : 1
|
|
1130
|
+
};
|
|
1131
|
+
const dragHandle = /* @__PURE__ */ jsx(
|
|
1132
|
+
IconButton,
|
|
1133
|
+
{
|
|
1134
|
+
ref: setActivatorNodeRef,
|
|
1135
|
+
type: "button",
|
|
1136
|
+
variant: "ghost",
|
|
1137
|
+
size: "sm",
|
|
1138
|
+
className: "shrink-0 mt-1.5 cursor-grab touch-none active:cursor-grabbing",
|
|
1139
|
+
"aria-label": t("appShell.sidebarCustomizationDragToReorder", "Drag to reorder"),
|
|
1140
|
+
disabled: rowProps.saving,
|
|
1141
|
+
...attributes,
|
|
1142
|
+
...listeners,
|
|
1143
|
+
children: /* @__PURE__ */ jsx(GripVertical, { className: "size-4" })
|
|
1144
|
+
}
|
|
1145
|
+
);
|
|
1146
|
+
return /* @__PURE__ */ jsx("div", { ref: setNodeRef, style, children: /* @__PURE__ */ jsx(ItemRow, { ...rowProps, dragHandle }) });
|
|
1147
|
+
}
|
|
1148
|
+
function ItemRows({
|
|
1149
|
+
items,
|
|
1150
|
+
draft,
|
|
1151
|
+
saving,
|
|
1152
|
+
onLabelChange,
|
|
1153
|
+
onHiddenChange,
|
|
1154
|
+
t,
|
|
1155
|
+
depth = 0,
|
|
1156
|
+
groupKey,
|
|
1157
|
+
sensors,
|
|
1158
|
+
onDragEnd,
|
|
1159
|
+
ancestorHidden = false
|
|
1160
|
+
}) {
|
|
1161
|
+
if (items.length === 0) return null;
|
|
1162
|
+
const renderRecursiveChildren = (item, parentHidden) => item.children && item.children.length > 0 ? /* @__PURE__ */ jsx(
|
|
1163
|
+
ItemRows,
|
|
1164
|
+
{
|
|
1165
|
+
items: item.children,
|
|
1166
|
+
draft,
|
|
1167
|
+
saving,
|
|
1168
|
+
onLabelChange,
|
|
1169
|
+
onHiddenChange,
|
|
1170
|
+
t,
|
|
1171
|
+
depth: depth + 1,
|
|
1172
|
+
ancestorHidden: parentHidden
|
|
1173
|
+
}
|
|
1174
|
+
) : null;
|
|
1175
|
+
if (depth === 0 && groupKey && sensors && onDragEnd) {
|
|
1176
|
+
const ordered = applyItemOrder(items, resolveItemKey, draft.itemOrder?.[groupKey]);
|
|
1177
|
+
const ids = ordered.map((item) => resolveItemKey(item));
|
|
1178
|
+
return /* @__PURE__ */ jsx(DndContext, { sensors, collisionDetection: closestCenter, onDragEnd, children: /* @__PURE__ */ jsx(SortableContext, { items: ids, strategy: verticalListSortingStrategy, children: ordered.map((item) => {
|
|
1179
|
+
const itemKey = resolveItemKey(item);
|
|
1180
|
+
const ownHidden = draft.hiddenItemIds[itemKey] === true;
|
|
1181
|
+
return /* @__PURE__ */ jsxs(React.Fragment, { children: [
|
|
1182
|
+
/* @__PURE__ */ jsx(
|
|
1183
|
+
SortableItemRow,
|
|
1184
|
+
{
|
|
1185
|
+
id: itemKey,
|
|
1186
|
+
item,
|
|
1187
|
+
draft,
|
|
1188
|
+
saving,
|
|
1189
|
+
onLabelChange,
|
|
1190
|
+
onHiddenChange,
|
|
1191
|
+
t,
|
|
1192
|
+
depth,
|
|
1193
|
+
ancestorHidden
|
|
1194
|
+
}
|
|
1195
|
+
),
|
|
1196
|
+
renderRecursiveChildren(item, ancestorHidden || ownHidden)
|
|
1197
|
+
] }, itemKey);
|
|
1198
|
+
}) }) });
|
|
1199
|
+
}
|
|
1200
|
+
return /* @__PURE__ */ jsx(Fragment, { children: items.map((item) => {
|
|
1201
|
+
const itemKey = resolveItemKey(item);
|
|
1202
|
+
const ownHidden = draft.hiddenItemIds[itemKey] === true;
|
|
1203
|
+
return /* @__PURE__ */ jsxs(React.Fragment, { children: [
|
|
1204
|
+
/* @__PURE__ */ jsx(
|
|
1205
|
+
ItemRow,
|
|
1206
|
+
{
|
|
1207
|
+
item,
|
|
1208
|
+
draft,
|
|
1209
|
+
saving,
|
|
1210
|
+
onLabelChange,
|
|
1211
|
+
onHiddenChange,
|
|
1212
|
+
t,
|
|
1213
|
+
depth,
|
|
1214
|
+
ancestorHidden
|
|
1215
|
+
}
|
|
1216
|
+
),
|
|
1217
|
+
renderRecursiveChildren(item, ancestorHidden || ownHidden)
|
|
1218
|
+
] }, itemKey);
|
|
1219
|
+
}) });
|
|
1220
|
+
}
|
|
1221
|
+
function SidebarPreviewIcon({ item }) {
|
|
1222
|
+
if (item.icon) return /* @__PURE__ */ jsx(Fragment, { children: item.icon });
|
|
1223
|
+
if (item.iconName) {
|
|
1224
|
+
const resolved = resolveInjectedIcon(item.iconName);
|
|
1225
|
+
if (resolved) return /* @__PURE__ */ jsx(Fragment, { children: resolved });
|
|
1226
|
+
}
|
|
1227
|
+
if (item.iconMarkup) {
|
|
1228
|
+
return /* @__PURE__ */ jsx("span", { "aria-hidden": "true", dangerouslySetInnerHTML: { __html: item.iconMarkup } });
|
|
1229
|
+
}
|
|
1230
|
+
return /* @__PURE__ */ jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: /* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "3" }) });
|
|
1231
|
+
}
|
|
1232
|
+
function SidebarPreview({
|
|
1233
|
+
groups,
|
|
1234
|
+
productName,
|
|
1235
|
+
pickFirstActive
|
|
1236
|
+
}) {
|
|
1237
|
+
const t = useT();
|
|
1238
|
+
const activeKey = React.useMemo(() => {
|
|
1239
|
+
if (!pickFirstActive) return null;
|
|
1240
|
+
for (const group of groups) {
|
|
1241
|
+
for (const item of group.items) {
|
|
1242
|
+
if (item.hidden === true) continue;
|
|
1243
|
+
return resolveItemKey(item);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
return null;
|
|
1247
|
+
}, [groups, pickFirstActive]);
|
|
1248
|
+
return /* @__PURE__ */ jsx("div", { className: "relative w-[240px] overflow-hidden rounded-xl border bg-background shadow-sm", children: /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3 px-3 py-4", children: [
|
|
1249
|
+
/* @__PURE__ */ jsx("div", { className: "mb-2", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 rounded-xl p-3", children: [
|
|
1250
|
+
/* @__PURE__ */ jsx(
|
|
1251
|
+
Image,
|
|
1252
|
+
{
|
|
1253
|
+
src: "/open-mercato.svg",
|
|
1254
|
+
alt: productName,
|
|
1255
|
+
width: 40,
|
|
1256
|
+
height: 40,
|
|
1257
|
+
className: "rounded-full shrink-0"
|
|
1258
|
+
}
|
|
1259
|
+
),
|
|
1260
|
+
/* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground truncate", children: productName })
|
|
1261
|
+
] }) }),
|
|
1262
|
+
/* @__PURE__ */ jsxs("div", { className: "mb-2 flex items-center gap-2 rounded-lg border border-border bg-background pl-2.5 pr-2 py-2 shadow-sm", children: [
|
|
1263
|
+
/* @__PURE__ */ jsx(Search, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": true }),
|
|
1264
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 text-sm text-muted-foreground/70 truncate", children: t("appShell.sidebarCustomizationPreviewSearchPlaceholder", "Search...") })
|
|
1265
|
+
] }),
|
|
1266
|
+
groups.length === 0 ? /* @__PURE__ */ jsx("p", { className: "px-2 text-sm text-muted-foreground", children: t("appShell.sidebarCustomizationPreviewEmpty", "No groups to preview.") }) : /* @__PURE__ */ jsx("nav", { className: "flex flex-col gap-2", children: groups.map((group, gi) => {
|
|
1267
|
+
const visibleItems = group.items.filter((item) => item.hidden !== true);
|
|
1268
|
+
if (visibleItems.length === 0) return null;
|
|
1269
|
+
return /* @__PURE__ */ jsxs("div", { children: [
|
|
1270
|
+
/* @__PURE__ */ jsx("div", { className: "w-full px-1 justify-between flex text-xs font-medium uppercase tracking-wider text-muted-foreground/70 py-1", children: /* @__PURE__ */ jsx("span", { children: group.name }) }),
|
|
1271
|
+
/* @__PURE__ */ jsx("div", { className: "flex flex-col gap-1", children: visibleItems.map((item) => {
|
|
1272
|
+
const itemKey = resolveItemKey(item);
|
|
1273
|
+
const isActive = activeKey === itemKey;
|
|
1274
|
+
return /* @__PURE__ */ jsxs(
|
|
1275
|
+
"div",
|
|
1276
|
+
{
|
|
1277
|
+
className: `relative text-sm font-medium rounded-lg inline-flex items-center w-full px-3 py-2 gap-2 ${isActive ? "bg-muted text-foreground" : "text-muted-foreground"}`,
|
|
1278
|
+
children: [
|
|
1279
|
+
isActive ? /* @__PURE__ */ jsx(
|
|
1280
|
+
"span",
|
|
1281
|
+
{
|
|
1282
|
+
"aria-hidden": true,
|
|
1283
|
+
className: "absolute left-[-12px] top-2 w-1 h-5 rounded-r bg-foreground"
|
|
1284
|
+
}
|
|
1285
|
+
) : null,
|
|
1286
|
+
/* @__PURE__ */ jsx("span", { className: "flex items-center justify-center shrink-0", children: /* @__PURE__ */ jsx(SidebarPreviewIcon, { item }) }),
|
|
1287
|
+
/* @__PURE__ */ jsx("span", { className: "truncate", children: item.title })
|
|
1288
|
+
]
|
|
1289
|
+
},
|
|
1290
|
+
itemKey
|
|
1291
|
+
);
|
|
1292
|
+
}) }),
|
|
1293
|
+
gi < groups.length - 1 ? /* @__PURE__ */ jsx("div", { className: "my-2 border-t -ml-3 -mr-4" }) : null
|
|
1294
|
+
] }, resolveGroupKey(group));
|
|
1295
|
+
}) })
|
|
1296
|
+
] }) });
|
|
1297
|
+
}
|
|
1298
|
+
var SidebarCustomizationEditor_default = SidebarCustomizationEditor;
|
|
1299
|
+
export {
|
|
1300
|
+
SidebarCustomizationEditor,
|
|
1301
|
+
SidebarCustomizationEditor_default as default
|
|
1302
|
+
};
|
|
1303
|
+
//# sourceMappingURL=SidebarCustomizationEditor.js.map
|