@commonpub/layer 0.10.1 → 0.13.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 (57) hide show
  1. package/components/EventCard.vue +121 -0
  2. package/components/PollDisplay.vue +108 -0
  3. package/components/PostVoteButtons.vue +108 -0
  4. package/components/contest/ContestJudgeManager.vue +110 -0
  5. package/components/homepage/ContentGridSection.vue +133 -0
  6. package/components/homepage/ContestsSection.vue +39 -0
  7. package/components/homepage/CustomHtmlSection.vue +20 -0
  8. package/components/homepage/EditorialSection.vue +32 -0
  9. package/components/homepage/HeroSection.vue +73 -0
  10. package/components/homepage/HomepageSectionRenderer.vue +64 -0
  11. package/components/homepage/HubsSection.vue +66 -0
  12. package/components/homepage/StatsSection.vue +38 -0
  13. package/components/nav/MobileNavRenderer.vue +94 -0
  14. package/components/nav/NavDropdown.vue +101 -0
  15. package/components/nav/NavLink.vue +40 -0
  16. package/components/nav/NavRenderer.vue +51 -0
  17. package/composables/useFeatures.ts +2 -0
  18. package/layouts/admin.vue +3 -0
  19. package/layouts/default.vue +22 -86
  20. package/middleware/feature-gate.global.ts +1 -0
  21. package/package.json +6 -6
  22. package/pages/admin/features.vue +338 -0
  23. package/pages/admin/homepage.vue +292 -0
  24. package/pages/admin/navigation.vue +350 -0
  25. package/pages/events/[slug]/edit.vue +182 -0
  26. package/pages/events/[slug]/index.vue +249 -0
  27. package/pages/events/create.vue +140 -0
  28. package/pages/events/index.vue +47 -0
  29. package/pages/index.vue +34 -1
  30. package/server/api/admin/features/index.get.ts +32 -0
  31. package/server/api/admin/features/index.put.ts +56 -0
  32. package/server/api/admin/homepage/sections.get.ts +11 -0
  33. package/server/api/admin/homepage/sections.put.ts +52 -0
  34. package/server/api/admin/navigation/items.get.ts +11 -0
  35. package/server/api/admin/navigation/items.put.ts +51 -0
  36. package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +20 -0
  37. package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +20 -0
  38. package/server/api/contests/[slug]/judge.post.ts +5 -7
  39. package/server/api/contests/[slug]/judges/[userId].delete.ts +26 -0
  40. package/server/api/contests/[slug]/judges/accept.post.ts +21 -0
  41. package/server/api/contests/[slug]/judges/index.get.ts +17 -0
  42. package/server/api/contests/[slug]/judges/index.post.ts +36 -0
  43. package/server/api/events/[slug]/attendees.get.ts +23 -0
  44. package/server/api/events/[slug]/rsvp.delete.ts +23 -0
  45. package/server/api/events/[slug]/rsvp.post.ts +23 -0
  46. package/server/api/events/[slug].delete.ts +22 -0
  47. package/server/api/events/[slug].get.ts +17 -0
  48. package/server/api/events/[slug].put.ts +38 -0
  49. package/server/api/events/index.get.ts +21 -0
  50. package/server/api/events/index.post.ts +40 -0
  51. package/server/api/features.get.ts +9 -0
  52. package/server/api/homepage/sections.get.ts +10 -0
  53. package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +18 -0
  54. package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +27 -0
  55. package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +21 -0
  56. package/server/api/navigation/items.get.ts +10 -0
  57. package/server/middleware/features.ts +1 -0
