@vincent99/vlib 0.1.0

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.
Files changed (92) hide show
  1. package/LICENSE +178 -0
  2. package/README.md +107 -0
  3. package/bin/vlib.js +10 -0
  4. package/dist/AdminForm.vue_vue_type_style_index_0_lang-xCk1ywLq.js +753 -0
  5. package/dist/auth/middleware.d.ts +18 -0
  6. package/dist/auth/middleware.d.ts.map +1 -0
  7. package/dist/auth/middleware.js +44 -0
  8. package/dist/auth/middleware.js.map +1 -0
  9. package/dist/auth/password.d.ts +10 -0
  10. package/dist/auth/password.d.ts.map +1 -0
  11. package/dist/auth/password.js +44 -0
  12. package/dist/auth/password.js.map +1 -0
  13. package/dist/cli.d.ts +3 -0
  14. package/dist/cli.d.ts.map +1 -0
  15. package/dist/cli.js +104 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/components/AdminForm.vue.d.ts +7 -0
  18. package/dist/components/AdminTable.vue.d.ts +5 -0
  19. package/dist/components/AppLayout.vue.d.ts +36 -0
  20. package/dist/components/NavSidebar.vue.d.ts +11 -0
  21. package/dist/components/TableView.vue.d.ts +52 -0
  22. package/dist/components/index.d.ts +6 -0
  23. package/dist/components/index.js +8 -0
  24. package/dist/components/types.d.ts +25 -0
  25. package/dist/db/index.d.ts +12 -0
  26. package/dist/db/index.d.ts.map +1 -0
  27. package/dist/db/index.js +84 -0
  28. package/dist/db/index.js.map +1 -0
  29. package/dist/db/migrate.d.ts +2 -0
  30. package/dist/db/migrate.d.ts.map +1 -0
  31. package/dist/db/migrate.js +94 -0
  32. package/dist/db/migrate.js.map +1 -0
  33. package/dist/index.d.ts +3 -0
  34. package/dist/index.js +11 -0
  35. package/dist/router/index.d.ts +33 -0
  36. package/dist/router/index.js +62 -0
  37. package/dist/server/api/admin.d.ts +3 -0
  38. package/dist/server/api/admin.d.ts.map +1 -0
  39. package/dist/server/api/admin.js +184 -0
  40. package/dist/server/api/admin.js.map +1 -0
  41. package/dist/server/api/auth.d.ts +3 -0
  42. package/dist/server/api/auth.d.ts.map +1 -0
  43. package/dist/server/api/auth.js +66 -0
  44. package/dist/server/api/auth.js.map +1 -0
  45. package/dist/server/index.d.ts +17 -0
  46. package/dist/server/index.d.ts.map +1 -0
  47. package/dist/server/index.js +47 -0
  48. package/dist/server/index.js.map +1 -0
  49. package/dist/types.d.ts +53 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +3 -0
  52. package/dist/types.js.map +1 -0
  53. package/dist/vlib.css +1 -0
  54. package/package.json +91 -0
  55. package/src/components/AdminForm.vue +491 -0
  56. package/src/components/AdminTable.vue +269 -0
  57. package/src/components/AppLayout.vue +280 -0
  58. package/src/components/NavSidebar.vue +176 -0
  59. package/src/components/TableView.vue +379 -0
  60. package/src/components/index.ts +13 -0
  61. package/src/components/types.ts +28 -0
  62. package/templates/.env.example +4 -0
  63. package/templates/.prettierignore +3 -0
  64. package/templates/.prettierrc +6 -0
  65. package/templates/Dockerfile.ejs +31 -0
  66. package/templates/docker-compose.prod.yml.ejs +22 -0
  67. package/templates/docker-compose.yml.ejs +22 -0
  68. package/templates/eslint.config.mjs +42 -0
  69. package/templates/index.html.ejs +13 -0
  70. package/templates/package.json.ejs +44 -0
  71. package/templates/postcss.config.js.ejs +6 -0
  72. package/templates/schemas/001-initial.sql +35 -0
  73. package/templates/scripts/migrate.ts +13 -0
  74. package/templates/server/index.ts +13 -0
  75. package/templates/src/App.vue +8 -0
  76. package/templates/src/main.ts +6 -0
  77. package/templates/src/router.ts +26 -0
  78. package/templates/src/routes/_layout.vue +58 -0
  79. package/templates/src/routes/admin/_layout.vue +8 -0
  80. package/templates/src/routes/admin/index.vue +88 -0
  81. package/templates/src/routes/admin/tables/[table]/[id].vue +20 -0
  82. package/templates/src/routes/admin/tables/[table]/index.vue +10 -0
  83. package/templates/src/routes/admin/tables/[table]/new.vue +10 -0
  84. package/templates/src/routes/index.vue +34 -0
  85. package/templates/src/routes/login.vue +128 -0
  86. package/templates/src/stores/auth.ts +58 -0
  87. package/templates/src/styles/main.scss +98 -0
  88. package/templates/src/styles/variables.scss +7 -0
  89. package/templates/tailwind.config.js.ejs +27 -0
  90. package/templates/tsconfig.json.ejs +26 -0
  91. package/templates/tsconfig.server.json.ejs +17 -0
  92. package/templates/vite.config.ts.ejs +36 -0
