@shwfed/nuxt 0.1.64 → 0.1.65

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.
@@ -1,31 +1,34 @@
1
1
  <script setup>
2
- import { useHead, useNuxtApp, useRoute, useRouter, useRuntimeConfig } from "#app";
3
- import { computed, onMounted, ref } from "vue";
4
- import { CommandDialog, CommandInput, CommandList, CommandGroup, CommandEmpty, CommandItem } from "./ui/command";
2
+ import { useHead, useNuxtApp, useRoute, useRuntimeConfig } from "#app";
3
+ import { computed, reactive, watch } from "vue";
4
+ import { CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "./ui/command";
5
5
  import { TooltipProvider } from "./ui/tooltip";
6
6
  import { Toaster } from "vue-sonner";
7
- import { useLocalStorage, useMagicKeys, useSessionStorage, useTimeoutFn, whenever } from "@vueuse/core";
7
+ import { useMagicKeys, useTimeoutFn, whenever } from "@vueuse/core";
8
8
  import { useI18n } from "vue-i18n";
9
9
  import { Icon } from "@iconify/vue";
10
10
  import { Effect } from "effect";
11
- import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuGroup, DropdownMenuLabel } from "./ui/dropdown-menu";
12
11
  import { setGlobalDslContext } from "../plugins/cel/context";
13
- import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider } from "./ui/sidebar";
12
+ import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar";
14
13
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
14
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
15
+ import { z } from "zod";
15
16
  import Logo from "./logo.vue";
16
- const { $dsl } = useNuxtApp();
17
+ import { useFavorite } from "../composables/useFavorite";
18
+ import { useNavigationTabs } from "../composables/useNavigationTabs";
19
+ import { commandActionSchema, executeCommandAction } from "../utils/command";
20
+ import { createPaletteItemValue, normalizeCommandsForPalette, splitNavigationForPalette } from "../utils/command-palette";
21
+ import { isRouteActive, normalizeRoutePath } from "../utils/route";
22
+ import { applyRootNavigationFallback } from "../utils/sidebar-fallback";
23
+ const { $dsl, $logout, $md } = useNuxtApp();
24
+ const { t } = useI18n();
25
+ const route = useRoute();
26
+ const { tabs, active, tabList, activateTab, closeTab } = useNavigationTabs();
17
27
  const {
18
28
  public: {
19
29
  shwfed: config
20
- },
21
- app
30
+ }
22
31
  } = useRuntimeConfig();