@@ -0,0 +1,292 @@
1
+ <script setup lang="ts">
2
+ import type { HomepageSection } from '@commonpub/server';
3
+
4
+ definePageMeta({ layout: 'admin', middleware: 'auth' });
5
+ useSeoMeta({ title: `Homepage — Admin — ${useSiteName()}` });
6
+
7
+ const toast = useToast();
8
+ const { data, refresh } = await useFetch<HomepageSection[]>('/api/admin/homepage/sections');
9
+
10
+ const sections = ref<HomepageSection[]>([]);
11
+ const saving = ref(false);
12
+ const hasChanges = ref(false);
13
+
14
+ watch(data, (val) => {
15
+ if (val) {
16
+ sections.value = JSON.parse(JSON.stringify(val));
17
+ hasChanges.value = false;
18
+ }
19
+ }, { immediate: true });
20
+
21
+ function markChanged(): void { hasChanges.value = true; }
22
+
23
+ const SECTION_TYPES: Array<{ value: HomepageSection['type']; label: string; icon: string }> = [
24
+ { value: 'hero', label: 'Hero Banner', icon: 'fa-solid fa-flag' },
25
+ { value: 'editorial', label: 'Staff Picks', icon: 'fa-solid fa-pen-fancy' },
26
+ { value: 'content-grid', label: 'Content Grid', icon: 'fa-solid fa-th-large' },
27
+ { value: 'contests', label: 'Contests', icon: 'fa-solid fa-trophy' },
28
+ { value: 'hubs', label: 'Hubs', icon: 'fa-solid fa-layer-group' },
29
+ { value: 'stats', label: 'Platform Stats', icon: 'fa-solid fa-chart-bar' },
30
+ { value: 'custom-html', label: 'Custom HTML', icon: 'fa-solid fa-code' },
31
+ ];
32
+
33
+ function getTypeInfo(type: string) {
34
+ return SECTION_TYPES.find(t => t.value === type) ?? { label: type, icon: 'fa-solid fa-puzzle-piece' };
35
+ }
36
+
37
+ function moveUp(index: number): void {
38
+ if (index <= 0) return;
39
+ const arr = [...sections.value];
40
+ [arr[index - 1], arr[index]] = [arr[index]!, arr[index - 1]!];
41
+ arr.forEach((s, i) => { s.order = i; });
42
+ sections.value = arr;
43
+ markChanged();
44
+ }
45
+
46
+ function moveDown(index: number): void {
47
+ if (index >= sections.value.length - 1) return;
48
+ const arr = [...sections.value];
49
+ [arr[index], arr[index + 1]] = [arr[index + 1]!, arr[index]!];
50
+ arr.forEach((s, i) => { s.order = i; });
51
+ sections.value = arr;
52
+ markChanged();
53
+ }
54
+
55
+ function toggleSection(index: number): void {
56
+ sections.value[index]!.enabled = !sections.value[index]!.enabled;
57
+ markChanged();
58
+ }
59
+
60
+ function removeSection(index: number): void {
61
+ if (!confirm(`Remove "${sections.value[index]!.title || sections.value[index]!.type}" section?`)) return;
62
+ sections.value.splice(index, 1);
63
+ sections.value.forEach((s, i) => { s.order = i; });
64
+ markChanged();
65
+ }
66
+
67
+ function addSection(): void {
68
+ const id = `section-${Date.now()}`;
69
+ sections.value.push({
70
+ id,
71
+ type: 'content-grid',
72
+ title: 'New Section',
73
+ enabled: true,
74
+ order: sections.value.length,
75
+ config: { sort: 'recent', limit: 6, columns: 3 },
76
+ });
77
+ markChanged();
78
+ }
79
+
80
+ async function save(): Promise<void> {
81
+ saving.value = true;
82
+ try {
83
+ await $fetch('/api/admin/homepage/sections', {
84
+ method: 'PUT',
85
+ body: { sections: sections.value },
86
+ });
87
+ toast.success('Homepage saved');
88
+ hasChanges.value = false;
89
+ await refresh();
90
+ } catch {
91
+ toast.error('Failed to save homepage');
92
+ } finally {
93
+ saving.value = false;
94
+ }
95
+ }
96
+
97
+ function discard(): void {
98
+ if (data.value) {
99
+ sections.value = JSON.parse(JSON.stringify(data.value));
100
+ hasChanges.value = false;
101
+ }
102
+ }
103
+
104
+ const editingId = ref<string | null>(null);
105
+ </script>
106
+
107
+ <template>
108
+ <div class="cpub-admin-homepage">
109
+ <div class="cpub-admin-header">
110
+ <div>
111
+ <h1 class="cpub-admin-title">Homepage Layout</h1>
112
+ <p class="cpub-admin-subtitle">Reorder, enable, or disable homepage sections.</p>
113
+ </div>
114
+ <div class="cpub-admin-header-actions">
115
+ <button class="cpub-btn cpub-btn-sm" @click="addSection">
116
+ <i class="fa-solid fa-plus"></i> Add Section
117
+ </button>
118
+ <button
119
+ v-if="hasChanges"
120
+ class="cpub-btn cpub-btn-primary cpub-btn-sm"
121
+ :disabled="saving"
122
+ @click="save"
123
+ >
124
+ <i :class="saving ? 'fa-solid fa-circle-notch fa-spin' : 'fa-solid fa-check'"></i> Save
125
+ </button>
126
+ </div>
127
+ </div>
128
+
129
+ <div class="cpub-sections-list">
130
+ <div
131
+ v-for="(section, idx) in sections"
132
+ :key="section.id"
133
+ class="cpub-section-row"
134
+ :class="{ 'cpub-section-disabled': !section.enabled }"
135
+ >
136
+ <div class="cpub-section-order">
137
+ <button class="cpub-order-btn" :disabled="idx === 0" @click="moveUp(idx)" title="Move up">
138
+ <i class="fa-solid fa-chevron-up"></i>
139
+ </button>
140
+ <span class="cpub-order-num">{{ idx + 1 }}</span>
141
+ <button class="cpub-order-btn" :disabled="idx === sections.length - 1" @click="moveDown(idx)" title="Move down">
142
+ <i class="fa-solid fa-chevron-down"></i>
143
+ </button>
144
+ </div>
145
+
146
+ <div class="cpub-section-icon">
147
+ <i :class="getTypeInfo(section.type).icon"></i>
148
+ </div>
149
+
150
+ <div class="cpub-section-info">
151
+ <div class="cpub-section-label">{{ section.title || getTypeInfo(section.type).label }}</div>
152
+ <div class="cpub-section-meta">
153
+ <span class="cpub-section-type-badge">{{ getTypeInfo(section.type).label }}</span>
154
+ <span v-if="section.config.featureGate" class="cpub-section-gate">gate: {{ section.config.featureGate }}</span>
155
+ <span v-if="section.config.limit" class="cpub-section-gate">limit: {{ section.config.limit }}</span>
156
+ </div>
157
+ </div>
158
+
159
+ <div class="cpub-section-actions">
160
+ <button
161
+ class="cpub-section-action"
162
+ :title="editingId === section.id ? 'Close' : 'Edit'"
163
+ @click="editingId = editingId === section.id ? null : section.id"
164
+ >
165
+ <i :class="editingId === section.id ? 'fa-solid fa-xmark' : 'fa-solid fa-pencil'"></i>
166
+ </button>
167
+ <button
168
+ class="cpub-section-action"
169
+ :class="{ 'cpub-section-action--active': section.enabled }"
170
+ :title="section.enabled ? 'Disable' : 'Enable'"
171
+ @click="toggleSection(idx)"
172
+ >
173
+ <i :class="section.enabled ? 'fa-solid fa-eye' : 'fa-solid fa-eye-slash'"></i>
174
+ </button>
175
+ <button class="cpub-section-action cpub-section-action--danger" title="Remove" @click="removeSection(idx)">
176
+ <i class="fa-solid fa-trash"></i>
177
+ </button>
178
+ </div>
179
+
180
+ <!-- Inline editor -->
181
+ <div v-if="editingId === section.id" class="cpub-section-editor">
182
+ <div class="cpub-editor-grid">
183
+ <div class="cpub-editor-field">
184
+ <label class="cpub-editor-label">Title</label>
185
+ <input v-model="section.title" class="cpub-editor-input" @input="markChanged" />
186
+ </div>
187
+ <div class="cpub-editor-field">
188
+ <label class="cpub-editor-label">Type</label>
189
+ <select v-model="section.type" class="cpub-editor-input" @change="markChanged">
190
+ <option v-for="t in SECTION_TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
191
+ </select>
192
+ </div>
193
+ <div class="cpub-editor-field">
194
+ <label class="cpub-editor-label">Feature Gate</label>
195
+ <input v-model="section.config.featureGate" class="cpub-editor-input" placeholder="e.g. contests" @input="markChanged" />
196
+ </div>
197
+ <div class="cpub-editor-field">
198
+ <label class="cpub-editor-label">Limit</label>
199
+ <input v-model.number="section.config.limit" type="number" min="1" max="50" class="cpub-editor-input" @input="markChanged" />
200
+ </div>
201
+ <div v-if="section.type === 'content-grid'" class="cpub-editor-field">
202
+ <label class="cpub-editor-label">Sort</label>
203
+ <select v-model="section.config.sort" class="cpub-editor-input" @change="markChanged">
204
+ <option value="popular">Popular</option>
205
+ <option value="recent">Recent</option>
206
+ <option value="featured">Featured</option>
207
+ <option value="editorial">Editorial</option>
208
+ </select>
209
+ </div>
210
+ <div v-if="section.type === 'content-grid'" class="cpub-editor-field">
211
+ <label class="cpub-editor-label">Columns</label>
212
+ <select v-model.number="section.config.columns" class="cpub-editor-input" @change="markChanged">
213
+ <option :value="2">2</option>
214
+ <option :value="3">3</option>
215
+ <option :value="4">4</option>
216
+ </select>
217
+ </div>
218
+ <div v-if="section.type === 'custom-html'" class="cpub-editor-field cpub-editor-field--full">
219
+ <label class="cpub-editor-label">HTML Content</label>
220
+ <textarea v-model="section.config.html" class="cpub-editor-textarea" rows="4" @input="markChanged" />
221
+ </div>
222
+ </div>
223
+ </div>
224
+ </div>
225
+ </div>
226
+
227
+ <div v-if="hasChanges" class="cpub-sections-footer">
228
+ <span class="cpub-sections-footer-text">Unsaved changes</span>
229
+ <button class="cpub-btn cpub-btn-sm" @click="discard">Discard</button>
230
+ <button class="cpub-btn cpub-btn-primary cpub-btn-sm" :disabled="saving" @click="save">Save</button>
231
+ </div>
232
+ </div>
233
+ </template>
234
+
235
+ <style scoped>
236
+ .cpub-admin-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--space-6); gap: var(--space-4); }
237
+ .cpub-admin-header-actions { display: flex; gap: var(--space-2); }
238
+ .cpub-admin-title { font-size: var(--text-xl); font-weight: var(--font-weight-bold); }
239
+ .cpub-admin-subtitle { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
240
+
241
+ .cpub-sections-list { display: flex; flex-direction: column; border: var(--border-width-default) solid var(--border); }
242
+
243
+ .cpub-section-row {
244
+ display: flex;
245
+ align-items: center;
246
+ padding: 12px 16px;
247
+ border-bottom: var(--border-width-default) solid var(--border2);
248
+ gap: 12px;
249
+ flex-wrap: wrap;
250
+ }
251
+ .cpub-section-row:last-child { border-bottom: none; }
252
+ .cpub-section-disabled { opacity: 0.5; }
253
+
254
+ .cpub-section-order { display: flex; flex-direction: column; align-items: center; gap: 2px; }
255
+ .cpub-order-btn { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 10px; padding: 2px 4px; }
256
+ .cpub-order-btn:hover { color: var(--accent); }
257
+ .cpub-order-btn:disabled { opacity: 0.3; cursor: default; }
258
+ .cpub-order-num { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); }
259
+
260
+ .cpub-section-icon { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: var(--surface2); border: var(--border-width-default) solid var(--border2); color: var(--text-dim); font-size: 13px; flex-shrink: 0; }
261
+
262
+ .cpub-section-info { flex: 1; min-width: 0; }
263
+ .cpub-section-label { font-size: 13px; font-weight: 600; }
264
+ .cpub-section-meta { display: flex; gap: 8px; margin-top: 2px; }
265
+ .cpub-section-type-badge { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; color: var(--text-faint); }
266
+ .cpub-section-gate { font-family: var(--font-mono); font-size: 9px; color: var(--accent); }
267
+
268
+ .cpub-section-actions { display: flex; gap: 6px; flex-shrink: 0; }
269
+ .cpub-section-action { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; padding: 4px 6px; }
270
+ .cpub-section-action:hover { color: var(--accent); }
271
+ .cpub-section-action--active { color: var(--green); }
272
+ .cpub-section-action--danger:hover { color: var(--red); }
273
+
274
+ .cpub-section-editor { width: 100%; padding: 12px 0 0; border-top: var(--border-width-default) solid var(--border2); margin-top: 8px; }
275
+ .cpub-editor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
276
+ .cpub-editor-field { display: flex; flex-direction: column; gap: 4px; }
277
+ .cpub-editor-field--full { grid-column: 1 / -1; }
278
+ .cpub-editor-label { font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); }
279
+ .cpub-editor-input { font-size: 13px; padding: 6px 10px; border: var(--border-width-default) solid var(--border); background: var(--bg); color: var(--text); outline: none; }
280
+ .cpub-editor-input:focus { border-color: var(--accent); }
281
+ .cpub-editor-textarea { font-size: 12px; font-family: var(--font-mono); padding: 8px 10px; border: var(--border-width-default) solid var(--border); background: var(--bg); color: var(--text); outline: none; resize: vertical; }
282
+
283
+ .cpub-sections-footer { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-4); margin-top: var(--space-4); background: var(--yellow-bg, var(--surface2)); border: var(--border-width-default) solid var(--yellow, var(--border)); }
284
+ .cpub-sections-footer-text { font-family: var(--font-mono); font-size: 11px; font-weight: 600; color: var(--yellow, var(--text-dim)); flex: 1; }
285
+
286
+ @media (max-width: 768px) {
287
+ .cpub-admin-header { flex-direction: column; }
288
+ .cpub-editor-grid { grid-template-columns: 1fr; }
289
+ .cpub-section-row { flex-direction: column; align-items: flex-start; }
290
+ .cpub-section-actions { align-self: flex-end; }
291
+ }
292
+ </style>
@@ -0,0 +1,350 @@
1
+ <script setup lang="ts">
2
+ import type { NavItem } from '@commonpub/server';
3
+
4
+ definePageMeta({ layout: 'admin', middleware: 'auth' });
5
+ useSeoMeta({ title: `Navigation — Admin — ${useSiteName()}` });
6
+
7
+ const toast = useToast();
8
+ const { data, refresh } = await useFetch<NavItem[]>('/api/admin/navigation/items');
9
+
10
+ const items = ref<NavItem[]>([]);
11
+ const saving = ref(false);
12
+ const hasChanges = ref(false);
13
+
14
+ watch(data, (val) => {
15
+ if (val) {
16
+ items.value = JSON.parse(JSON.stringify(val));
17
+ hasChanges.value = false;
18
+ }
19
+ }, { immediate: true });
20
+
21
+ function markChanged(): void { hasChanges.value = true; }
22
+
23
+ const NAV_TYPES: Array<{ value: NavItem['type']; label: string }> = [
24
+ { value: 'link', label: 'Internal Link' },
25
+ { value: 'dropdown', label: 'Dropdown Menu' },
26
+ { value: 'external', label: 'External Link' },
27
+ ];
28
+
29
+ const VISIBILITY_OPTIONS: Array<{ value: string; label: string }> = [
30
+ { value: 'all', label: 'Everyone' },
31
+ { value: 'authenticated', label: 'Logged-in users' },
32
+ { value: 'admin', label: 'Admins only' },
33
+ ];
34
+
35
+ function moveUp(index: number): void {
36
+ if (index <= 0) return;
37
+ const arr = [...items.value];
38
+ [arr[index - 1], arr[index]] = [arr[index]!, arr[index - 1]!];
39
+ items.value = arr;
40
+ markChanged();
41
+ }
42
+
43
+ function moveDown(index: number): void {
44
+ if (index >= items.value.length - 1) return;
45
+ const arr = [...items.value];
46
+ [arr[index], arr[index + 1]] = [arr[index + 1]!, arr[index]!];
47
+ items.value = arr;
48
+ markChanged();
49
+ }
50
+
51
+ function removeItem(index: number): void {
52
+ if (!confirm(`Remove "${items.value[index]!.label}" from navigation?`)) return;
53
+ items.value.splice(index, 1);
54
+ markChanged();
55
+ }
56
+
57
+ function addItem(): void {
58
+ items.value.push({
59
+ id: `nav-${Date.now()}`,
60
+ type: 'link',
61
+ label: 'New Link',
62
+ icon: 'fa-solid fa-link',
63
+ route: '/',
64
+ });
65
+ markChanged();
66
+ }
67
+
68
+ function addChild(parentIndex: number): void {
69
+ const parent = items.value[parentIndex]!;
70
+ if (!parent.children) parent.children = [];
71
+ parent.children.push({
72
+ id: `nav-child-${Date.now()}`,
73
+ type: 'link',
74
+ label: 'New Child',
75
+ icon: 'fa-solid fa-link',
76
+ route: '/',
77
+ });
78
+ markChanged();
79
+ }
80
+
81
+ function removeChild(parentIndex: number, childIndex: number): void {
82
+ const parent = items.value[parentIndex]!;
83
+ if (!parent.children) return;
84
+ parent.children.splice(childIndex, 1);
85
+ markChanged();
86
+ }
87
+
88
+ function moveChildUp(parentIndex: number, childIndex: number): void {
89
+ const children = items.value[parentIndex]!.children;
90
+ if (!children || childIndex <= 0) return;
91
+ [children[childIndex - 1], children[childIndex]] = [children[childIndex]!, children[childIndex - 1]!];
92
+ markChanged();
93
+ }
94
+
95
+ function moveChildDown(parentIndex: number, childIndex: number): void {
96
+ const children = items.value[parentIndex]!.children;
97
+ if (!children || childIndex >= children.length - 1) return;
98
+ [children[childIndex], children[childIndex + 1]] = [children[childIndex + 1]!, children[childIndex]!];
99
+ markChanged();
100
+ }
101
+
102
+ async function save(): Promise<void> {
103
+ saving.value = true;
104
+ try {
105
+ await $fetch('/api/admin/navigation/items', {
106
+ method: 'PUT',
107
+ body: { items: items.value },
108
+ });
109
+ toast.success('Navigation saved');
110
+ hasChanges.value = false;
111
+ await refresh();
112
+ } catch {
113
+ toast.error('Failed to save navigation');
114
+ } finally {
115
+ saving.value = false;
116
+ }
117
+ }
118
+
119
+ function discard(): void {
120
+ if (data.value) {
121
+ items.value = JSON.parse(JSON.stringify(data.value));
122
+ hasChanges.value = false;
123
+ }
124
+ }
125
+
126
+ const editingId = ref<string | null>(null);
127
+ </script>
128
+
129
+ <template>
130
+ <div class="cpub-admin-nav-page">
131
+ <div class="cpub-admin-header">
132
+ <div>
133
+ <h1 class="cpub-admin-title">Navigation</h1>
134
+ <p class="cpub-admin-subtitle">Configure the main site navigation bar.</p>
135
+ </div>
136
+ <div class="cpub-admin-header-actions">
137
+ <button class="cpub-btn cpub-btn-sm" @click="addItem">
138
+ <i class="fa-solid fa-plus"></i> Add Item
139
+ </button>
140
+ <button
141
+ v-if="hasChanges"
142
+ class="cpub-btn cpub-btn-primary cpub-btn-sm"
143
+ :disabled="saving"
144
+ @click="save"
145
+ >
146
+ <i :class="saving ? 'fa-solid fa-circle-notch fa-spin' : 'fa-solid fa-check'"></i> Save
147
+ </button>
148
+ </div>
149
+ </div>
150
+
151
+ <div class="cpub-nav-list">
152
+ <div
153
+ v-for="(item, idx) in items"
154
+ :key="item.id"
155
+ class="cpub-nav-row"
156
+ >
157
+ <div class="cpub-nav-order">
158
+ <button class="cpub-order-btn" :disabled="idx === 0" title="Move up" @click="moveUp(idx)">
159
+ <i class="fa-solid fa-chevron-up"></i>
160
+ </button>
161
+ <span class="cpub-order-num">{{ idx + 1 }}</span>
162
+ <button class="cpub-order-btn" :disabled="idx === items.length - 1" title="Move down" @click="moveDown(idx)">
163
+ <i class="fa-solid fa-chevron-down"></i>
164
+ </button>
165
+ </div>
166
+
167
+ <div class="cpub-nav-icon-cell">
168
+ <i :class="item.icon || 'fa-solid fa-link'"></i>
169
+ </div>
170
+
171
+ <div class="cpub-nav-info">
172
+ <div class="cpub-nav-label">{{ item.label }}</div>
173
+ <div class="cpub-nav-meta">
174
+ <span class="cpub-nav-type-badge">{{ item.type }}</span>
175
+ <span v-if="item.route" class="cpub-nav-route">{{ item.route }}</span>
176
+ <span v-if="item.href" class="cpub-nav-route">{{ item.href }}</span>
177
+ <span v-if="item.featureGate" class="cpub-nav-gate">gate: {{ item.featureGate }}</span>
178
+ <span v-if="item.visibleTo && item.visibleTo !== 'all'" class="cpub-nav-gate">{{ item.visibleTo }}</span>
179
+ <span v-if="item.children?.length" class="cpub-nav-gate">{{ item.children.length }} children</span>
180
+ </div>
181
+ </div>
182
+
183
+ <div class="cpub-nav-actions">
184
+ <button
185
+ class="cpub-nav-action"
186
+ :title="editingId === item.id ? 'Close' : 'Edit'"
187
+ @click="editingId = editingId === item.id ? null : item.id"
188
+ >
189
+ <i :class="editingId === item.id ? 'fa-solid fa-xmark' : 'fa-solid fa-pencil'"></i>
190
+ </button>
191
+ <button class="cpub-nav-action cpub-nav-action--danger" title="Remove" @click="removeItem(idx)">
192
+ <i class="fa-solid fa-trash"></i>
193
+ </button>
194
+ </div>
195
+
196
+ <!-- Inline editor -->
197
+ <div v-if="editingId === item.id" class="cpub-nav-editor">
198
+ <div class="cpub-editor-grid">
199
+ <div class="cpub-editor-field">
200
+ <label class="cpub-editor-label">Label</label>
201
+ <input v-model="item.label" class="cpub-editor-input" @input="markChanged" />
202
+ </div>
203
+ <div class="cpub-editor-field">
204
+ <label class="cpub-editor-label">Type</label>
205
+ <select v-model="item.type" class="cpub-editor-input" @change="markChanged">
206
+ <option v-for="t in NAV_TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
207
+ </select>
208
+ </div>
209
+ <div class="cpub-editor-field">
210
+ <label class="cpub-editor-label">Icon (FontAwesome class)</label>
211
+ <input v-model="item.icon" class="cpub-editor-input" placeholder="fa-solid fa-house" @input="markChanged" />
212
+ </div>
213
+ <div v-if="item.type === 'link' || item.type === 'dropdown'" class="cpub-editor-field">
214
+ <label class="cpub-editor-label">Route</label>
215
+ <input v-model="item.route" class="cpub-editor-input" placeholder="/page" @input="markChanged" />
216
+ </div>
217
+ <div v-if="item.type === 'external'" class="cpub-editor-field">
218
+ <label class="cpub-editor-label">URL</label>
219
+ <input v-model="item.href" class="cpub-editor-input" placeholder="https://..." @input="markChanged" />
220
+ </div>
221
+ <div class="cpub-editor-field">
222
+ <label class="cpub-editor-label">Feature Gate</label>
223
+ <input v-model="item.featureGate" class="cpub-editor-input" placeholder="e.g. hubs" @input="markChanged" />
224
+ </div>
225
+ <div class="cpub-editor-field">
226
+ <label class="cpub-editor-label">Visible To</label>
227
+ <select v-model="item.visibleTo" class="cpub-editor-input" @change="markChanged">
228
+ <option v-for="v in VISIBILITY_OPTIONS" :key="v.value" :value="v.value">{{ v.label }}</option>
229
+ </select>
230
+ </div>
231
+ </div>
232
+
233
+ <!-- Children (for dropdown type) -->
234
+ <div v-if="item.type === 'dropdown'" class="cpub-children-section">
235
+ <div class="cpub-children-header">
236
+ <span class="cpub-children-title">Children</span>
237
+ <button class="cpub-btn cpub-btn-sm" @click="addChild(idx)">
238
+ <i class="fa-solid fa-plus"></i> Add Child
239
+ </button>
240
+ </div>
241
+ <div v-if="item.children?.length" class="cpub-children-list">
242
+ <div
243
+ v-for="(child, ci) in item.children"
244
+ :key="child.id"
245
+ class="cpub-child-row"
246
+ >
247
+ <div class="cpub-child-order">
248
+ <button class="cpub-order-btn" :disabled="ci === 0" @click="moveChildUp(idx, ci)">
249
+ <i class="fa-solid fa-chevron-up"></i>
250
+ </button>
251
+ <button class="cpub-order-btn" :disabled="ci === (item.children?.length ?? 0) - 1" @click="moveChildDown(idx, ci)">
252
+ <i class="fa-solid fa-chevron-down"></i>
253
+ </button>
254
+ </div>
255
+ <div class="cpub-child-fields">
256
+ <input v-model="child.label" class="cpub-editor-input cpub-child-input" placeholder="Label" @input="markChanged" />
257
+ <input v-model="child.icon" class="cpub-editor-input cpub-child-input" placeholder="Icon class" @input="markChanged" />
258
+ <input v-model="child.route" class="cpub-editor-input cpub-child-input" placeholder="Route" @input="markChanged" />
259
+ <input v-model="child.featureGate" class="cpub-editor-input cpub-child-input" placeholder="Feature gate" @input="markChanged" />
260
+ <label class="cpub-child-disabled-label">
261
+ <input type="checkbox" v-model="child.disabled" @change="markChanged" /> Disabled
262
+ </label>
263
+ </div>
264
+ <button class="cpub-nav-action cpub-nav-action--danger" title="Remove child" @click="removeChild(idx, ci)">
265
+ <i class="fa-solid fa-trash"></i>
266
+ </button>
267
+ </div>
268
+ </div>
269
+ <p v-else class="cpub-children-empty">No children. Add items to populate this dropdown.</p>
270
+ </div>
271
+ </div>
272
+ </div>
273
+ </div>
274
+
275
+ <div v-if="hasChanges" class="cpub-nav-footer">
276
+ <span class="cpub-nav-footer-text">Unsaved changes</span>
277
+ <button class="cpub-btn cpub-btn-sm" @click="discard">Discard</button>
278
+ <button class="cpub-btn cpub-btn-primary cpub-btn-sm" :disabled="saving" @click="save">Save</button>
279
+ </div>
280
+ </div>
281
+ </template>
282
+
283
+ <style scoped>
284
+ .cpub-admin-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--space-6); gap: var(--space-4); }
285
+ .cpub-admin-header-actions { display: flex; gap: var(--space-2); }
286
+ .cpub-admin-title { font-size: var(--text-xl); font-weight: var(--font-weight-bold); }
287
+ .cpub-admin-subtitle { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
288
+
289
+ .cpub-nav-list { display: flex; flex-direction: column; border: var(--border-width-default) solid var(--border); }
290
+
291
+ .cpub-nav-row {
292
+ display: flex;
293
+ align-items: center;
294
+ padding: 12px 16px;
295
+ border-bottom: var(--border-width-default) solid var(--border2);
296
+ gap: 12px;
297
+ flex-wrap: wrap;
298
+ }
299
+ .cpub-nav-row:last-child { border-bottom: none; }
300
+
301
+ .cpub-nav-order { display: flex; flex-direction: column; align-items: center; gap: 2px; }
302
+ .cpub-order-btn { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 10px; padding: 2px 4px; }
303
+ .cpub-order-btn:hover { color: var(--accent); }
304
+ .cpub-order-btn:disabled { opacity: 0.3; cursor: default; }
305
+ .cpub-order-num { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); }
306
+
307
+ .cpub-nav-icon-cell { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: var(--surface2); border: var(--border-width-default) solid var(--border2); color: var(--text-dim); font-size: 13px; flex-shrink: 0; }
308
+
309
+ .cpub-nav-info { flex: 1; min-width: 0; }
310
+ .cpub-nav-label { font-size: 13px; font-weight: 600; }
311
+ .cpub-nav-meta { display: flex; gap: 8px; margin-top: 2px; flex-wrap: wrap; }
312
+ .cpub-nav-type-badge { font-family: var(--font-mono); font-size: 9px; text-transform: uppercase; color: var(--text-faint); }
313
+ .cpub-nav-route { font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); }
314
+ .cpub-nav-gate { font-family: var(--font-mono); font-size: 9px; color: var(--accent); }
315
+
316
+ .cpub-nav-actions { display: flex; gap: 6px; flex-shrink: 0; }
317
+ .cpub-nav-action { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; padding: 4px 6px; }
318
+ .cpub-nav-action:hover { color: var(--accent); }
319
+ .cpub-nav-action--danger:hover { color: var(--red); }
320
+
321
+ .cpub-nav-editor { width: 100%; padding: 12px 0 0; border-top: var(--border-width-default) solid var(--border2); margin-top: 8px; }
322
+ .cpub-editor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
323
+ .cpub-editor-field { display: flex; flex-direction: column; gap: 4px; }
324
+ .cpub-editor-label { font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); }
325
+ .cpub-editor-input { font-size: 13px; padding: 6px 10px; border: var(--border-width-default) solid var(--border); background: var(--bg); color: var(--text); outline: none; }
326
+ .cpub-editor-input:focus { border-color: var(--accent); }
327
+
328
+ .cpub-children-section { margin-top: var(--space-4); padding-top: var(--space-3); border-top: var(--border-width-default) solid var(--border2); }
329
+ .cpub-children-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-3); }
330
+ .cpub-children-title { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-faint); }
331
+ .cpub-children-list { display: flex; flex-direction: column; gap: 8px; }
332
+ .cpub-child-row { display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--surface2); border: var(--border-width-default) solid var(--border2); }
333
+ .cpub-child-order { display: flex; flex-direction: column; gap: 2px; }
334
+ .cpub-child-fields { display: flex; gap: 6px; flex: 1; flex-wrap: wrap; align-items: center; }
335
+ .cpub-child-input { flex: 1; min-width: 100px; font-size: 12px; padding: 4px 8px; }
336
+ .cpub-child-disabled-label { font-size: 11px; color: var(--text-dim); display: flex; align-items: center; gap: 4px; white-space: nowrap; }
337
+ .cpub-children-empty { font-size: 12px; color: var(--text-faint); font-style: italic; }
338
+
339
+ .cpub-nav-footer { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-4); margin-top: var(--space-4); background: var(--yellow-bg, var(--surface2)); border: var(--border-width-default) solid var(--yellow, var(--border)); }
340
+ .cpub-nav-footer-text { font-family: var(--font-mono); font-size: 11px; font-weight: 600; color: var(--yellow, var(--text-dim)); flex: 1; }
341
+
342
+ @media (max-width: 768px) {
343
+ .cpub-admin-header { flex-direction: column; }
344
+ .cpub-editor-grid { grid-template-columns: 1fr; }
345
+ .cpub-nav-row { flex-direction: column; align-items: flex-start; }
346
+ .cpub-nav-actions { align-self: flex-end; }
347
+ .cpub-child-fields { flex-direction: column; }
348
+ .cpub-child-input { min-width: 0; }
349
+ }
350
+ </style>