@@ -0,0 +1,269 @@
1
+ <template>
2
+ <div class="vl-admin-table">
3
+ <div class="vl-admin-table__header">
4
+ <h1 class="vl-admin-table__title">{{ tableName }}</h1>
5
+ <RouterLink
6
+ :to="`/admin/tables/${tableName}/new`"
7
+ class="vl-btn vl-btn--primary"
8
+ >
9
+ + Add Row
10
+ </RouterLink>
11
+ </div>
12
+
13
+ <div v-if="error" class="vl-alert vl-alert--error">{{ error }}</div>
14
+
15
+ <TableView
16
+ :rows="rows"
17
+ :headers="tableHeaders"
18
+ :total="total"
19
+ :page="page"
20
+ :page-size="pageSize"
21
+ :actions="bulkActions"
22
+ @update:page="loadPage"
23
+ @action="handleBulkAction"
24
+ @sort="handleSort"
25
+ >
26
+ <template #row-actions="{ row, id }">
27
+ <RouterLink
28
+ :to="`/admin/tables/${tableName}/${id}`"
29
+ class="vl-btn vl-btn--sm"
30
+ >Edit</RouterLink
31
+ >
32
+ <button
33
+ class="vl-btn vl-btn--sm vl-btn--danger"
34
+ @click="confirmDelete([id])"
35
+ >
36
+ Del
37
+ </button>
38
+ </template>
39
+ </TableView>
40
+
41
+ <!-- Delete confirmation dialog -->
42
+ <div
43
+ v-if="deleteTarget"
44
+ class="vl-modal-overlay"
45
+ @click.self="deleteTarget = null"
46
+ >
47
+ <div class="vl-modal">
48
+ <h3 class="vl-modal__title">Confirm Delete</h3>
49
+ <p>
50
+ Delete {{ deleteTarget.length }} row{{
51
+ deleteTarget.length !== 1 ? 's' : ''
52
+ }}?<br />This cannot be undone.
53
+ </p>
54
+ <div class="vl-modal__actions">
55
+ <button class="vl-btn" @click="deleteTarget = null">Cancel</button>
56
+ <button class="vl-btn vl-btn--danger" @click="executeDelete">
57
+ Delete
58
+ </button>
59
+ </div>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ </template>
64
+
65
+ <script setup lang="ts">
66
+ import { ref, computed, watch, onMounted } from 'vue';
67
+ import TableView from './TableView.vue';
68
+ import type { TableHeader } from './types.js';
69
+
70
+ const props = defineProps<{ tableName: string }>();
71
+
72
+ const rows = ref<Record<string, unknown>[]>([]);
73
+ const columns = ref<Array<{ name: string; type: string }>>([]);
74
+ const total = ref(0);
75
+ const page = ref(1);
76
+ const pageSize = ref(50);
77
+ const sortKey = ref<string | undefined>(undefined);
78
+ const sortDir = ref<'asc' | 'desc'>('asc');
79
+ const error = ref<string | null>(null);
80
+ const deleteTarget = ref<(string | number)[] | null>(null);
81
+
82
+ const tableHeaders = computed<TableHeader[]>(() =>
83
+ columns.value.map((c) => ({ key: c.name, label: c.name, sortable: true }))
84
+ );
85
+
86
+ const bulkActions = [
87
+ { key: 'delete', label: 'Delete Selected', variant: 'danger' as const },
88
+ ];
89
+
90
+ async function loadPage(p: number) {
91
+ page.value = p;
92
+ await fetchRows();
93
+ }
94
+
95
+ async function fetchRows() {
96
+ error.value = null;
97
+ try {
98
+ const params = new URLSearchParams({
99
+ page: String(page.value),
100
+ pageSize: String(pageSize.value),
101
+ ...(sortKey.value ? { orderBy: sortKey.value, dir: sortDir.value } : {}),
102
+ });
103
+ const res = await fetch(`/api/admin/tables/${props.tableName}?${params}`);
104
+ if (!res.ok) {
105
+ throw new Error((await res.json()).error);
106
+ }
107
+ const data = await res.json();
108
+ rows.value = data.rows;
109
+ total.value = data.total;
110
+
111
+ // Infer columns from first row if not loaded
112
+ if (columns.value.length === 0 && data.rows.length > 0) {
113
+ columns.value = Object.keys(data.rows[0]).map((k) => ({
114
+ name: k,
115
+ type: 'TEXT',
116
+ }));
117
+ }
118
+ } catch (e) {
119
+ error.value = String(e);
120
+ }
121
+ }
122
+
123
+ async function fetchColumns() {
124
+ try {
125
+ const res = await fetch('/api/admin/tables');
126
+ if (!res.ok) {
127
+ return;
128
+ }
129
+ const tables: Array<{
130
+ name: string;
131
+ columns: Array<{ name: string; type: string }>;
132
+ }> = await res.json();
133
+ const tableInfo = tables.find((t) => t.name === props.tableName);
134
+ if (tableInfo) {
135
+ columns.value = tableInfo.columns;
136
+ }
137
+ } catch {
138
+ /* ignore */
139
+ }
140
+ }
141
+
142
+ function handleSort(key: string, dir: 'asc' | 'desc') {
143
+ sortKey.value = key;
144
+ sortDir.value = dir;
145
+ page.value = 1;
146
+ fetchRows();
147
+ }
148
+
149
+ function handleBulkAction(key: string, ids: (string | number)[]) {
150
+ if (key === 'delete') {
151
+ confirmDelete(ids);
152
+ }
153
+ }
154
+
155
+ function confirmDelete(ids: (string | number)[]) {
156
+ deleteTarget.value = ids;
157
+ }
158
+
159
+ async function executeDelete() {
160
+ if (!deleteTarget.value) {
161
+ return;
162
+ }
163
+ const ids = deleteTarget.value;
164
+ deleteTarget.value = null;
165
+ try {
166
+ const res = await fetch(`/api/admin/tables/${props.tableName}`, {
167
+ method: 'DELETE',
168
+ headers: { 'Content-Type': 'application/json' },
169
+ body: JSON.stringify({ ids }),
170
+ });
171
+ if (!res.ok) {
172
+ throw new Error((await res.json()).error);
173
+ }
174
+ await fetchRows();
175
+ } catch (e) {
176
+ error.value = String(e);
177
+ }
178
+ }
179
+
180
+ watch(
181
+ () => props.tableName,
182
+ () => {
183
+ page.value = 1;
184
+ columns.value = [];
185
+ fetchColumns();
186
+ fetchRows();
187
+ }
188
+ );
189
+
190
+ onMounted(() => {
191
+ fetchColumns();
192
+ fetchRows();
193
+ });
194
+ </script>
195
+
196
+ <style lang="scss">
197
+ .vl-admin-table {
198
+ &__header {
199
+ display: flex;
200
+ align-items: center;
201
+ justify-content: space-between;
202
+ margin-bottom: var(--space-4);
203
+ }
204
+
205
+ &__title {
206
+ font-size: 1.5rem;
207
+ font-weight: 700;
208
+ color: var(--color-text);
209
+ text-transform: capitalize;
210
+ }
211
+ }
212
+
213
+ .vl-alert {
214
+ padding: var(--space-3) var(--space-4);
215
+ border-radius: var(--radius);
216
+ margin-bottom: var(--space-4);
217
+ font-size: 0.875rem;
218
+
219
+ &--error {
220
+ background: #fef2f2;
221
+ border: 1px solid #fecaca;
222
+ color: #dc2626;
223
+ }
224
+
225
+ &--success {
226
+ background: #f0fdf4;
227
+ border: 1px solid #bbf7d0;
228
+ color: #16a34a;
229
+ }
230
+ }
231
+
232
+ .vl-modal-overlay {
233
+ position: fixed;
234
+ inset: 0;
235
+ background: rgba(0, 0, 0, 0.5);
236
+ display: flex;
237
+ align-items: center;
238
+ justify-content: center;
239
+ z-index: 300;
240
+ }
241
+
242
+ .vl-modal {
243
+ background: white;
244
+ border-radius: var(--radius-lg);
245
+ padding: var(--space-6);
246
+ min-width: 320px;
247
+ max-width: 480px;
248
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
249
+
250
+ &__title {
251
+ font-size: 1.1rem;
252
+ font-weight: 700;
253
+ margin-bottom: var(--space-3);
254
+ color: var(--color-text);
255
+ }
256
+
257
+ p {
258
+ color: var(--color-text-secondary);
259
+ margin-bottom: var(--space-5);
260
+ line-height: 1.5;
261
+ }
262
+
263
+ &__actions {
264
+ display: flex;
265
+ gap: var(--space-2);
266
+ justify-content: flex-end;
267
+ }
268
+ }
269
+ </style>
@@ -0,0 +1,280 @@
1
+ <template>
2
+ <div class="vl-app">
3
+ <!-- Header -->
4
+ <header class="vl-header">
5
+ <div class="vl-header__left">
6
+ <!-- Hamburger (small screens only) -->
7
+ <button
8
+ class="vl-hamburger"
9
+ aria-label="Toggle navigation"
10
+ @click="sidebarOpen = !sidebarOpen"
11
+ >
12
+ <span class="vl-hamburger__bar" />
13
+ <span class="vl-hamburger__bar" />
14
+ <span class="vl-hamburger__bar" />
15
+ </button>
16
+ <slot name="logo">
17
+ <span class="vl-header__title">{{ appName }}</span>
18
+ </slot>
19
+ </div>
20
+ <div class="vl-header__right">
21
+ <!-- User menu -->
22
+ <div v-if="user" ref="userMenuRef" class="vl-user-menu">
23
+ <button
24
+ class="vl-user-menu__btn"
25
+ @click="userMenuOpen = !userMenuOpen"
26
+ >
27
+ <span class="vl-user-menu__name">{{
28
+ user.displayName || user.username
29
+ }}</span>
30
+ <span class="vl-user-menu__caret">▾</span>
31
+ </button>
32
+ <div v-if="userMenuOpen" class="vl-user-menu__dropdown">
33
+ <button class="vl-user-menu__item" @click="logout">Logout</button>
34
+ </div>
35
+ </div>
36
+ </div>
37
+ </header>
38
+
39
+ <!-- Body -->
40
+ <div class="vl-body">
41
+ <!-- Overlay for mobile sidebar -->
42
+ <div
43
+ v-if="sidebarOpen"
44
+ class="vl-sidebar-overlay"
45
+ @click="sidebarOpen = false"
46
+ />
47
+
48
+ <!-- Sidebar -->
49
+ <nav :class="['vl-sidebar', { 'vl-sidebar--open': sidebarOpen }]">
50
+ <slot name="sidebar">
51
+ <NavSidebar :items="navItems" @navigate="sidebarOpen = false" />
52
+ </slot>
53
+ </nav>
54
+
55
+ <!-- Main content -->
56
+ <main class="vl-main">
57
+ <slot />
58
+ </main>
59
+ </div>
60
+ </div>
61
+ </template>
62
+
63
+ <script setup lang="ts">
64
+ import { ref, onMounted, onUnmounted } from 'vue';
65
+ import { useRouter } from 'vue-router';
66
+ import NavSidebar from './NavSidebar.vue';
67
+ import type { AppLayoutProps, NavItem } from './types.js';
68
+
69
+ export type { AppLayoutProps, NavItem };
70
+
71
+ const props = withDefaults(defineProps<AppLayoutProps>(), {
72
+ appName: 'App',
73
+ user: null,
74
+ navItems: () => [],
75
+ });
76
+
77
+ const emit = defineEmits<{
78
+ logout: [];
79
+ }>();
80
+
81
+ const router = useRouter();
82
+ const sidebarOpen = ref(false);
83
+ const userMenuOpen = ref(false);
84
+ const userMenuRef = ref<HTMLElement | null>(null);
85
+
86
+ function logout() {
87
+ userMenuOpen.value = false;
88
+ emit('logout');
89
+ }
90
+
91
+ function handleClickOutside(e: MouseEvent) {
92
+ if (userMenuRef.value && !userMenuRef.value.contains(e.target as Node)) {
93
+ userMenuOpen.value = false;
94
+ }
95
+ }
96
+
97
+ // Close sidebar on route change
98
+ router.afterEach(() => {
99
+ sidebarOpen.value = false;
100
+ });
101
+
102
+ onMounted(() => document.addEventListener('click', handleClickOutside));
103
+ onUnmounted(() => document.removeEventListener('click', handleClickOutside));
104
+ </script>
105
+
106
+ <style lang="scss">
107
+ .vl-app {
108
+ display: flex;
109
+ flex-direction: column;
110
+ height: 100vh;
111
+ overflow: hidden;
112
+ }
113
+
114
+ .vl-header {
115
+ display: flex;
116
+ align-items: center;
117
+ justify-content: space-between;
118
+ height: var(--header-height);
119
+ padding: 0 var(--space-4);
120
+ background: var(--color-header);
121
+ color: white;
122
+ position: sticky;
123
+ top: 0;
124
+ z-index: 100;
125
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
126
+
127
+ &__left {
128
+ display: flex;
129
+ align-items: center;
130
+ gap: var(--space-3);
131
+ }
132
+
133
+ &__right {
134
+ display: flex;
135
+ align-items: center;
136
+ gap: var(--space-3);
137
+ }
138
+
139
+ &__title {
140
+ font-size: 1.2rem;
141
+ font-weight: 600;
142
+ letter-spacing: 0.02em;
143
+ }
144
+ }
145
+
146
+ .vl-hamburger {
147
+ display: none;
148
+ flex-direction: column;
149
+ gap: 5px;
150
+ background: none;
151
+ border: none;
152
+ cursor: pointer;
153
+ padding: var(--space-1);
154
+
155
+ &__bar {
156
+ display: block;
157
+ width: 22px;
158
+ height: 2px;
159
+ background: white;
160
+ border-radius: 2px;
161
+ transition: all 0.2s;
162
+ }
163
+
164
+ @media (max-width: 767px) {
165
+ display: flex;
166
+ }
167
+ }
168
+
169
+ .vl-body {
170
+ display: flex;
171
+ flex: 1;
172
+ overflow: hidden;
173
+ position: relative;
174
+ }
175
+
176
+ .vl-sidebar-overlay {
177
+ display: none;
178
+ position: fixed;
179
+ inset: 0;
180
+ background: rgba(0, 0, 0, 0.4);
181
+ z-index: 49;
182
+
183
+ @media (max-width: 767px) {
184
+ display: block;
185
+ }
186
+ }
187
+
188
+ .vl-sidebar {
189
+ width: var(--sidebar-width);
190
+ background: var(--color-sidebar);
191
+ color: white;
192
+ overflow-y: auto;
193
+ flex-shrink: 0;
194
+ transition: transform 0.25s ease;
195
+
196
+ @media (max-width: 767px) {
197
+ position: fixed;
198
+ top: var(--header-height);
199
+ left: 0;
200
+ bottom: 0;
201
+ z-index: 50;
202
+ transform: translateX(-100%);
203
+
204
+ &--open {
205
+ transform: translateX(0);
206
+ }
207
+ }
208
+ }
209
+
210
+ .vl-main {
211
+ flex: 1;
212
+ overflow-y: auto;
213
+ padding: var(--space-6);
214
+ background: var(--color-background);
215
+
216
+ @media (max-width: 767px) {
217
+ padding: var(--space-4);
218
+ }
219
+ }
220
+
221
+ // User menu
222
+ .vl-user-menu {
223
+ position: relative;
224
+
225
+ &__btn {
226
+ display: flex;
227
+ align-items: center;
228
+ gap: var(--space-2);
229
+ background: rgba(255, 255, 255, 0.15);
230
+ border: 1px solid rgba(255, 255, 255, 0.3);
231
+ color: white;
232
+ padding: var(--space-1) var(--space-3);
233
+ border-radius: var(--radius);
234
+ cursor: pointer;
235
+ font-size: 0.875rem;
236
+ transition: background 0.15s;
237
+
238
+ &:hover {
239
+ background: rgba(255, 255, 255, 0.25);
240
+ }
241
+ }
242
+
243
+ &__name {
244
+ max-width: 160px;
245
+ overflow: hidden;
246
+ text-overflow: ellipsis;
247
+ white-space: nowrap;
248
+ }
249
+
250
+ &__dropdown {
251
+ position: absolute;
252
+ top: calc(100% + 6px);
253
+ right: 0;
254
+ background: white;
255
+ border: 1px solid var(--color-border);
256
+ border-radius: var(--radius);
257
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
258
+ min-width: 160px;
259
+ overflow: hidden;
260
+ z-index: 200;
261
+ }
262
+
263
+ &__item {
264
+ display: block;
265
+ width: 100%;
266
+ text-align: left;
267
+ padding: var(--space-2) var(--space-4);
268
+ background: none;
269
+ border: none;
270
+ cursor: pointer;
271
+ color: var(--color-text);
272
+ font-size: 0.875rem;
273
+ transition: background 0.15s;
274
+
275
+ &:hover {
276
+ background: var(--color-background);
277
+ }
278
+ }
279
+ }
280
+ </style>
@@ -0,0 +1,176 @@
1
+ <template>
2
+ <div class="vl-nav">
3
+ <template v-for="item in items" :key="item.label">
4
+ <!-- Group -->
5
+ <div v-if="item.children" class="vl-nav__group">
6
+ <button class="vl-nav__group-header" @click="toggleGroup(item.label)">
7
+ <span v-if="item.icon" class="vl-nav__group-icon">{{
8
+ item.icon
9
+ }}</span>
10
+ <span class="vl-nav__group-label">{{ item.label }}</span>
11
+ <span
12
+ class="vl-nav__group-caret"
13
+ :class="{ 'vl-nav__group-caret--open': isOpen(item.label) }"
14
+ >▸</span
15
+ >
16
+ </button>
17
+ <div v-show="isOpen(item.label)" class="vl-nav__group-children">
18
+ <RouterLink
19
+ v-for="child in item.children"
20
+ :key="child.to"
21
+ :to="child.to"
22
+ class="vl-nav__link vl-nav__link--child"
23
+ active-class="vl-nav__link--active"
24
+ exact-active-class="vl-nav__link--exact-active"
25
+ @click="$emit('navigate')"
26
+ >
27
+ <span v-if="child.icon" class="vl-nav__link-icon">{{
28
+ child.icon
29
+ }}</span>
30
+ {{ child.label }}
31
+ </RouterLink>
32
+ </div>
33
+ </div>
34
+
35
+ <!-- Plain link -->
36
+ <RouterLink
37
+ v-else
38
+ :to="item.to!"
39
+ class="vl-nav__link"
40
+ active-class="vl-nav__link--active"
41
+ exact-active-class="vl-nav__link--exact-active"
42
+ @click="$emit('navigate')"
43
+ >
44
+ <span v-if="item.icon" class="vl-nav__link-icon">{{ item.icon }}</span>
45
+ {{ item.label }}
46
+ </RouterLink>
47
+ </template>
48
+ </div>
49
+ </template>
50
+
51
+ <script setup lang="ts">
52
+ import { ref } from 'vue';
53
+ import type { NavItem } from './types.js';
54
+
55
+ export type { NavItem };
56
+
57
+ const props = defineProps<{ items: NavItem[] }>();
58
+ defineEmits<{ navigate: [] }>();
59
+
60
+ const openGroups = ref<Set<string>>(
61
+ new Set(
62
+ props.items
63
+ .filter((i) => i.children && i.defaultOpen !== false)
64
+ .map((i) => i.label)
65
+ )
66
+ );
67
+
68
+ function isOpen(label: string) {
69
+ return openGroups.value.has(label);
70
+ }
71
+
72
+ function toggleGroup(label: string) {
73
+ if (openGroups.value.has(label)) {
74
+ openGroups.value.delete(label);
75
+ } else {
76
+ openGroups.value.add(label);
77
+ }
78
+ }
79
+ </script>
80
+
81
+ <style lang="scss">
82
+ .vl-nav {
83
+ padding: var(--space-3) 0;
84
+ }
85
+
86
+ .vl-nav__link {
87
+ display: flex;
88
+ align-items: center;
89
+ gap: var(--space-2);
90
+ padding: var(--space-2) var(--space-4);
91
+ color: rgba(255, 255, 255, 0.85);
92
+ text-decoration: none;
93
+ font-size: 0.9rem;
94
+ border-left: 3px solid transparent;
95
+ transition:
96
+ background 0.15s,
97
+ color 0.15s,
98
+ border-color 0.15s;
99
+
100
+ &:hover {
101
+ background: rgba(255, 255, 255, 0.1);
102
+ color: white;
103
+ }
104
+
105
+ &--active {
106
+ background: rgba(255, 255, 255, 0.12);
107
+ color: white;
108
+ }
109
+
110
+ &--exact-active {
111
+ border-left-color: var(--color-accent);
112
+ background: rgba(255, 255, 255, 0.15);
113
+ color: white;
114
+ font-weight: 500;
115
+ }
116
+
117
+ &--child {
118
+ padding-left: var(--space-8);
119
+ font-size: 0.85rem;
120
+ }
121
+
122
+ &-icon {
123
+ font-size: 1rem;
124
+ width: 1.2em;
125
+ text-align: center;
126
+ }
127
+ }
128
+
129
+ .vl-nav__group {
130
+ &-header {
131
+ display: flex;
132
+ align-items: center;
133
+ gap: var(--space-2);
134
+ width: 100%;
135
+ padding: var(--space-2) var(--space-4);
136
+ background: none;
137
+ border: none;
138
+ color: rgba(255, 255, 255, 0.6);
139
+ font-size: 0.75rem;
140
+ font-weight: 700;
141
+ letter-spacing: 0.08em;
142
+ text-transform: uppercase;
143
+ cursor: pointer;
144
+ text-align: left;
145
+ transition: color 0.15s;
146
+
147
+ &:hover {
148
+ color: rgba(255, 255, 255, 0.9);
149
+ }
150
+ }
151
+
152
+ &-label {
153
+ flex: 1;
154
+ }
155
+
156
+ &-caret {
157
+ font-size: 0.75rem;
158
+ transition: transform 0.2s;
159
+ display: inline-block;
160
+
161
+ &--open {
162
+ transform: rotate(90deg);
163
+ }
164
+ }
165
+
166
+ &-icon {
167
+ font-size: 1rem;
168
+ width: 1.2em;
169
+ text-align: center;
170
+ }
171
+
172
+ &-children {
173
+ // children are the RouterLinks inside
174
+ }
175
+ }
176
+ </style>