@commonpub/layer 0.10.1 → 0.11.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/homepage/ContentGridSection.vue +133 -0
- package/components/homepage/ContestsSection.vue +39 -0
- package/components/homepage/CustomHtmlSection.vue +20 -0
- package/components/homepage/EditorialSection.vue +32 -0
- package/components/homepage/HeroSection.vue +73 -0
- package/components/homepage/HomepageSectionRenderer.vue +64 -0
- package/components/homepage/HubsSection.vue +66 -0
- package/components/homepage/StatsSection.vue +38 -0
- package/layouts/admin.vue +2 -0
- package/package.json +5 -5
- package/pages/admin/features.vue +338 -0
- package/pages/admin/homepage.vue +292 -0
- package/pages/index.vue +34 -1
- package/server/api/admin/features/index.get.ts +32 -0
- package/server/api/admin/features/index.put.ts +56 -0
- package/server/api/admin/homepage/sections.get.ts +11 -0
- package/server/api/admin/homepage/sections.put.ts +52 -0
- package/server/api/features.get.ts +9 -0
- package/server/api/homepage/sections.get.ts +10 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
definePageMeta({ layout: 'admin', middleware: 'auth' });
|
|
3
|
+
useSeoMeta({ title: `Feature Flags — Admin — ${useSiteName()}` });
|
|
4
|
+
|
|
5
|
+
const toast = useToast();
|
|
6
|
+
|
|
7
|
+
interface FlagInfo {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
isOverridden: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { data, refresh } = await useFetch<{
|
|
13
|
+
flags: Record<string, FlagInfo>;
|
|
14
|
+
overrides: Record<string, boolean>;
|
|
15
|
+
}>('/api/admin/features');
|
|
16
|
+
|
|
17
|
+
const saving = ref(false);
|
|
18
|
+
const pendingChanges = ref<Record<string, boolean>>({});
|
|
19
|
+
|
|
20
|
+
const flagMeta: Record<string, { label: string; description: string; icon: string }> = {
|
|
21
|
+
content: { label: 'Content', description: 'Content system (CRUD, publishing)', icon: 'fa-solid fa-newspaper' },
|
|
22
|
+
social: { label: 'Social', description: 'Likes, comments, bookmarks, follows', icon: 'fa-solid fa-heart' },
|
|
23
|
+
hubs: { label: 'Hubs', description: 'Communities, products, companies', icon: 'fa-solid fa-layer-group' },
|
|
24
|
+
docs: { label: 'Docs', description: 'Documentation sites with versioning', icon: 'fa-solid fa-book' },
|
|
25
|
+
video: { label: 'Video', description: 'Video content and categories', icon: 'fa-solid fa-video' },
|
|
26
|
+
contests: { label: 'Contests', description: 'Contest system with judging', icon: 'fa-solid fa-trophy' },
|
|
27
|
+
learning: { label: 'Learning', description: 'Learning paths and courses', icon: 'fa-solid fa-graduation-cap' },
|
|
28
|
+
explainers: { label: 'Explainers', description: 'Interactive explainer modules', icon: 'fa-solid fa-lightbulb' },
|
|
29
|
+
editorial: { label: 'Editorial', description: 'Staff picks and content categories', icon: 'fa-solid fa-pen-fancy' },
|
|
30
|
+
federation: { label: 'Federation', description: 'ActivityPub federation', icon: 'fa-solid fa-globe' },
|
|
31
|
+
seamlessFederation: { label: 'Seamless Federation', description: 'Mix federated content into feeds', icon: 'fa-solid fa-arrows-spin' },
|
|
32
|
+
federateHubs: { label: 'Federate Hubs', description: 'Hub federation via AP Groups', icon: 'fa-solid fa-diagram-project' },
|
|
33
|
+
admin: { label: 'Admin Panel', description: 'Admin dashboard and management', icon: 'fa-solid fa-shield-halved' },
|
|
34
|
+
emailNotifications: { label: 'Email Notifications', description: 'Email digests and instant notifications', icon: 'fa-solid fa-envelope' },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const flagKeys = computed(() => data.value ? Object.keys(data.value.flags) : []);
|
|
38
|
+
|
|
39
|
+
function getEffectiveValue(key: string): boolean {
|
|
40
|
+
if (key in pendingChanges.value) return pendingChanges.value[key]!;
|
|
41
|
+
return data.value?.flags[key]?.enabled ?? false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isOverridden(key: string): boolean {
|
|
45
|
+
if (key in pendingChanges.value) return true;
|
|
46
|
+
return data.value?.flags[key]?.isOverridden ?? false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toggleFlag(key: string): void {
|
|
50
|
+
const current = getEffectiveValue(key);
|
|
51
|
+
pendingChanges.value = { ...pendingChanges.value, [key]: !current };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resetFlag(key: string): void {
|
|
55
|
+
const changes = { ...pendingChanges.value };
|
|
56
|
+
delete changes[key];
|
|
57
|
+
pendingChanges.value = changes;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const hasPendingChanges = computed(() => Object.keys(pendingChanges.value).length > 0);
|
|
61
|
+
|
|
62
|
+
async function saveChanges(): Promise<void> {
|
|
63
|
+
if (!hasPendingChanges.value) return;
|
|
64
|
+
saving.value = true;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
// Build the full overrides object: existing overrides + pending changes
|
|
68
|
+
const currentOverrides = data.value?.overrides ?? {};
|
|
69
|
+
const merged = { ...currentOverrides, ...pendingChanges.value };
|
|
70
|
+
|
|
71
|
+
await $fetch('/api/admin/features', {
|
|
72
|
+
method: 'PUT',
|
|
73
|
+
body: { overrides: merged },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
toast.success('Feature flags updated');
|
|
77
|
+
pendingChanges.value = {};
|
|
78
|
+
await refresh();
|
|
79
|
+
} catch {
|
|
80
|
+
toast.error('Failed to update feature flags');
|
|
81
|
+
} finally {
|
|
82
|
+
saving.value = false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function resetOverride(key: string): Promise<void> {
|
|
87
|
+
try {
|
|
88
|
+
// Remove this key from overrides
|
|
89
|
+
const currentOverrides = { ...(data.value?.overrides ?? {}) };
|
|
90
|
+
delete currentOverrides[key];
|
|
91
|
+
|
|
92
|
+
await $fetch('/api/admin/features', {
|
|
93
|
+
method: 'PUT',
|
|
94
|
+
body: { overrides: currentOverrides },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
toast.success(`Reset ${flagMeta[key]?.label ?? key} to default`);
|
|
98
|
+
resetFlag(key);
|
|
99
|
+
await refresh();
|
|
100
|
+
} catch {
|
|
101
|
+
toast.error('Failed to reset flag');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
</script>
|
|
105
|
+
|
|
106
|
+
<template>
|
|
107
|
+
<div class="cpub-admin-features">
|
|
108
|
+
<div class="cpub-admin-header">
|
|
109
|
+
<div>
|
|
110
|
+
<h1 class="cpub-admin-title">Feature Flags</h1>
|
|
111
|
+
<p class="cpub-admin-subtitle">Toggle features on or off at runtime. Changes take effect within 60 seconds.</p>
|
|
112
|
+
</div>
|
|
113
|
+
<button
|
|
114
|
+
v-if="hasPendingChanges"
|
|
115
|
+
class="cpub-btn cpub-btn-primary cpub-btn-sm"
|
|
116
|
+
:disabled="saving"
|
|
117
|
+
@click="saveChanges"
|
|
118
|
+
>
|
|
119
|
+
<i :class="saving ? 'fa-solid fa-circle-notch fa-spin' : 'fa-solid fa-check'"></i>
|
|
120
|
+
Save Changes
|
|
121
|
+
</button>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div v-if="data?.flags" class="cpub-flags-list">
|
|
125
|
+
<div
|
|
126
|
+
v-for="key in flagKeys"
|
|
127
|
+
:key="key"
|
|
128
|
+
class="cpub-flag-row"
|
|
129
|
+
:class="{
|
|
130
|
+
'cpub-flag-row--changed': key in pendingChanges,
|
|
131
|
+
'cpub-flag-row--overridden': isOverridden(key) && !(key in pendingChanges),
|
|
132
|
+
}"
|
|
133
|
+
>
|
|
134
|
+
<div class="cpub-flag-info">
|
|
135
|
+
<div class="cpub-flag-icon">
|
|
136
|
+
<i :class="flagMeta[key]?.icon ?? 'fa-solid fa-toggle-on'"></i>
|
|
137
|
+
</div>
|
|
138
|
+
<div class="cpub-flag-text">
|
|
139
|
+
<span class="cpub-flag-label">{{ flagMeta[key]?.label ?? key }}</span>
|
|
140
|
+
<span class="cpub-flag-desc">{{ flagMeta[key]?.description ?? '' }}</span>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<div class="cpub-flag-controls">
|
|
145
|
+
<span v-if="isOverridden(key) && !(key in pendingChanges)" class="cpub-flag-badge cpub-flag-badge--override">
|
|
146
|
+
overridden
|
|
147
|
+
</span>
|
|
148
|
+
<span v-if="key in pendingChanges" class="cpub-flag-badge cpub-flag-badge--pending">
|
|
149
|
+
unsaved
|
|
150
|
+
</span>
|
|
151
|
+
|
|
152
|
+
<button
|
|
153
|
+
v-if="data.flags[key]?.isOverridden && !(key in pendingChanges)"
|
|
154
|
+
class="cpub-flag-reset"
|
|
155
|
+
title="Reset to default"
|
|
156
|
+
@click="resetOverride(key)"
|
|
157
|
+
>
|
|
158
|
+
<i class="fa-solid fa-rotate-left"></i>
|
|
159
|
+
</button>
|
|
160
|
+
|
|
161
|
+
<button
|
|
162
|
+
class="cpub-flag-toggle"
|
|
163
|
+
:class="{ 'cpub-flag-toggle--on': getEffectiveValue(key) }"
|
|
164
|
+
:aria-pressed="getEffectiveValue(key)"
|
|
165
|
+
:aria-label="`Toggle ${flagMeta[key]?.label ?? key}`"
|
|
166
|
+
@click="toggleFlag(key)"
|
|
167
|
+
>
|
|
168
|
+
<span class="cpub-flag-toggle-track">
|
|
169
|
+
<span class="cpub-flag-toggle-thumb" />
|
|
170
|
+
</span>
|
|
171
|
+
</button>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<div v-if="hasPendingChanges" class="cpub-flags-footer">
|
|
177
|
+
<span class="cpub-flags-footer-text">{{ Object.keys(pendingChanges).length }} unsaved change(s)</span>
|
|
178
|
+
<button class="cpub-btn cpub-btn-sm" @click="pendingChanges = {}">Discard</button>
|
|
179
|
+
<button class="cpub-btn cpub-btn-primary cpub-btn-sm" :disabled="saving" @click="saveChanges">
|
|
180
|
+
Save Changes
|
|
181
|
+
</button>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
</template>
|
|
185
|
+
|
|
186
|
+
<style scoped>
|
|
187
|
+
.cpub-admin-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--space-6); gap: var(--space-4); }
|
|
188
|
+
.cpub-admin-title { font-size: var(--text-xl); font-weight: var(--font-weight-bold); }
|
|
189
|
+
.cpub-admin-subtitle { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
|
|
190
|
+
|
|
191
|
+
.cpub-flags-list {
|
|
192
|
+
display: flex;
|
|
193
|
+
flex-direction: column;
|
|
194
|
+
border: var(--border-width-default) solid var(--border);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.cpub-flag-row {
|
|
198
|
+
display: flex;
|
|
199
|
+
align-items: center;
|
|
200
|
+
justify-content: space-between;
|
|
201
|
+
padding: 14px 16px;
|
|
202
|
+
border-bottom: var(--border-width-default) solid var(--border2);
|
|
203
|
+
gap: var(--space-4);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.cpub-flag-row:last-child { border-bottom: none; }
|
|
207
|
+
|
|
208
|
+
.cpub-flag-row--changed {
|
|
209
|
+
background: var(--yellow-bg, var(--surface2));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.cpub-flag-row--overridden {
|
|
213
|
+
background: var(--accent-bg);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.cpub-flag-info {
|
|
217
|
+
display: flex;
|
|
218
|
+
align-items: center;
|
|
219
|
+
gap: 12px;
|
|
220
|
+
min-width: 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.cpub-flag-icon {
|
|
224
|
+
width: 32px;
|
|
225
|
+
height: 32px;
|
|
226
|
+
display: flex;
|
|
227
|
+
align-items: center;
|
|
228
|
+
justify-content: center;
|
|
229
|
+
background: var(--surface2);
|
|
230
|
+
border: var(--border-width-default) solid var(--border2);
|
|
231
|
+
color: var(--text-dim);
|
|
232
|
+
font-size: 13px;
|
|
233
|
+
flex-shrink: 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.cpub-flag-text {
|
|
237
|
+
display: flex;
|
|
238
|
+
flex-direction: column;
|
|
239
|
+
gap: 2px;
|
|
240
|
+
min-width: 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.cpub-flag-label { font-size: 13px; font-weight: 600; }
|
|
244
|
+
.cpub-flag-desc { font-size: 11px; color: var(--text-dim); }
|
|
245
|
+
|
|
246
|
+
.cpub-flag-controls {
|
|
247
|
+
display: flex;
|
|
248
|
+
align-items: center;
|
|
249
|
+
gap: 8px;
|
|
250
|
+
flex-shrink: 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.cpub-flag-badge {
|
|
254
|
+
font-family: var(--font-mono);
|
|
255
|
+
font-size: 9px;
|
|
256
|
+
font-weight: 600;
|
|
257
|
+
text-transform: uppercase;
|
|
258
|
+
letter-spacing: 0.06em;
|
|
259
|
+
padding: 2px 6px;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.cpub-flag-badge--override { color: var(--accent); background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); }
|
|
263
|
+
.cpub-flag-badge--pending { color: var(--yellow); background: var(--yellow-bg); border: var(--border-width-default) solid var(--yellow); }
|
|
264
|
+
|
|
265
|
+
.cpub-flag-reset {
|
|
266
|
+
background: none;
|
|
267
|
+
border: none;
|
|
268
|
+
color: var(--text-faint);
|
|
269
|
+
cursor: pointer;
|
|
270
|
+
font-size: 11px;
|
|
271
|
+
padding: 4px;
|
|
272
|
+
}
|
|
273
|
+
.cpub-flag-reset:hover { color: var(--accent); }
|
|
274
|
+
|
|
275
|
+
/* Toggle switch */
|
|
276
|
+
.cpub-flag-toggle {
|
|
277
|
+
background: none;
|
|
278
|
+
border: none;
|
|
279
|
+
cursor: pointer;
|
|
280
|
+
padding: 2px;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.cpub-flag-toggle-track {
|
|
284
|
+
display: block;
|
|
285
|
+
width: 36px;
|
|
286
|
+
height: 20px;
|
|
287
|
+
background: var(--border);
|
|
288
|
+
border: var(--border-width-default) solid var(--border2);
|
|
289
|
+
position: relative;
|
|
290
|
+
transition: background 0.2s;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.cpub-flag-toggle--on .cpub-flag-toggle-track {
|
|
294
|
+
background: var(--accent);
|
|
295
|
+
border-color: var(--accent);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.cpub-flag-toggle-thumb {
|
|
299
|
+
display: block;
|
|
300
|
+
width: 14px;
|
|
301
|
+
height: 14px;
|
|
302
|
+
background: var(--surface);
|
|
303
|
+
border: var(--border-width-default) solid var(--border);
|
|
304
|
+
position: absolute;
|
|
305
|
+
top: 2px;
|
|
306
|
+
left: 2px;
|
|
307
|
+
transition: transform 0.2s;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.cpub-flag-toggle--on .cpub-flag-toggle-thumb {
|
|
311
|
+
transform: translateX(16px);
|
|
312
|
+
border-color: var(--surface);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.cpub-flags-footer {
|
|
316
|
+
display: flex;
|
|
317
|
+
align-items: center;
|
|
318
|
+
gap: var(--space-3);
|
|
319
|
+
padding: var(--space-4);
|
|
320
|
+
margin-top: var(--space-4);
|
|
321
|
+
background: var(--yellow-bg, var(--surface2));
|
|
322
|
+
border: var(--border-width-default) solid var(--yellow, var(--border));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.cpub-flags-footer-text {
|
|
326
|
+
font-family: var(--font-mono);
|
|
327
|
+
font-size: 11px;
|
|
328
|
+
font-weight: 600;
|
|
329
|
+
color: var(--yellow, var(--text-dim));
|
|
330
|
+
flex: 1;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
@media (max-width: 768px) {
|
|
334
|
+
.cpub-admin-header { flex-direction: column; }
|
|
335
|
+
.cpub-flag-row { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
|
|
336
|
+
.cpub-flag-controls { align-self: flex-end; }
|
|
337
|
+
}
|
|
338
|
+
</style>
|
|
@@ -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>
|
package/pages/index.vue
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { Serialized, ContentListItem, PaginatedResponse } from '@commonpub/server';
|
|
2
|
+
import type { Serialized, ContentListItem, PaginatedResponse, HomepageSection } from '@commonpub/server';
|
|
3
3
|
|
|
4
4
|
useSeoMeta({
|
|
5
5
|
title: `${useSiteName()} — Open Maker Platform`,
|
|
6
6
|
description: 'Build, document, and share your projects with a community of makers.',
|
|
7
7
|
});
|
|
8
8
|
|
|
9
|
+
// Fetch configurable homepage sections (from DB or defaults)
|
|
10
|
+
const { data: homepageSections } = await useFetch<HomepageSection[]>('/api/homepage/sections');
|
|
11
|
+
const hasCustomSections = computed(() => !!homepageSections.value?.length);
|
|
12
|
+
const sortedSections = computed(() =>
|
|
13
|
+
[...(homepageSections.value ?? [])].sort((a, b) => a.order - b.order),
|
|
14
|
+
);
|
|
15
|
+
|
|
9
16
|
const { user: authUser } = useAuth();
|
|
10
17
|
const { hubs: hubsEnabled, contests: contestsEnabled, learning: learningEnabled, video: videoEnabled, docs: docsEnabled, editorial: editorialEnabled } = useFeatures();
|
|
11
18
|
const { enabledTypeMeta } = useContentTypes();
|
|
@@ -117,6 +124,30 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
|
|
|
117
124
|
|
|
118
125
|
<template>
|
|
119
126
|
<div>
|
|
127
|
+
<!-- ═══ CONFIGURABLE HOMEPAGE (section renderer) ═══ -->
|
|
128
|
+
<template v-if="hasCustomSections">
|
|
129
|
+
<!-- Full-width sections (hero) -->
|
|
130
|
+
<HomepageSectionRenderer :sections="sortedSections" zone="full-width" />
|
|
131
|
+
|
|
132
|
+
<!-- 2-column layout: main + sidebar -->
|
|
133
|
+
<div class="cpub-main-layout">
|
|
134
|
+
<main class="cpub-feed-col">
|
|
135
|
+
<HomepageSectionRenderer :sections="sortedSections" zone="main" />
|
|
136
|
+
</main>
|
|
137
|
+
<aside class="cpub-sidebar">
|
|
138
|
+
<HomepageSectionRenderer :sections="sortedSections" zone="sidebar" />
|
|
139
|
+
<!-- Powered badge -->
|
|
140
|
+
<div class="cpub-powered-badge">
|
|
141
|
+
<span class="cpub-powered-text">Powered by</span>
|
|
142
|
+
<span class="cpub-powered-logo">[<span>C</span>] CommonPub</span>
|
|
143
|
+
</div>
|
|
144
|
+
</aside>
|
|
145
|
+
</div>
|
|
146
|
+
</template>
|
|
147
|
+
|
|
148
|
+
<!-- ═══ LEGACY HARDCODED HOMEPAGE (fallback) ═══ -->
|
|
149
|
+
<template v-else>
|
|
150
|
+
|
|
120
151
|
<!-- ═══ HERO BANNER ═══ -->
|
|
121
152
|
<section v-if="!heroDismissed" class="cpub-hero-banner">
|
|
122
153
|
<div class="cpub-hero-grid-bg" />
|
|
@@ -383,6 +414,8 @@ async function handleHubJoin(hubSlug: string): Promise<void> {
|
|
|
383
414
|
</div>
|
|
384
415
|
</aside>
|
|
385
416
|
</div>
|
|
417
|
+
|
|
418
|
+
</template><!-- end legacy fallback -->
|
|
386
419
|
</div>
|
|
387
420
|
</template>
|
|
388
421
|
|