23
- const props = defineProps({
24
- navigation: { type: Array, required: false },
25
- commands: { type: Array, required: false },
26
- dsl: { type: null, required: false }
27
- });
28
- const { t } = useI18n();
29
32
  useHead({
30
33
  bodyAttrs: {
31
34
  style: {
@@ -34,308 +37,197 @@ useHead({
34
37
  }
35
38
  }
36
39
  });
37
- const isCommandOpen = ref(false);
38
- const isDropdownOpen = ref(false);
39
- const { start: startDropdownCloseTimer, stop: stopDropdownCloseTimer } = useTimeoutFn(() => {
40
- isDropdownOpen.value = false;
40
+ const props = defineProps({
41
+ dsl: { type: null, required: false }
42
+ });
43
+ const ui = reactive({
44
+ isCommandPaletteOpen: false,
45
+ isProfileDropdownOpen: false
46
+ });
47
+ const { start: startProfileCloseTimer, stop: stopProfileCloseTimer } = useTimeoutFn(() => {
48
+ ui.isProfileDropdownOpen = false;
41
49
  }, 150, { immediate: false });
42
50
  const { meta_k } = useMagicKeys();
43
- whenever(() => meta_k?.value, () => {
44
- isCommandOpen.value = !isCommandOpen.value;
45
- });
46
- const router = useRouter();
47
- const route = useRoute();
51
+ whenever(() => meta_k?.value, () => ui.isCommandPaletteOpen = !ui.isCommandPaletteOpen);
48
52
  setGlobalDslContext(await props.dsl?.pipe(Effect.scoped).pipe(Effect.runPromise) ?? {});
49
- function toNavigationKey(groupId, itemId) {
50
- return groupId ? `${groupId}::${itemId}` : `::${itemId}`;
51
- }
52
- function findNavigationByKey(navList, key) {
53
- const parts = key.split("::");
54
- if (parts.length < 2) return void 0;
55
- const groupId = parts[0] ?? "";
56
- const itemId = parts[1] ?? "";
57
- if (groupId === "") {
58
- for (const el of navList) {
59
- if (el.type === "item" && el.id === itemId) return el;
60
- }
61
- return void 0;
62
- }
63
- for (const el of navList) {
64
- if (el.type === "group" && el.id === groupId) {
65
- const child = el.children.find((c) => c.id === itemId);
66
- return child;
67
- }
53
+ const navigationItems = computed(() => {
54
+ const item = z.object({
55
+ id: z.union([z.string(), z.number()]),
56
+ title: z.string(),
57
+ icon: z.string().optional(),
58
+ route: z.string(),
59
+ keywords: z.array(z.string()).optional()
60
+ });
61
+ const group = z.object({
62
+ id: z.union([z.string(), z.number()]),
63
+ title: z.string(),
64
+ icon: z.string().optional(),
65
+ children: z.array(item)
66
+ });
67
+ try {
68
+ const result = $dsl.evaluate`${config.sidebar.menus}`();
69
+ return z.array(z.union([group, item])).parse(result);
70
+ } catch {
71
+ return [];
68
72
  }
69
- return void 0;
70
- }
71
- function normalizePathForMatch(p) {
72
- const trimmed = p.replace(/\/$/, "");
73
- return trimmed === "" ? "/" : trimmed;
74
- }
75
- function findNavigationKeyByPath(navList, path) {
76
- const normalized = normalizePathForMatch(path);
77
- for (const el of navList) {
78
- if (el.type === "item") {
79
- if (el.external === true) continue;
80
- if (normalizePathForMatch(el.target) === normalized) return toNavigationKey("", el.id);
81
- } else {
82
- for (const child of el.children) {
83
- if (child.external === true) continue;
84
- if (normalizePathForMatch(child.target) === normalized) return toNavigationKey(el.id, child.id);
85
- }
86
- }
87
- }
88
- return void 0;
89
- }
90
- const palette = computed(() => {
91
- const items = [];
92
- for (const navigation2 of props.navigation ?? []) {
93
- if ("children" in navigation2) {
94
- const children = navigation2.children.filter((child) => {
95
- if (typeof child.hidden === "string" && $dsl.evaluate`${child.hidden}`()) {
73
+ });
74
+ const {
75
+ isFavorited: isNavigationFavorited,
76
+ canBeFavorited: canBeNavigationFavorited,
77
+ toggle: toggleNavigationFavorite,
78
+ withFavorites: navigations
79
+ } = useFavorite("navigation", navigationItems);
80
+ const isNavigationItemActive = (itemRoute) => isRouteActive(route.path, itemRoute);
81
+ const getTabLabel = (tab) => {
82
+ const normalizedTab = normalizeRoutePath(tab);
83
+ for (const group of navigations.value) {
84
+ if ("children" in group) {
85
+ const matchedChild = group.children.find((child) => {
86
+ if (!("route" in child))
96
87
  return false;
97
- }
98
- return true;
99
- });
100
- if (children.length > 0) {
101
- items.push({
102
- id: navigation2.id,
103
- title: navigation2.title,
104
- icon: navigation2.icon,
105
- type: "group",
106
- children: children.map((child) => ({
107
- id: child.id,
108
- title: child.title,
109
- icon: child.icon,
110
- type: "item",
111
- external: $dsl.evaluate`${child.external}`(),
112
- effect: Effect.sync(function() {
113
- if ($dsl.evaluate`${child.external}`() !== true) {
114
- tabs.value.add(toNavigationKey(navigation2.id, child.id));
115
- activeTab.value = toNavigationKey(navigation2.id, child.id);
116
- router.replace(child.target);
117
- }
118
- })
119
- }))
120
- });
121
- }
122
- } else {
123
- if (typeof navigation2.hidden === "string" && $dsl.evaluate`${navigation2.hidden}`()) {
124
- continue;
125
- }
126
- items.push({
127
- id: navigation2.id,
128
- title: navigation2.title,
129
- icon: navigation2.icon,
130
- type: "item",
131
- external: $dsl.evaluate`${navigation2.external}`(),
132
- effect: Effect.sync(function() {
133
- if ($dsl.evaluate`${navigation2.external}`() !== true) {
134
- tabs.value.add(toNavigationKey("", navigation2.id));
135
- activeTab.value = toNavigationKey("", navigation2.id);
136
- router.replace(navigation2.target);
137
- }
138
- })
88
+ return normalizeRoutePath(child.route) === normalizedTab;
139
89
  });
90
+ if (matchedChild)
91
+ return matchedChild.title;
92
+ continue;
140
93
  }
94
+ if (normalizeRoutePath(group.route) === normalizedTab)
95
+ return group.title;
141
96
  }
142
- for (const command of props.commands ?? []) {
143
- if ("children" in command) {
144
- const children = command.children.filter(
145
- (child) => (typeof child.hidden !== "string" || !$dsl.evaluate`${child.hidden}`()) && child.positions.includes("command-palette")
146
- );
147
- if (children.length > 0) {
148
- items.push({
149
- id: command.id,
150
- title: command.title,
151
- icon: command.icon,
152
- type: "group",
153
- children: children.map((child) => ({
154
- id: child.id,
155
- title: child.title,
156
- icon: child.icon,
157
- type: "item",
158
- external: false,
159
- effect: child.effect
160
- }))
161
- });
162
- }
163
- } else {
164
- if ((typeof command.hidden !== "string" || !$dsl.evaluate`${command.hidden}`()) && command.positions.includes("command-palette")) {
165
- items.push({
166
- id: command.id,
167
- title: command.title,
168
- icon: command.icon,
169
- type: "item",
170
- external: false,
171
- effect: command.effect
172
- });
173
- }
174
- }
97
+ return normalizedTab;
98
+ };
99
+ const profileName = computed(() => {
100
+ if (config.profile.name === void 0)
101
+ return t("profile");
102
+ try {
103
+ const content = $md.inline`${config.profile.name}`();
104
+ const plainText = content.replace(/<[^>]*>/g, "").trim();
105
+ if (plainText.length === 0)
106
+ return t("profile");
107
+ return plainText;
108
+ } catch {
109
+ return t("profile");
175
110
  }
176
- return items;
177
111
  });
178
- const navigation = computed(() => {
179
- const items = [];
180
- for (const navigation2 of props.navigation ?? []) {
181
- if ("children" in navigation2) {
182
- const children = navigation2.children.filter(
183
- (child) => typeof child.hidden !== "string" || !$dsl.evaluate`${child.hidden}`()
184
- );
185
- if (children.length > 0) {
186
- items.push({
187
- id: navigation2.id,
188
- title: navigation2.title,
189
- icon: navigation2.icon,
190
- type: "group",
191
- children: children.map((child) => ({
192
- id: child.id,
193
- title: child.title,
194
- icon: child.icon,
195
- type: "item",
196
- external: $dsl.evaluate`${child.external}`(),
197
- target: child.target,
198
- effect: Effect.sync(function() {
199
- if ($dsl.evaluate`${child.external}`() !== true) {
200
- tabs.value.add(toNavigationKey(navigation2.id, child.id));
201
- activeTab.value = toNavigationKey(navigation2.id, child.id);
202
- router.replace(child.target);
203
- }
204
- })
205
- }))
206
- });
207
- }
208
- } else {
209
- if (typeof navigation2.hidden !== "string" || !$dsl.evaluate`${navigation2.hidden}`()) {
210
- items.push({
211
- id: navigation2.id,
212
- title: navigation2.title,
213
- icon: navigation2.icon,
214
- type: "item",
215
- external: $dsl.evaluate`${navigation2.external}`(),
216
- target: navigation2.target,
217
- effect: Effect.sync(function() {
218
- if ($dsl.evaluate`${navigation2.external}`() !== true) {
219
- tabs.value.add(toNavigationKey("", navigation2.id));
220
- activeTab.value = toNavigationKey("", navigation2.id);
221
- router.replace(navigation2.target);
222
- }
223
- })
224
- });
225
- }
226
- }
227
- }
228
- return items;
229
- });
230
- const profile = computed(() => {
231
- const items = [];
232
- for (const command of props.commands ?? []) {
233
- if ("children" in command) {
234
- const children = command.children.filter(
235
- (child) => (typeof child.hidden !== "string" || !$dsl.evaluate`${child.hidden}`()) && child.positions.includes("profile")
236
- );
237
- if (children.length > 0) {
238
- items.push({
239
- id: command.id,
240
- title: command.title,
241
- icon: command.icon,
242
- type: "group",
243
- children: children.map((child) => ({
244
- id: child.id,
245
- title: child.title,
246
- icon: child.icon,
247
- type: "item",
248
- external: false,
249
- effect: child.effect
250
- }))
251
- });
252
- }
253
- } else {
254
- if ((typeof command.hidden !== "string" || !$dsl.evaluate`${command.hidden}`()) && command.positions.includes("profile")) {
255
- items.push({
256
- id: command.id,
257
- title: command.title,
258
- icon: command.icon,
259
- type: "item",
260
- external: false,
261
- effect: command.effect
262
- });
263
- }
264
- }
265
- }
266
- return items;
112
+ const commandItem = z.object({
113
+ id: z.union([z.string(), z.number()]),
114
+ title: z.string(),
115
+ icon: z.string().optional(),
116
+ hidden: z.string().optional(),
117
+ disabled: z.string().optional(),
118
+ effect: z.string().optional(),
119
+ keywords: z.array(z.string()).optional()
267
120
  });
268
- const navigationFavorites = useLocalStorage("navigation-favorites", [], {
269
- writeDefaults: false
121
+ const commandGroup = z.object({
122
+ id: z.union([z.string(), z.number()]),
123
+ title: z.string(),
124
+ icon: z.string().optional(),
125
+ children: z.array(commandItem)
270
126
  });
271
- const navigationFavoritesItems = computed(() => {
272
- const items = [];
273
- for (const key of navigationFavorites.value) {
274
- const item = findNavigationByKey(navigation.value, key);
275
- if (!item) continue;
276
- items.push({
277
- id: key,
278
- title: item.title,
279
- icon: item.icon,
280
- effect: Effect.sync(function() {
281
- if (item.external !== true) {
282
- tabs.value.add(key);
283
- activeTab.value = key;
284
- router.replace(item.target);
285
- }
286
- })
287
- });
127
+ const evaluateDslBoolean = (expression) => {
128
+ if (expression === void 0)
129
+ return false;
130
+ try {
131
+ return $dsl.evaluate`${expression}`() === true;
132
+ } catch {
133
+ return false;
288
134
  }
289
- return items;
290
- });
291
- const paletteFavorites = computed(() => {
292
- const items = [];
293
- for (const key of navigationFavorites.value) {
294
- const item = findNavigationByKey(navigation.value, key);
295
- if (!item) continue;
296
- items.push({
297
- id: key,
298
- title: item.title,
299
- icon: item.icon,
300
- external: item.external,
301
- effect: Effect.sync(function() {
302
- if (item.external !== true) {
303
- tabs.value.add(key);
304
- activeTab.value = key;
305
- router.replace(item.target);
135
+ };
136
+ const normalizeEffectExpression = (expression) => {
137
+ return expression.replace(/([{,]\s*)([a-z_][\w-]*)(\s*:)/gi, "$1'$2'$3");
138
+ };
139
+ const profileCommands = computed(() => {
140
+ if (config.commands === void 0)
141
+ return [];
142
+ try {
143
+ const result = $dsl.evaluate`${config.commands}`();
144
+ const entries = z.array(z.union([commandGroup, commandItem])).parse(result);
145
+ const normalized = [];
146
+ for (const entry of entries) {
147
+ if ("children" in entry) {
148
+ const children = entry.children.filter((child) => !evaluateDslBoolean(child.hidden)).map((child) => ({
149
+ id: child.id,
150
+ title: child.title,
151
+ icon: child.icon,
152
+ disabled: evaluateDslBoolean(child.disabled),
153
+ effect: child.effect,
154
+ keywords: child.keywords
155
+ }));
156
+ if (children.length > 0) {
157
+ normalized.push({
158
+ id: entry.id,
159
+ title: entry.title,
160
+ icon: entry.icon,
161
+ children
162
+ });
306
163
  }
307
- })
308
- });
164
+ continue;
165
+ }
166
+ if (evaluateDslBoolean(entry.hidden))
167
+ continue;
168
+ normalized.push({
169
+ id: entry.id,
170
+ title: entry.title,
171
+ icon: entry.icon,
172
+ disabled: evaluateDslBoolean(entry.disabled),
173
+ effect: entry.effect,
174
+ keywords: entry.keywords
175
+ });
176
+ }
177
+ return normalized;
178
+ } catch {
179
+ return [];
309
180
  }
310
- return items;
311
- });
312
- const tabs = useSessionStorage("navigation-tabs", /* @__PURE__ */ new Set(), {
313
- writeDefaults: false
314
181
  });
315
- const activeTab = useSessionStorage("navigation-active-tab", void 0, {
316
- writeDefaults: false
317
- });
318
- onMounted(() => {
319
- const baseRaw = app.baseURL ?? "/";
320
- const base = baseRaw === "" || baseRaw === "/" ? "/" : baseRaw.replace(/\/*$/, "") + "/";
321
- let pathToMatch;
322
- if (base === "/") {
323
- pathToMatch = route.path;
324
- } else if (route.fullPath.startsWith(base)) {
325
- const remainder = route.fullPath.slice(base.length) || "/";
326
- pathToMatch = remainder.startsWith("/") ? remainder : `/${remainder}`;
327
- } else {
328
- pathToMatch = route.path;
182
+ const executeCommandEffect = (effect) => {
183
+ if (effect === void 0)
184
+ return false;
185
+ try {
186
+ let result;
187
+ try {
188
+ result = $dsl.evaluate`${effect}`();
189
+ } catch {
190
+ result = $dsl.evaluate`${normalizeEffectExpression(effect)}`();
191
+ }
192
+ const action = commandActionSchema.parse(result);
193
+ executeCommandAction(action, {
194
+ logout: $logout
195
+ });
196
+ return true;
197
+ } catch {
198
+ return false;
329
199
  }
330
- const key = findNavigationKeyByPath(navigation.value, pathToMatch);
331
- if (key !== void 0) {
332
- tabs.value.add(key);
333
- activeTab.value = key;
200
+ };
201
+ const executeProfileCommand = (effect) => {
202
+ if (effect === void 0 || executeCommandEffect(effect))
203
+ ui.isProfileDropdownOpen = false;
204
+ };
205
+ const paletteNavigation = computed(() => splitNavigationForPalette(navigations.value));
206
+ const favoriteRoutesForPalette = computed(() => paletteNavigation.value.favoriteRoutes);
207
+ const navigationForPalette = computed(() => paletteNavigation.value.navigationEntries);
208
+ const commandForPalette = computed(() => normalizeCommandsForPalette(profileCommands.value));
209
+ const executePaletteItem = (item) => {
210
+ if (item.type === "route") {
211
+ activateTab(item.route);
212
+ ui.isCommandPaletteOpen = false;
213
+ return;
334
214
  }
335
- });
336
- useHead({
337
- title: () => activeTab.value ? findNavigationByKey(navigation.value, activeTab.value)?.title ?? config.title : config.title
338
- });
215
+ if (item.effect !== void 0 && !executeCommandEffect(item.effect))
216
+ return;
217
+ ui.isCommandPaletteOpen = false;
218
+ };
219
+ watch(
220
+ () => route.path,
221
+ (path) => {
222
+ applyRootNavigationFallback({
223
+ path,
224
+ items: navigationItems.value,
225
+ tabs: tabs.value,
226
+ activateTab
227
+ });
228
+ },
229
+ { immediate: true }
230
+ );
339
231
  </script>
340
232
 
341
233
  <template>
@@ -343,9 +235,9 @@ useHead({
343
235
  <ClientOnly>
344
236
  <Toaster />
345
237
  <CommandDialog
346
- v-model:open="isCommandOpen"
238
+ v-model:open="ui.isCommandPaletteOpen"
347
239
  >
348
- <CommandInput />
240
+ <CommandInput :placeholder="t('search')" />
349
241
  <CommandList>
350
242
  <CommandEmpty as-child>
351
243
  <section class="h-32 flex flex-col text-lg items-center justify-center gap-2 select-none">
@@ -360,77 +252,103 @@ useHead({
360
252
  </CommandEmpty>
361
253
 
362
254
  <CommandGroup
363
- v-if="paletteFavorites.length > 0"
255
+ v-if="favoriteRoutesForPalette.length > 0"
364
256
  :heading="t('favorites')"
365
257
  >
366
258
  <CommandItem
367
- v-for="el in paletteFavorites"
368
- :key="el.id"
369
- :value="el.id"
370
- @select="el.effect.pipe(Effect.scoped).pipe(Effect.tap(() => {
371
- isCommandOpen = false;
372
- })).pipe(Effect.runPromise)"
259
+ v-for="menu in favoriteRoutesForPalette"
260
+ :key="`fav:${menu.id}:${menu.route}`"
261
+ :value="createPaletteItemValue('fav', `${menu.id}:${menu.route}`)"
262
+ @select="executePaletteItem({ type: 'route', route: menu.route })"
373
263
  >
374
- <Icon
375
- v-if="el.icon"
376
- :icon="el.icon"
377
- />
378
- <span>{{ el.title }}</span>
264
+ <span>{{ menu.title }}</span>
265
+ <span
266
+ v-if="menu.keywords.length > 0"
267
+ class="sr-only"
268
+ >
269
+ {{ menu.keywords.join(" ") }}
270
+ </span>
379
271
  </CommandItem>
380
272
  </CommandGroup>
381
273
 
382
274
  <template
383
- v-for="el in palette"
384
- :key="el.id"
275
+ v-for="navigation in navigationForPalette"
276
+ :key="navigation.id"
385
277
  >
386
278
  <CommandGroup
387
- v-if="el.type === 'group'"
388
- :heading="el.title"
279
+ v-if="'children' in navigation"
280
+ :heading="navigation.title"
389
281
  >
390
282
  <CommandItem
391
- v-for="item in el.children"
392
- :key="item.id"
393
- :value="item.id"
394
- :disabled="typeof item.disabled === 'string' && $dsl.evaluate`${item.disabled}`()"
395
- @select="
396
- item.effect.pipe(Effect.scoped).pipe(Effect.tap(() => {
397
- isCommandOpen = false;
398
- })).pipe(Effect.runPromise)
399
- "
283
+ v-for="menu in navigation.children"
284
+ :key="menu.id"
285
+ :value="createPaletteItemValue('nav', menu.id, navigation.id)"
286
+ @select="executePaletteItem({ type: 'route', route: menu.route })"
400
287
  >
401
- <Icon
402
- v-if="item.icon"
403
- :icon="item.icon"
404
- />
405
- <span>{{ item.title }}</span>
288
+ <span>{{ menu.title }}</span>
406
289
  <span
407
- v-if="item.keywords?.length"
290
+ v-if="menu.keywords.length > 0"
408
291
  class="sr-only"
409
292
  >
410
- {{ item.keywords.join(" ") }}
293
+ {{ menu.keywords.join(" ") }}
411
294
  </span>
412
295
  </CommandItem>
413
296
  </CommandGroup>
414
- <CommandItem
415
- v-if="el.type === 'item'"
416
- :value="el.id"
417
- :disabled="typeof el.disabled === 'string' && $dsl.evaluate`${el.disabled}`()"
418
- @select="el.effect.pipe(Effect.scoped).pipe(Effect.tap(() => {
419
- isCommandOpen = false;
420
- })).pipe(Effect.runPromise)"
297
+ <CommandGroup v-else>
298
+ <CommandItem
299
+ :value="createPaletteItemValue('nav', navigation.id)"
300
+ @select="executePaletteItem({ type: 'route', route: navigation.route })"
301
+ >
302
+ <span>{{ navigation.title }}</span>
303
+ <span
304
+ v-if="navigation.keywords.length > 0"
305
+ class="sr-only"
306
+ >
307
+ {{ navigation.keywords.join(" ") }}
308
+ </span>
309
+ </CommandItem>
310
+ </CommandGroup>
311
+ </template>
312
+
313
+ <template
314
+ v-for="command in commandForPalette"
315
+ :key="command.id"
316
+ >
317
+ <CommandGroup
318
+ v-if="'children' in command"
319
+ :heading="command.title"
421
320
  >
422
- <Icon
423
- v-if="el.icon"
424
- :icon="el.icon"
425
- />
426
- <span>{{ el.title }}</span>
427
- <span
428
- v-if="el.keywords?.length"
429
- class="sr-only"
321
+ <CommandItem
322
+ v-for="child in command.children"
323
+ :key="child.id"
324
+ :value="createPaletteItemValue('cmd', child.id, command.id)"
325
+ :disabled="child.disabled"
326
+ @select="executePaletteItem({ type: 'command', effect: child.effect })"
430
327
  >
431
- {{ el.keywords.join(" ") }}
432
- </span>
433
- </CommandItem>
328
+ <span>{{ child.title }}</span>
329
+ <span
330
+ v-if="child.keywords.length > 0"
331
+ class="sr-only"
332
+ >
333
+ {{ child.keywords.join(" ") }}
334
+ </span>
335
+ </CommandItem>
336
+ </CommandGroup>
337
+ <CommandGroup v-else>
338
+ <CommandItem
339
+ :value="createPaletteItemValue('cmd', command.id)"
340
+ :disabled="command.disabled"
341
+ @select="executePaletteItem({ type: 'command', effect: command.effect })"
342
+ >
343
+ <span>{{ command.title }}</span>
344
+ <span
345
+ v-if="command.keywords.length > 0"
346
+ class="sr-only"
347
+ >
348
+ {{ command.keywords.join(" ") }}
349
+ </span>
350
+ </CommandItem>
351
+ </CommandGroup>
434
352
  </template>
435
353
  </CommandList>
436
354
  </CommandDialog>
@@ -442,83 +360,68 @@ useHead({
442
360
  <slot name="menu" />
443
361
  <span class="flex-1" />
444
362
  <DropdownMenu
445
- v-model:open="isDropdownOpen"
363
+ v-model:open="ui.isProfileDropdownOpen"
446
364
  :modal="false"
447
- @update:open="stopDropdownCloseTimer();
448
- isDropdownOpen = $event"
365
+ @update:open="ui.isProfileDropdownOpen = $event"
449
366
  >
450
367
  <DropdownMenuTrigger as-child>
451
368
  <button
452
- ref="dropdown-trigger"
453
369
  type="button"
454
- class="text-white text-sm cursor-pointer appearance-none outline-none pl-4 py-2"
455
- @pointerenter="stopDropdownCloseTimer();
456
- isDropdownOpen = true"
457
- @pointerleave="stopDropdownCloseTimer();
458
- startDropdownCloseTimer()"
459
- @click.prevent="if (!isDropdownOpen) {
460
- stopDropdownCloseTimer();
461
- isDropdownOpen = true;
462
- }"
370
+ class="text-white text-sm cursor-pointer appearance-none outline-none py-2 px-3 rounded hover:bg-white/10"
371
+ @pointerenter="stopProfileCloseTimer();
372
+ ui.isProfileDropdownOpen = true"
373
+ @pointerleave="stopProfileCloseTimer();
374
+ startProfileCloseTimer()"
375
+ @click.prevent="stopProfileCloseTimer();
376
+ ui.isProfileDropdownOpen = true"
463
377
  >
464
- {{ $md.inline`${$config.public.shwfed.profile.name}`() }}
378
+ {{ profileName }}
465
379
  </button>
466
380
  </DropdownMenuTrigger>
467
381
  <DropdownMenuContent
468
382
  class="min-w-56"
469
383
  align="end"
470
- @pointerenter="stopDropdownCloseTimer()"
471
- @pointerleave="stopDropdownCloseTimer();
472
- startDropdownCloseTimer()"
384
+ @pointerenter="stopProfileCloseTimer()"
385
+ @pointerleave="stopProfileCloseTimer();
386
+ startProfileCloseTimer()"
473
387
  >
474
388
  <template
475
- v-for="el in profile"
476
- :key="el.id"
389
+ v-for="command in profileCommands"
390
+ :key="command.id"
477
391
  >
478
- <template v-if="el.type === 'group'">
392
+ <template v-if="'children' in command">
479
393
  <DropdownMenuLabel>
480
- {{ el.title }}
394
+ {{ command.title }}
481
395
  </DropdownMenuLabel>
482
396
  <DropdownMenuGroup>
483
397
  <DropdownMenuItem
484
- v-for="item in el.children"
485
- :key="item.id"
486
- :disabled="typeof item.disabled === 'string' && $dsl.evaluate`${item.disabled}`()"
487
- @select="item.effect.pipe(Effect.scoped).pipe(Effect.tap(() => {
488
- isDropdownOpen = false;
489
- })).pipe(Effect.runPromise)"
398
+ v-for="child in command.children"
399
+ :key="child.id"
400
+ :disabled="child.disabled"
401
+ @select="executeProfileCommand(child.effect)"
490
402
  >
491
403
  <Icon
492
- v-if="item.icon"
493
- :icon="item.icon"
404
+ v-if="child.icon"
405
+ :icon="child.icon"
494
406
  />
495
- {{ item.title }}
407
+ {{ child.title }}
496
408
  </DropdownMenuItem>
497
409
  </DropdownMenuGroup>
498
410
  <DropdownMenuSeparator />
499
411
  </template>
500
- <template v-else>
501
- <DropdownMenuItem
502
- :disabled="typeof el.disabled === 'string' && $dsl.evaluate`${el.disabled}`()"
503
- @select="el.effect.pipe(Effect.scoped).pipe(Effect.tap(() => {
504
- isDropdownOpen = false;
505
- })).pipe(Effect.runPromise)"
506
- >
507
- <Icon
508
- v-if="el.icon"
509
- :icon="el.icon"
510
- />
511
- {{ el.title }}
512
- </DropdownMenuItem>
513
- </template>
412
+ <DropdownMenuItem
413
+ v-else
414
+ :disabled="command.disabled"
415
+ @select="executeProfileCommand(command.effect)"
416
+ >
417
+ <Icon
418
+ v-if="command.icon"
419
+ :icon="command.icon"
420
+ />
421
+ {{ command.title }}
422
+ </DropdownMenuItem>
514
423
  </template>
515
- <DropdownMenuItem
516
- @select="$logout"
517
- >
518
- <Icon icon="fluent:sign-out-20-regular" />
519
- {{ t("logout") }}
520
- </DropdownMenuItem>
521
- <DropdownMenuSeparator />
424
+ <DropdownMenuSeparator v-if="profileCommands.length > 0" />
522
425
  <DropdownMenuItem disabled>
523
426
  <Icon icon="fluent:history-20-regular" />
524
427
  {{ t("build") }}
@@ -539,178 +442,122 @@ useHead({
539
442
  collapsible="icon"
540
443
  class="sticky h-full"
541
444
  >
542
- <SidebarContent>
543
- <SidebarGroup v-if="navigationFavoritesItems.length > 0">
544
- <SidebarGroupLabel>
545
- {{ t("favorites") }}
546
- </SidebarGroupLabel>
547
- <SidebarMenu>
548
- <SidebarMenuItem
549
- v-for="el in navigationFavoritesItems"
550
- :key="el.id"
551
- >
552
- <SidebarMenuButton
445
+ <SidebarContent class="gap-0 py-1">
446
+ <template
447
+ v-for="group in navigations"
448
+ :key="group.id"
449
+ >
450
+ <Collapsible
451
+ v-if="'children' in group"
452
+ default-open
453
+ class="group/collapsible"
454
+ >
455
+ <SidebarGroup>
456
+ <SidebarGroupLabel
553
457
  as-child
554
- :is-active="el.id === activeTab"
458
+ class="group/label text-sm text-zinc-700 hover:bg-zinc-100 hover:text-zinc-900"
555
459
  >
556
- <button
557
- type="button"
558
- class="w-full"
559
- @click="el.effect.pipe(Effect.scoped).pipe(Effect.runPromise)"
560
- >
460
+ <CollapsibleTrigger>
561
461
  <Icon
562
- v-if="el.icon"
563
- :icon="el.icon"
462
+ v-if="group.icon"
463
+ :icon="group.icon"
564
464
  />
565
- {{ el.title }}
566
- </button>
567
- </SidebarMenuButton>
568
- <SidebarMenuAction
569
- show-on-hover
570
- as-child
571
- >
572
- <button
573
- type="button"
574
- @click="() => {
575
- navigationFavorites = navigationFavorites.filter((id) => id !== el.id);
576
- }"
577
- >
465
+ {{ group.title }}
578
466
  <Icon
579
- icon="fluent:star-off-20-regular"
467
+ icon="fluent:chevron-right-20-regular"
468
+ class="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90"
580
469
  />
581
- </button>
582
- </SidebarMenuAction>
583
- </SidebarMenuItem>
584
- </SidebarMenu>
585
- </SidebarGroup>
586
- <SidebarGroup>
587
- <SidebarGroupLabel>
588
- {{ t("navigation") }}
589
- </SidebarGroupLabel>
590
- <SidebarMenu>
591
- <template
592
- v-for="el in navigation"
593
- :key="el.id"
594
- >
595
- <Collapsible
596
- v-if="el.type === 'group'"
597
- class="group/collapsible"
598
- :default-open="el.children.some((child) => toNavigationKey(el.id, child.id) === activeTab)"
599
- as-child
600
- >
601
- <SidebarMenuItem>
602
- <CollapsibleTrigger as-child>
603
- <SidebarMenuButton :tooltip="el.title">
604
- <Icon
605
- v-if="el.icon"
606
- :icon="el.icon"
607
- />
608
- {{ el.title }}
609
- <Icon
610
- icon="fluent:chevron-right-20-regular"
611
- class="absolute right-1 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
612
- />
613
- </SidebarMenuButton>
614
- <CollapsibleContent>
615
- <SidebarMenuSub>
616
- <SidebarMenuSubItem
617
- v-for="item in el.children"
618
- :key="item.id"
619
- class="group/menu-sub-item"
470
+ </CollapsibleTrigger>
471
+ </SidebarGroupLabel>
472
+ <CollapsibleContent class="overflow-hidden data-[state=open]:overflow-visible data-[state=open]:h-auto">
473
+ <SidebarGroupContent>
474
+ <SidebarMenu>
475
+ <SidebarMenuItem
476
+ v-for="menu in group.children"
477
+ :key="menu.id"
478
+ >
479
+ <SidebarMenuButton
480
+ v-if="'route' in menu"
481
+ as-child
482
+ :is-active="isNavigationItemActive(menu.route)"
483
+ >
484
+ <button
485
+ type="button"
486
+ @click="activateTab(menu.route)"
620
487
  >
621
- <SidebarMenuSubButton
622
- as-child
623
- :is-active="toNavigationKey(el.id, item.id) === activeTab"
624
- >
625
- <button
626
- type="button"
627
- class="w-full"
628
- @click="item.effect.pipe(Effect.scoped).pipe(Effect.runPromise)"
629
- >
630
- <Icon
631
- v-if="item.icon"
632
- :icon="item.icon"
633
- />
634
- {{ item.title }}
635
- </button>
636
- </SidebarMenuSubButton>
637
- <SidebarMenuAction
638
- :class="['transition-opacity duration-80 text-yellow-400 -translate-y-1/2', !navigationFavorites.includes(toNavigationKey(el.id, item.id)) && 'group-hover/menu-sub-item:opacity-100 opacity-0 ']"
639
- as-child
640
- >
641
- <button
642
- type="button"
643
- @click="() => {
644
- const key = toNavigationKey(el.id, item.id);
645
- if (navigationFavorites.includes(key)) {
646
- navigationFavorites = navigationFavorites.filter((id) => id !== key);
647
- } else {
648
- navigationFavorites = Array.from(/* @__PURE__ */ new Set([...navigationFavorites, key]));
649
- }
650
- }"
651
- >
652
- <Icon
653
- v-if="navigationFavorites.includes(toNavigationKey(el.id, item.id))"
654
- icon="fluent:star-20-filled"
655
- class="text-yellow-400"
656
- />
657
- <Icon
658
- v-else
659
- icon="fluent:star-20-regular"
660
- class="text-yellow-400"
661
- />
662
- </button>
663
- </SidebarMenuAction>
664
- </SidebarMenuSubItem>
665
- </SidebarMenuSub>
666
- </CollapsibleContent>
667
- </CollapsibleTrigger>
668
- </SidebarMenuItem>
669
- </Collapsible>
670
- <SidebarMenuItem v-else>
671
- <Icon
672
- v-if="el.icon"
673
- :icon="el.icon"
674
- />
675
- {{ el.title }}
676
- </SidebarMenuItem>
677
- </template>
678
- </SidebarMenu>
679
- </SidebarGroup>
488
+ <Icon
489
+ v-if="menu.icon"
490
+ :icon="menu.icon"
491
+ />
492
+ {{ menu.title }}
493
+ </button>
494
+ </SidebarMenuButton>
495
+ <SidebarMenuAction
496
+ v-if="canBeNavigationFavorited(menu, group.id)"
497
+ :show-on-hover="!isNavigationFavorited(menu, group.id)"
498
+ class="text-yellow-500!"
499
+ @click.stop="toggleNavigationFavorite(menu, group.id)"
500
+ >
501
+ <Icon
502
+ :icon="isNavigationFavorited(menu, group.id) ? 'fluent:star-20-filled' : 'fluent:star-20-regular'"
503
+ />
504
+ </SidebarMenuAction>
505
+ </SidebarMenuItem>
506
+ </SidebarMenu>
507
+ </SidebarGroupContent>
508
+ </CollapsibleContent>
509
+ </SidebarGroup>
510
+ </Collapsible>
511
+ <template v-else-if="'route' in group">
512
+ <SidebarGroup>
513
+ <SidebarGroupContent>
514
+ <SidebarMenu>
515
+ <SidebarMenuItem>
516
+ <SidebarMenuButton
517
+ as-child
518
+ :is-active="isNavigationItemActive(group.route)"
519
+ >
520
+ <button
521
+ type="button"
522
+ @click="activateTab(group.route)"
523
+ >
524
+ <Icon
525
+ v-if="group.icon"
526
+ :icon="group.icon"
527
+ />
528
+ {{ group.title }}
529
+ </button>
530
+ </SidebarMenuButton>
531
+ </SidebarMenuItem>
532
+ </SidebarMenu>
533
+ </SidebarGroupContent>
534
+ </SidebarGroup>
535
+ </template>
536
+ </template>
680
537
  </SidebarContent>
681
538
  </Sidebar>
682
539
  </SidebarProvider>
683
540
  <main class="flex-1 flex flex-col bg-zinc-100 overflow-hidden">
684
- <nav class="bg-white p-1 border-b border-zinc-100 h-10 flex items-center justify-start gap-1 overflow-x-hidden min-w-0">
541
+ <nav class="bg-white p-1 border-b border-zinc-100 h-10 flex items-center justify-start gap-1 overflow-x-auto min-w-0">
685
542
  <div
686
- v-for="tab in tabs"
543
+ v-for="tab in tabList"
687
544
  :key="tab"
688
- :class="['border rounded cursor-pointer px-2 py-1 text-sm flex items-center gap-2 truncate', activeTab === tab ? 'text-(--primary) border-(--primary)' : 'text-zinc-500 border-zinc-300 hover:text-zinc-700']"
689
- @click="() => {
690
- activeTab = tab;
691
- const item2 = findNavigationByKey(navigation, tab);
692
- $router.replace(item2?.target ?? '');
693
- }"
545
+ :class="[
546
+ 'h-8 max-w-60 border rounded px-2 text-sm flex items-center gap-2 shrink-0 min-w-0 transition-colors',
547
+ active === tab ? 'text-(--primary) border-(--primary) bg-cyan-50' : 'text-zinc-600 border-zinc-200 hover:text-zinc-900 hover:border-zinc-300'
548
+ ]"
694
549
  >
695
- {{ findNavigationByKey(navigation, tab)?.title ?? tab ?? "Unknown" }}
696
550
  <button
697
- v-if="tabs.size > 1"
698
551
  type="button"
699
- class="opacity-80 group-hover/tab:opacity-100 transition-opacity cursor-pointer text-zinc-500 hover:text-(--primary) duration-180"
700
- @click.stop="() => {
701
- if (activeTab === tab) {
702
- const idx = Array.from(tabs).findIndex((t2) => t2 === activeTab) - 1;
703
- const previous = Array.from(tabs)[idx];
704
- if (previous) {
705
- activeTab = previous;
706
- const item2 = findNavigationByKey(navigation, previous);
707
- $router.replace(item2?.target ?? '');
708
- } else {
709
- activeTab = void 0;
710
- }
711
- }
712
- tabs.delete(tab);
713
- }"
552
+ class="truncate cursor-pointer"
553
+ @click="activateTab(tab)"
554
+ >
555
+ {{ getTabLabel(tab) }}
556
+ </button>
557
+ <button
558
+ type="button"
559
+ class="cursor-pointer text-zinc-500 hover:text-zinc-900"
560
+ @click.stop="closeTab(tab)"
714
561
  >
715
562
  <Icon icon="fluent:dismiss-20-regular" />
716
563
  </button>
@@ -729,23 +576,29 @@ useHead({
729
576
  {
730
577
  "zh": {
731
578
  "command-palette-empty": "无搜索结果",
579
+ "search": "搜索",
732
580
  "logout": "退出登录",
733
581
  "build": "构建",
734
582
  "navigation": "导航",
583
+ "profile": "个人信息",
735
584
  "favorites": "收藏"
736
585
  },
737
586
  "ja": {
738
587
  "command-palette-empty": "結果はありません",
588
+ "search": "検索",
739
589
  "logout": "ログアウト",
740
590
  "build": "ビルド",
741
591
  "navigation": "ナビゲーション",
592
+ "profile": "プロフィール",
742
593
  "favorites": "お気に入り"
743
594
  },
744
595
  "en": {
745
596
  "command-palette-empty": "No results",
597
+ "search": "Search",
746
598
  "logout": "Logout",
747
599
  "build": "Build",
748
600
  "navigation": "Navigation",
601
+ "profile": "Profile",
749
602
  "favorites": "Favorites"
750
603
  }
751
604
  }