@commonpub/layer 0.11.0 → 0.14.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.
- package/components/DiscussionItem.vue +4 -1
- package/components/EventCard.vue +121 -0
- package/components/PollDisplay.vue +108 -0
- package/components/PostVoteButtons.vue +108 -0
- package/components/contest/ContestJudgeManager.vue +110 -0
- package/components/hub/HubDiscussions.vue +2 -0
- package/components/nav/MobileNavRenderer.vue +94 -0
- package/components/nav/NavDropdown.vue +101 -0
- package/components/nav/NavLink.vue +40 -0
- package/components/nav/NavRenderer.vue +51 -0
- package/composables/useAuth.ts +20 -15
- package/composables/useFeatures.ts +34 -13
- package/layouts/admin.vue +1 -0
- package/layouts/default.vue +50 -108
- package/middleware/feature-gate.global.ts +9 -4
- package/package.json +6 -6
- package/pages/admin/navigation.vue +350 -0
- package/pages/contests/[slug]/index.vue +1 -0
- package/pages/events/[slug]/edit.vue +182 -0
- package/pages/events/[slug]/index.vue +249 -0
- package/pages/events/create.vue +140 -0
- package/pages/events/index.vue +47 -0
- package/pages/federated-hubs/[id]/index.vue +1 -0
- package/pages/hubs/[slug]/index.vue +1 -0
- package/server/api/admin/navigation/items.get.ts +11 -0
- package/server/api/admin/navigation/items.put.ts +51 -0
- package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +20 -0
- package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +20 -0
- package/server/api/contests/[slug]/judge.post.ts +5 -7
- package/server/api/contests/[slug]/judges/[userId].delete.ts +26 -0
- package/server/api/contests/[slug]/judges/accept.post.ts +21 -0
- package/server/api/contests/[slug]/judges/index.get.ts +17 -0
- package/server/api/contests/[slug]/judges/index.post.ts +36 -0
- package/server/api/events/[slug]/attendees.get.ts +23 -0
- package/server/api/events/[slug]/rsvp.delete.ts +23 -0
- package/server/api/events/[slug]/rsvp.post.ts +23 -0
- package/server/api/events/[slug].delete.ts +22 -0
- package/server/api/events/[slug].get.ts +17 -0
- package/server/api/events/[slug].put.ts +38 -0
- package/server/api/events/index.get.ts +21 -0
- package/server/api/events/index.post.ts +40 -0
- package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +18 -0
- package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +27 -0
- package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +21 -0
- package/server/api/navigation/items.get.ts +10 -0
- package/server/middleware/features.ts +1 -0
- package/types/hub.ts +1 -0
|
@@ -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>
|
|
@@ -140,6 +140,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
140
140
|
<ContestRules v-if="c?.rules" :rules="c.rules" />
|
|
141
141
|
<ContestPrizes v-if="c?.prizes?.length" :prizes="c.prizes" />
|
|
142
142
|
<ContestJudges v-if="c?.judges?.length" :judge-ids="c.judges" />
|
|
143
|
+
<ContestJudgeManager v-if="isOwner && c" :contest-slug="slug" :is-owner="isOwner" />
|
|
143
144
|
<ContestEntries
|
|
144
145
|
:entries="entries"
|
|
145
146
|
:contest-status="c?.status"
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { EventDetail } from '@commonpub/server';
|
|
3
|
+
|
|
4
|
+
definePageMeta({ middleware: 'auth' });
|
|
5
|
+
|
|
6
|
+
const route = useRoute();
|
|
7
|
+
const slug = route.params.slug as string;
|
|
8
|
+
const toast = useToast();
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
const { user } = useAuth();
|
|
11
|
+
|
|
12
|
+
const { data: event } = await useFetch<EventDetail>(`/api/events/${slug}`);
|
|
13
|
+
|
|
14
|
+
if (!event.value) {
|
|
15
|
+
throw createError({ statusCode: 404, statusMessage: 'Event not found' });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const isOwner = computed(() => user.value?.id === event.value?.createdById);
|
|
19
|
+
const isAdmin = computed(() => user.value?.role === 'admin');
|
|
20
|
+
if (!isOwner.value && !isAdmin.value) {
|
|
21
|
+
throw createError({ statusCode: 403, statusMessage: 'Unauthorized' });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
useSeoMeta({ title: `Edit ${event.value.title} — Events — ${useSiteName()}` });
|
|
25
|
+
|
|
26
|
+
const saving = ref(false);
|
|
27
|
+
const form = reactive({
|
|
28
|
+
title: event.value.title,
|
|
29
|
+
description: event.value.description ?? '',
|
|
30
|
+
eventType: event.value.eventType,
|
|
31
|
+
status: event.value.status,
|
|
32
|
+
startDate: new Date(event.value.startDate).toISOString().slice(0, 16),
|
|
33
|
+
endDate: new Date(event.value.endDate).toISOString().slice(0, 16),
|
|
34
|
+
timezone: event.value.timezone,
|
|
35
|
+
location: event.value.location ?? '',
|
|
36
|
+
locationUrl: event.value.locationUrl ?? '',
|
|
37
|
+
onlineUrl: event.value.onlineUrl ?? '',
|
|
38
|
+
capacity: event.value.capacity ?? undefined as number | undefined,
|
|
39
|
+
isFeatured: event.value.isFeatured,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
async function submit(): Promise<void> {
|
|
43
|
+
if (!form.title || !form.startDate || !form.endDate) {
|
|
44
|
+
toast.error('Title, start date, and end date are required');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (new Date(form.startDate) >= new Date(form.endDate)) {
|
|
48
|
+
toast.error('End date must be after start date');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
saving.value = true;
|
|
53
|
+
try {
|
|
54
|
+
const body: Record<string, unknown> = {
|
|
55
|
+
title: form.title,
|
|
56
|
+
status: form.status,
|
|
57
|
+
eventType: form.eventType,
|
|
58
|
+
startDate: new Date(form.startDate).toISOString(),
|
|
59
|
+
endDate: new Date(form.endDate).toISOString(),
|
|
60
|
+
timezone: form.timezone,
|
|
61
|
+
isFeatured: form.isFeatured,
|
|
62
|
+
};
|
|
63
|
+
if (form.description) body.description = form.description;
|
|
64
|
+
if (form.location) body.location = form.location;
|
|
65
|
+
if (form.locationUrl) body.locationUrl = form.locationUrl;
|
|
66
|
+
if (form.onlineUrl) body.onlineUrl = form.onlineUrl;
|
|
67
|
+
if (form.capacity) body.capacity = form.capacity;
|
|
68
|
+
|
|
69
|
+
await $fetch(`/api/events/${slug}`, { method: 'PUT', body });
|
|
70
|
+
toast.success('Event updated');
|
|
71
|
+
router.push(`/events/${slug}`);
|
|
72
|
+
} catch {
|
|
73
|
+
toast.error('Failed to update event');
|
|
74
|
+
} finally {
|
|
75
|
+
saving.value = false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
</script>
|
|
79
|
+
|
|
80
|
+
<template>
|
|
81
|
+
<div class="cpub-edit-event">
|
|
82
|
+
<SectionHeader :title="`Edit: ${form.title}`" large />
|
|
83
|
+
|
|
84
|
+
<form class="cpub-form" @submit.prevent="submit">
|
|
85
|
+
<div class="cpub-form-field">
|
|
86
|
+
<label class="cpub-form-label">Title *</label>
|
|
87
|
+
<input v-model="form.title" class="cpub-form-input" required />
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div class="cpub-form-field">
|
|
91
|
+
<label class="cpub-form-label">Description</label>
|
|
92
|
+
<textarea v-model="form.description" class="cpub-form-textarea" rows="4" />
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="cpub-form-row">
|
|
96
|
+
<div class="cpub-form-field">
|
|
97
|
+
<label class="cpub-form-label">Type</label>
|
|
98
|
+
<select v-model="form.eventType" class="cpub-form-input">
|
|
99
|
+
<option value="in-person">In-Person</option>
|
|
100
|
+
<option value="online">Online</option>
|
|
101
|
+
<option value="hybrid">Hybrid</option>
|
|
102
|
+
</select>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="cpub-form-field">
|
|
105
|
+
<label class="cpub-form-label">Status</label>
|
|
106
|
+
<select v-model="form.status" class="cpub-form-input">
|
|
107
|
+
<option value="draft">Draft</option>
|
|
108
|
+
<option value="published">Published</option>
|
|
109
|
+
<option value="active">Active</option>
|
|
110
|
+
<option value="completed">Completed</option>
|
|
111
|
+
<option value="cancelled">Cancelled</option>
|
|
112
|
+
</select>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
<div class="cpub-form-row">
|
|
117
|
+
<div class="cpub-form-field">
|
|
118
|
+
<label class="cpub-form-label">Start Date *</label>
|
|
119
|
+
<input v-model="form.startDate" type="datetime-local" class="cpub-form-input" required />
|
|
120
|
+
</div>
|
|
121
|
+
<div class="cpub-form-field">
|
|
122
|
+
<label class="cpub-form-label">End Date *</label>
|
|
123
|
+
<input v-model="form.endDate" type="datetime-local" class="cpub-form-input" required />
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div class="cpub-form-row">
|
|
128
|
+
<div class="cpub-form-field">
|
|
129
|
+
<label class="cpub-form-label">Capacity</label>
|
|
130
|
+
<input v-model.number="form.capacity" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" />
|
|
131
|
+
</div>
|
|
132
|
+
<div class="cpub-form-field">
|
|
133
|
+
<label class="cpub-form-label cpub-form-checkbox-label">
|
|
134
|
+
<input type="checkbox" v-model="form.isFeatured" /> Featured Event
|
|
135
|
+
</label>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div v-if="form.eventType !== 'online'" class="cpub-form-row">
|
|
140
|
+
<div class="cpub-form-field">
|
|
141
|
+
<label class="cpub-form-label">Location</label>
|
|
142
|
+
<input v-model="form.location" class="cpub-form-input" placeholder="Venue name / address" />
|
|
143
|
+
</div>
|
|
144
|
+
<div class="cpub-form-field">
|
|
145
|
+
<label class="cpub-form-label">Location URL</label>
|
|
146
|
+
<input v-model="form.locationUrl" type="url" class="cpub-form-input" placeholder="Map link" />
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<div v-if="form.eventType !== 'in-person'" class="cpub-form-field">
|
|
151
|
+
<label class="cpub-form-label">Online URL</label>
|
|
152
|
+
<input v-model="form.onlineUrl" type="url" class="cpub-form-input" placeholder="Meeting link" />
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<div class="cpub-form-actions">
|
|
156
|
+
<NuxtLink :to="`/events/${slug}`" class="cpub-btn cpub-btn-sm">Cancel</NuxtLink>
|
|
157
|
+
<button type="submit" class="cpub-btn cpub-btn-primary cpub-btn-sm" :disabled="saving">
|
|
158
|
+
<i :class="saving ? 'fa-solid fa-circle-notch fa-spin' : 'fa-solid fa-check'"></i>
|
|
159
|
+
Save Changes
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
</form>
|
|
163
|
+
</div>
|
|
164
|
+
</template>
|
|
165
|
+
|
|
166
|
+
<style scoped>
|
|
167
|
+
.cpub-edit-event { max-width: 640px; margin: 0 auto; padding: 32px; }
|
|
168
|
+
.cpub-form { display: flex; flex-direction: column; gap: var(--space-4); margin-top: 24px; }
|
|
169
|
+
.cpub-form-field { display: flex; flex-direction: column; gap: 4px; }
|
|
170
|
+
.cpub-form-label { font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); }
|
|
171
|
+
.cpub-form-input { font-size: 13px; padding: 8px 12px; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); outline: none; }
|
|
172
|
+
.cpub-form-input:focus { border-color: var(--accent); }
|
|
173
|
+
.cpub-form-textarea { font-size: 13px; padding: 8px 12px; border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); outline: none; resize: vertical; font-family: inherit; }
|
|
174
|
+
.cpub-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); }
|
|
175
|
+
.cpub-form-checkbox-label { display: flex; align-items: center; gap: 6px; margin-top: 18px; }
|
|
176
|
+
.cpub-form-actions { display: flex; gap: var(--space-3); justify-content: flex-end; padding-top: var(--space-4); border-top: var(--border-width-default) solid var(--border2); }
|
|
177
|
+
|
|
178
|
+
@media (max-width: 768px) {
|
|
179
|
+
.cpub-edit-event { padding: 16px; }
|
|
180
|
+
.cpub-form-row { grid-template-columns: 1fr; }
|
|
181
|
+
}
|
|
182
|
+
</style>
|