@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.
- 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/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/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/useFeatures.ts +2 -0
- package/layouts/admin.vue +3 -0
- package/layouts/default.vue +22 -86
- package/middleware/feature-gate.global.ts +1 -0
- package/package.json +6 -6
- package/pages/admin/features.vue +338 -0
- package/pages/admin/homepage.vue +292 -0
- package/pages/admin/navigation.vue +350 -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/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/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/features.get.ts +9 -0
- package/server/api/homepage/sections.get.ts +10 -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/layouts/default.vue
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import type { NavItem } from '@commonpub/server';
|
|
3
|
+
|
|
2
4
|
const { user, isAuthenticated, isAdmin, signOut, refreshSession } = useAuth();
|
|
3
5
|
const { count: unreadCount, connect: connectNotifications, disconnect: disconnectNotifications } = useNotifications();
|
|
4
6
|
const { count: unreadMessages, connect: connectMessages, disconnect: disconnectMessages } = useMessages();
|
|
5
|
-
const { hubs, learning, video, docs, contests, admin, federation, explainers } = useFeatures();
|
|
7
|
+
const { hubs, learning, video, docs, contests, events, admin, federation, explainers } = useFeatures();
|
|
6
8
|
const { isDark, setDarkMode } = useTheme();
|
|
7
9
|
const { enabledTypeMeta } = useContentTypes();
|
|
8
10
|
const runtimeConfig = useRuntimeConfig();
|
|
@@ -18,6 +20,9 @@ const userMenuOpen = ref(false);
|
|
|
18
20
|
const mobileMenuOpen = ref(false);
|
|
19
21
|
const openDropdown = ref<string | null>(null);
|
|
20
22
|
|
|
23
|
+
// Fetch configurable nav items (falls back to defaults on server)
|
|
24
|
+
const { data: navItems } = await useFetch<NavItem[]>('/api/navigation/items');
|
|
25
|
+
|
|
21
26
|
function toggleDropdown(name: string): void {
|
|
22
27
|
openDropdown.value = openDropdown.value === name ? null : name;
|
|
23
28
|
}
|
|
@@ -80,58 +85,13 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
80
85
|
<SiteLogo />
|
|
81
86
|
</NuxtLink>
|
|
82
87
|
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
</button>
|
|
91
|
-
<div v-if="openDropdown === 'learn'" class="cpub-nav-panel">
|
|
92
|
-
<NuxtLink v-if="learning" to="/learn" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-route"></i> Learning Paths</NuxtLink>
|
|
93
|
-
<NuxtLink v-if="explainers" to="/explainer" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-lightbulb"></i> Explainers</NuxtLink>
|
|
94
|
-
<NuxtLink v-if="docs" to="/docs" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-book"></i> Docs</NuxtLink>
|
|
95
|
-
</div>
|
|
96
|
-
</div>
|
|
97
|
-
|
|
98
|
-
<!-- Build dropdown -->
|
|
99
|
-
<div class="cpub-nav-dropdown">
|
|
100
|
-
<button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'build' }" aria-haspopup="true" :aria-expanded="openDropdown === 'build'" @click.stop="toggleDropdown('build')">
|
|
101
|
-
<i class="fa-solid fa-hammer"></i> Build <i class="fa-solid fa-chevron-down cpub-nav-caret" />
|
|
102
|
-
</button>
|
|
103
|
-
<div v-if="openDropdown === 'build'" class="cpub-nav-panel">
|
|
104
|
-
<NuxtLink to="/project" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-cube"></i> Projects</NuxtLink>
|
|
105
|
-
<NuxtLink v-if="contests" to="/contests" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-trophy"></i> Contests</NuxtLink>
|
|
106
|
-
</div>
|
|
107
|
-
</div>
|
|
108
|
-
|
|
109
|
-
<!-- Read dropdown -->
|
|
110
|
-
<div class="cpub-nav-dropdown">
|
|
111
|
-
<button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'read' }" aria-haspopup="true" :aria-expanded="openDropdown === 'read'" @click.stop="toggleDropdown('read')">
|
|
112
|
-
<i class="fa-solid fa-newspaper"></i> Read <i class="fa-solid fa-chevron-down cpub-nav-caret" />
|
|
113
|
-
</button>
|
|
114
|
-
<div v-if="openDropdown === 'read'" class="cpub-nav-panel">
|
|
115
|
-
<NuxtLink to="/blog" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-pen-nib"></i> Blog</NuxtLink>
|
|
116
|
-
</div>
|
|
117
|
-
</div>
|
|
118
|
-
|
|
119
|
-
<!-- Watch dropdown -->
|
|
120
|
-
<div v-if="video" class="cpub-nav-dropdown">
|
|
121
|
-
<button class="cpub-nav-link cpub-nav-trigger" :class="{ 'cpub-nav-trigger--open': openDropdown === 'watch' }" aria-haspopup="true" :aria-expanded="openDropdown === 'watch'" @click.stop="toggleDropdown('watch')">
|
|
122
|
-
<i class="fa-solid fa-play"></i> Watch <i class="fa-solid fa-chevron-down cpub-nav-caret" />
|
|
123
|
-
</button>
|
|
124
|
-
<div v-if="openDropdown === 'watch'" class="cpub-nav-panel">
|
|
125
|
-
<NuxtLink to="/videos" class="cpub-nav-panel-item" @click="closeDropdowns"><i class="fa-solid fa-video"></i> Videos</NuxtLink>
|
|
126
|
-
<span class="cpub-nav-panel-item cpub-nav-panel-item--disabled"><i class="fa-solid fa-tower-broadcast"></i> Live Streams</span>
|
|
127
|
-
<span class="cpub-nav-panel-item cpub-nav-panel-item--disabled"><i class="fa-solid fa-podcast"></i> Podcasts</span>
|
|
128
|
-
</div>
|
|
129
|
-
</div>
|
|
130
|
-
|
|
131
|
-
<NuxtLink v-if="hubs" to="/hubs" class="cpub-nav-link"><i class="fa-solid fa-users"></i> Hubs</NuxtLink>
|
|
132
|
-
<NuxtLink v-if="federation" to="/federation" class="cpub-nav-link"><i class="fa-solid fa-globe"></i> Fediverse</NuxtLink>
|
|
133
|
-
<NuxtLink v-if="isAdmin && admin" to="/admin" class="cpub-nav-link"><i class="fa-solid fa-shield-halved"></i> Admin</NuxtLink>
|
|
134
|
-
</nav>
|
|
88
|
+
<NavRenderer
|
|
89
|
+
v-if="navItems"
|
|
90
|
+
:items="navItems"
|
|
91
|
+
:open-dropdown="openDropdown"
|
|
92
|
+
@toggle-dropdown="toggleDropdown"
|
|
93
|
+
@close-dropdowns="closeDropdowns"
|
|
94
|
+
/>
|
|
135
95
|
|
|
136
96
|
<div class="cpub-topbar-spacer" />
|
|
137
97
|
|
|
@@ -183,38 +143,12 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
183
143
|
|
|
184
144
|
<!-- Mobile menu -->
|
|
185
145
|
<div v-if="mobileMenuOpen" class="cpub-mobile-menu" @click.self="mobileMenuOpen = false">
|
|
186
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
<NuxtLink v-if="learning" to="/learn" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-route"></i> Learning Paths</NuxtLink>
|
|
193
|
-
<NuxtLink v-if="explainers" to="/explainer" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-lightbulb"></i> Explainers</NuxtLink>
|
|
194
|
-
<NuxtLink v-if="docs" to="/docs" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-book"></i> Docs</NuxtLink>
|
|
195
|
-
</template>
|
|
196
|
-
|
|
197
|
-
<!-- Build -->
|
|
198
|
-
<div class="cpub-mobile-section-label">Build</div>
|
|
199
|
-
<NuxtLink to="/project" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-cube"></i> Projects</NuxtLink>
|
|
200
|
-
<NuxtLink v-if="contests" to="/contests" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-trophy"></i> Contests</NuxtLink>
|
|
201
|
-
|
|
202
|
-
<!-- Read -->
|
|
203
|
-
<div class="cpub-mobile-section-label">Read</div>
|
|
204
|
-
<NuxtLink to="/blog" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-pen-nib"></i> Blog</NuxtLink>
|
|
205
|
-
|
|
206
|
-
<!-- Watch -->
|
|
207
|
-
<template v-if="video">
|
|
208
|
-
<div class="cpub-mobile-section-label">Watch</div>
|
|
209
|
-
<NuxtLink to="/videos" class="cpub-mobile-link cpub-mobile-link--indent" @click="mobileMenuOpen = false"><i class="fa-solid fa-video"></i> Videos</NuxtLink>
|
|
210
|
-
<span class="cpub-mobile-link cpub-mobile-link--indent cpub-mobile-link--disabled"><i class="fa-solid fa-tower-broadcast"></i> Live Streams</span>
|
|
211
|
-
<span class="cpub-mobile-link cpub-mobile-link--indent cpub-mobile-link--disabled"><i class="fa-solid fa-podcast"></i> Podcasts</span>
|
|
212
|
-
</template>
|
|
213
|
-
|
|
214
|
-
<div class="cpub-mobile-divider" />
|
|
215
|
-
<NuxtLink v-if="hubs" to="/hubs" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-users"></i> Hubs</NuxtLink>
|
|
216
|
-
<NuxtLink v-if="federation" to="/federation" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-globe"></i> Fediverse</NuxtLink>
|
|
217
|
-
<NuxtLink v-if="isAdmin && admin" to="/admin" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-shield-halved"></i> Admin</NuxtLink>
|
|
146
|
+
<MobileNavRenderer
|
|
147
|
+
v-if="navItems"
|
|
148
|
+
:items="navItems"
|
|
149
|
+
@close="mobileMenuOpen = false"
|
|
150
|
+
/>
|
|
151
|
+
<div class="cpub-mobile-nav cpub-mobile-nav-extra">
|
|
218
152
|
<NuxtLink to="/search" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-magnifying-glass"></i> Search</NuxtLink>
|
|
219
153
|
<template v-if="isAuthenticated">
|
|
220
154
|
<div class="cpub-mobile-divider" />
|
|
@@ -223,7 +157,7 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
223
157
|
<NuxtLink to="/messages" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-envelope"></i> Messages</NuxtLink>
|
|
224
158
|
<NuxtLink to="/notifications" class="cpub-mobile-link" @click="mobileMenuOpen = false"><i class="fa-solid fa-bell"></i> Notifications</NuxtLink>
|
|
225
159
|
</template>
|
|
226
|
-
</
|
|
160
|
+
</div>
|
|
227
161
|
</div>
|
|
228
162
|
|
|
229
163
|
<!-- ═══ MAIN ═══ -->
|
|
@@ -254,6 +188,7 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
254
188
|
<h4 class="cpub-footer-col-title">Community</h4>
|
|
255
189
|
<NuxtLink v-if="hubs" to="/hubs" class="cpub-footer-link">Hubs</NuxtLink>
|
|
256
190
|
<NuxtLink v-if="contests" to="/contests" class="cpub-footer-link">Contests</NuxtLink>
|
|
191
|
+
<NuxtLink v-if="events" to="/events" class="cpub-footer-link">Events</NuxtLink>
|
|
257
192
|
<NuxtLink v-if="learning" to="/learn" class="cpub-footer-link">Learning Paths</NuxtLink>
|
|
258
193
|
<NuxtLink v-if="video" to="/videos" class="cpub-footer-link">Videos</NuxtLink>
|
|
259
194
|
<NuxtLink to="/search" class="cpub-footer-link">Explore</NuxtLink>
|
|
@@ -358,6 +293,7 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
358
293
|
.cpub-mobile-link:hover { background: var(--surface2); color: var(--text); }
|
|
359
294
|
.cpub-mobile-link i { width: 16px; text-align: center; font-size: 12px; }
|
|
360
295
|
.cpub-mobile-divider { height: 2px; background: var(--border2); margin: 4px 16px; }
|
|
296
|
+
.cpub-mobile-nav-extra { border-top: var(--border-width-default) solid var(--border2); }
|
|
361
297
|
|
|
362
298
|
#main-content { margin-top: 48px; flex: 1; }
|
|
363
299
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@aws-sdk/client-s3": "^3.1010.0",
|
|
31
31
|
"@commonpub/explainer": "^0.7.11",
|
|
32
|
-
"@commonpub/schema": "^0.
|
|
33
|
-
"@commonpub/server": "^2.
|
|
32
|
+
"@commonpub/schema": "^0.13.0",
|
|
33
|
+
"@commonpub/server": "^2.39.0",
|
|
34
34
|
"@tiptap/core": "^2.11.0",
|
|
35
35
|
"@tiptap/extension-bold": "^2.11.0",
|
|
36
36
|
"@tiptap/extension-bullet-list": "^2.11.0",
|
|
@@ -53,12 +53,12 @@
|
|
|
53
53
|
"vue": "^3.4.0",
|
|
54
54
|
"vue-router": "^4.3.0",
|
|
55
55
|
"zod": "^4.3.6",
|
|
56
|
-
"@commonpub/config": "0.9.2",
|
|
57
56
|
"@commonpub/docs": "0.6.2",
|
|
58
|
-
"@commonpub/auth": "0.5.1",
|
|
59
57
|
"@commonpub/editor": "0.7.9",
|
|
60
|
-
"@commonpub/ui": "0.8.5",
|
|
61
58
|
"@commonpub/learning": "0.5.0",
|
|
59
|
+
"@commonpub/config": "0.10.0",
|
|
60
|
+
"@commonpub/auth": "0.5.1",
|
|
61
|
+
"@commonpub/ui": "0.8.5",
|
|
62
62
|
"@commonpub/protocol": "0.9.9"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
@@ -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>
|