@commonpub/layer 0.82.0 → 0.83.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/AppToast.vue +1 -1
- package/components/ContentAvatar.vue +98 -0
- package/components/CpubCriteriaBar.vue +88 -0
- package/components/CpubDateTimeField.vue +73 -0
- package/components/CpubMarkdown.vue +3 -1
- package/components/FormatToggle.vue +2 -2
- package/components/ImageUpload.vue +5 -8
- package/components/MirrorDetailModal.vue +3 -1
- package/components/MirrorRequestApproveModal.vue +3 -1
- package/components/ProductEditModal.vue +184 -0
- package/components/RemoteFollowDialog.vue +2 -2
- package/components/SearchSidebar.vue +14 -21
- package/components/ShareToHubModal.vue +3 -1
- package/components/admin/layouts/AdminLayoutsPalette.vue +5 -1
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +7 -1
- package/components/admin/layouts/AdminLayoutsToolbar.vue +1 -1
- package/components/blocks/BlockCompareColumnsView.vue +92 -0
- package/components/blocks/BlockContentRenderer.vue +17 -0
- package/components/blocks/BlockCriteriaBarView.vue +25 -0
- package/components/blocks/BlockGalleryView.vue +5 -0
- package/components/blocks/BlockHtmlView.vue +26 -0
- package/components/blocks/BlockImageView.vue +4 -0
- package/components/blocks/BlockJudgesShowcaseView.vue +52 -0
- package/components/blocks/BlockRoadmapView.vue +84 -0
- package/components/blocks/BlockSponsorsView.vue +89 -0
- package/components/blocks/BlockTableView.vue +49 -0
- package/components/blocks/BlockTabsView.vue +121 -0
- package/components/contest/ContestBodyCanvas.vue +155 -0
- package/components/contest/ContestCriteriaEditor.vue +79 -0
- package/components/contest/ContestEditor.vue +948 -0
- package/components/contest/ContestEntries.vue +1 -1
- package/components/contest/ContestEntryPrivateData.vue +126 -0
- package/components/contest/ContestHero.vue +114 -186
- package/components/contest/ContestJudgeManager.vue +6 -4
- package/components/contest/ContestJudgingCriteria.vue +5 -21
- package/components/contest/ContestPrizes.vue +8 -1
- package/components/contest/ContestProposalForm.vue +88 -0
- package/components/contest/ContestRules.vue +8 -1
- package/components/contest/ContestSidebar.vue +8 -2
- package/components/contest/ContestStageSubmission.vue +10 -36
- package/components/contest/ContestStagesEditor.vue +141 -65
- package/components/contest/ContestStakeholderManager.vue +3 -2
- package/components/contest/ContestSubmissionField.vue +141 -0
- package/components/contest/blocks/CompareColumnsBlock.vue +127 -0
- package/components/contest/blocks/ContestTabPanel.vue +27 -0
- package/components/contest/blocks/CriteriaBarBlock.vue +118 -0
- package/components/contest/blocks/HtmlBlock.vue +61 -0
- package/components/contest/blocks/JudgesShowcaseBlock.vue +96 -0
- package/components/contest/blocks/RoadmapBlock.vue +127 -0
- package/components/contest/blocks/SponsorsBlock.vue +127 -0
- package/components/contest/blocks/TableBlock.vue +101 -0
- package/components/contest/blocks/TabsBlock.vue +168 -0
- package/components/editors/ArticleEditor.vue +9 -16
- package/components/editors/ExplainerEditor.vue +8 -5
- package/components/editors/ProjectEditor.vue +13 -10
- package/components/homepage/CustomHtmlSection.vue +11 -2
- package/components/hub/HubProducts.vue +4 -2
- package/components/nav/NavDropdown.vue +1 -5
- package/components/nav/NavLink.vue +2 -0
- package/components/views/ArticleView.vue +3 -56
- package/components/views/ExplainerView.vue +4 -0
- package/components/views/ProjectView.vue +83 -245
- package/composables/useContestEditor.ts +388 -0
- package/composables/useDocsPageTree.ts +154 -0
- package/composables/useDocsSiteSettings.ts +107 -0
- package/composables/useEditorAutosave.ts +131 -0
- package/composables/useEngagement.ts +13 -6
- package/composables/useFeatures.ts +9 -1
- package/composables/useFileUpload.ts +60 -0
- package/composables/useProfileContent.ts +84 -0
- package/composables/useSanitize.ts +38 -4
- package/composables/useScrollSpy.ts +87 -0
- package/layouts/admin.vue +41 -19
- package/layouts/default.vue +18 -9
- package/nuxt.config.ts +13 -0
- package/package.json +9 -9
- package/pages/[type]/index.vue +6 -1
- package/pages/admin/api-keys.vue +13 -3
- package/pages/admin/features.vue +2 -0
- package/pages/admin/federation.vue +1 -1
- package/pages/admin/layouts/[id].vue +30 -2
- package/pages/admin/settings.vue +2 -1
- package/pages/admin/users.vue +1 -1
- package/pages/admin/video-categories.vue +203 -0
- package/pages/cert/[code].vue +6 -2
- package/pages/contests/[slug]/edit.vue +4 -769
- package/pages/contests/[slug]/entries/[entryId].vue +34 -1
- package/pages/contests/[slug]/index.vue +93 -7
- package/pages/contests/[slug]/judge.vue +49 -26
- package/pages/contests/create.vue +5 -466
- package/pages/contests/index.vue +7 -2
- package/pages/cookies.vue +1 -1
- package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
- package/pages/docs/[siteSlug]/edit.vue +93 -231
- package/pages/events/[slug]/edit.vue +20 -20
- package/pages/events/create.vue +18 -18
- package/pages/events/index.vue +7 -2
- package/pages/hubs/[slug]/index.vue +34 -9
- package/pages/hubs/[slug]/invites.vue +312 -0
- package/pages/hubs/[slug]/members.vue +128 -0
- package/pages/hubs/[slug]/posts/[postId].vue +2 -2
- package/pages/hubs/index.vue +6 -1
- package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
- package/pages/learn/index.vue +8 -1
- package/pages/messages/index.vue +1 -1
- package/pages/mirror/[id].vue +1 -1
- package/pages/products/[slug].vue +55 -2
- package/pages/products/index.vue +6 -1
- package/pages/settings/account.vue +8 -8
- package/pages/settings/profile.vue +23 -14
- package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
- package/pages/u/[username]/followers.vue +11 -3
- package/pages/u/[username]/following.vue +10 -8
- package/pages/u/[username]/index.vue +73 -7
- package/pages/videos/index.vue +13 -10
- package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
- package/server/api/admin/api-keys/[id].delete.ts +2 -2
- package/server/api/admin/api-keys/index.get.ts +1 -0
- package/server/api/admin/api-keys/index.post.ts +1 -0
- package/server/api/admin/federation/refederate.post.ts +18 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
- package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
- package/server/api/admin/layouts/[id].delete.ts +1 -4
- package/server/api/admin/layouts/[id].get.ts +1 -4
- package/server/api/admin/layouts/[id].put.ts +1 -4
- package/server/api/auth/federated/login.post.ts +12 -5
- package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
- package/server/api/content/[id]/build.get.ts +11 -0
- package/server/api/content/[id]/report.post.ts +2 -0
- package/server/api/content/[id]/versions.get.ts +15 -0
- package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
- package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
- package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
- package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
- package/server/api/contests/[slug]/export.get.ts +43 -0
- package/server/api/contests/[slug]/judge.post.ts +8 -2
- package/server/api/contests/[slug]/proposal.post.ts +36 -0
- package/server/api/contests/[slug]/user-search.get.ts +30 -0
- package/server/api/contests/index.post.ts +1 -1
- package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
- package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
- package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
- package/server/api/docs/[siteSlug]/search.get.ts +7 -1
- package/server/api/events/[slug]/attendees.get.ts +10 -0
- package/server/api/events/[slug].get.ts +9 -0
- package/server/api/events/index.get.ts +8 -1
- package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
- package/server/api/federation/content/[id]/build.get.ts +10 -0
- package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
- package/server/api/hubs/[slug]/invites.get.ts +5 -3
- package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
- package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
- package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
- package/server/api/hubs/[slug]/requests.get.ts +20 -0
- package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
- package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
- package/server/api/products/[id].delete.ts +22 -2
- package/server/api/registry/ping.post.ts +17 -3
- package/server/api/search/index.get.ts +5 -3
- package/server/api/social/bookmark.get.ts +1 -0
- package/server/api/social/bookmark.post.ts +1 -0
- package/server/api/social/bookmarks.get.ts +1 -0
- package/server/api/social/comments/[id].delete.ts +1 -0
- package/server/api/social/comments.get.ts +1 -0
- package/server/api/social/comments.post.ts +1 -0
- package/server/api/social/like.get.ts +1 -0
- package/server/api/social/like.post.ts +1 -0
- package/server/api/users/[username]/content.get.ts +15 -3
- package/server/api/users/[username]/follow.delete.ts +1 -0
- package/server/api/users/[username]/follow.post.ts +1 -0
- package/server/api/users/[username]/followers.get.ts +2 -1
- package/server/api/users/[username]/following.get.ts +2 -1
- package/server/middleware/content-ap.ts +8 -3
- package/server/middleware/csrf.ts +93 -0
- package/server/plugins/federation-hub-sync.ts +48 -17
- package/server/plugins/notification-email.ts +22 -3
- package/server/routes/hubs/[slug]/inbox.ts +13 -1
- package/server/routes/inbox.ts +14 -1
- package/server/routes/users/[username]/inbox.ts +13 -1
- package/server/utils/inbox.ts +7 -2
- package/server/utils/validate.ts +22 -0
- package/theme/base.css +5 -0
- package/theme/prose.css +20 -0
- package/theme/stoa-dark.css +4 -0
- package/types/contestBlocks.ts +122 -0
- package/utils/contestBlocks.ts +107 -0
- package/utils/contestBody.ts +25 -0
- package/utils/contestStages.ts +62 -0
- package/utils/contestSubmission.ts +97 -0
- package/utils/datetime.ts +45 -0
- package/utils/projectBlocks.ts +162 -0
- package/components/editors/BlogEditor.vue +0 -648
package/layouts/default.vue
CHANGED
|
@@ -71,6 +71,15 @@ function handleGlobalKeydown(e: KeyboardEvent): void {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
const avatarBtnRef = ref<HTMLButtonElement | null>(null);
|
|
75
|
+
|
|
76
|
+
// Disclosure menu: Esc closes and returns focus to the trigger.
|
|
77
|
+
function closeUserMenu(): void {
|
|
78
|
+
if (!userMenuOpen.value) return;
|
|
79
|
+
userMenuOpen.value = false;
|
|
80
|
+
avatarBtnRef.value?.focus();
|
|
81
|
+
}
|
|
82
|
+
|
|
74
83
|
// Close menus on click outside
|
|
75
84
|
function handleClickOutside(e: MouseEvent): void {
|
|
76
85
|
const target = e.target as HTMLElement;
|
|
@@ -162,32 +171,32 @@ const userUsername = computed(() => user.value?.username ?? '');
|
|
|
162
171
|
<i class="fa-solid fa-plus"></i> <span class="cpub-new-text">New</span>
|
|
163
172
|
</NuxtLink>
|
|
164
173
|
<div class="cpub-user-menu-wrapper">
|
|
165
|
-
<button class="cpub-avatar-btn" aria-label="User menu" :aria-expanded="userMenuOpen" @click.stop="userMenuOpen = !userMenuOpen">
|
|
174
|
+
<button ref="avatarBtnRef" class="cpub-avatar-btn" aria-label="User menu" :aria-expanded="userMenuOpen" @click.stop="userMenuOpen = !userMenuOpen" @keydown.esc="closeUserMenu">
|
|
166
175
|
<span class="cpub-user-avatar">
|
|
167
176
|
<img v-if="userImage" :src="userImage" :alt="user?.name || user?.username" class="cpub-user-avatar-img" />
|
|
168
177
|
<span v-else>{{ userInitial }}</span>
|
|
169
178
|
</span>
|
|
170
179
|
</button>
|
|
171
|
-
<div v-if="userMenuOpen" class="cpub-user-dropdown"
|
|
180
|
+
<div v-if="userMenuOpen" class="cpub-user-dropdown" @keydown.esc="closeUserMenu">
|
|
172
181
|
<!-- Mobile-only: messages/notifications relocated here from
|
|
173
182
|
the top bar (hidden on desktop, which keeps the icons). -->
|
|
174
|
-
<NuxtLink to="/messages" class="cpub-dropdown-item cpub-dropdown-item--mobile"
|
|
183
|
+
<NuxtLink to="/messages" class="cpub-dropdown-item cpub-dropdown-item--mobile" @click="userMenuOpen = false">
|
|
175
184
|
<i class="fa-solid fa-envelope"></i> Messages
|
|
176
185
|
<span v-if="unreadMessages > 0" class="cpub-dropdown-count">{{ unreadMessages > 99 ? '99+' : unreadMessages }}</span>
|
|
177
186
|
</NuxtLink>
|
|
178
|
-
<NuxtLink to="/notifications" class="cpub-dropdown-item cpub-dropdown-item--mobile"
|
|
187
|
+
<NuxtLink to="/notifications" class="cpub-dropdown-item cpub-dropdown-item--mobile" @click="userMenuOpen = false">
|
|
179
188
|
<i class="fa-solid fa-bell"></i> Notifications
|
|
180
189
|
<span v-if="unreadCount > 0" class="cpub-dropdown-count">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
|
181
190
|
</NuxtLink>
|
|
182
191
|
<div class="cpub-dropdown-divider cpub-dropdown-item--mobile" />
|
|
183
|
-
<NuxtLink :to="`/u/${userUsername}`" class="cpub-dropdown-item"
|
|
184
|
-
<NuxtLink to="/dashboard" class="cpub-dropdown-item"
|
|
185
|
-
<NuxtLink to="/settings" class="cpub-dropdown-item"
|
|
186
|
-
<button class="cpub-dropdown-item"
|
|
192
|
+
<NuxtLink :to="`/u/${userUsername}`" class="cpub-dropdown-item" @click="userMenuOpen = false"><i class="fa-solid fa-user"></i> Profile</NuxtLink>
|
|
193
|
+
<NuxtLink to="/dashboard" class="cpub-dropdown-item" @click="userMenuOpen = false"><i class="fa-solid fa-gauge"></i> Dashboard</NuxtLink>
|
|
194
|
+
<NuxtLink to="/settings" class="cpub-dropdown-item" @click="userMenuOpen = false"><i class="fa-solid fa-gear"></i> Settings</NuxtLink>
|
|
195
|
+
<button class="cpub-dropdown-item" @click="setDarkMode(!isDark)">
|
|
187
196
|
<i :class="isDark ? 'fa-solid fa-sun' : 'fa-solid fa-moon'"></i> {{ isDark ? 'Light mode' : 'Dark mode' }}
|
|
188
197
|
</button>
|
|
189
198
|
<div class="cpub-dropdown-divider" />
|
|
190
|
-
<button class="cpub-dropdown-item"
|
|
199
|
+
<button class="cpub-dropdown-item" @click="handleSignOut"><i class="fa-solid fa-right-from-bracket"></i> Sign out</button>
|
|
191
200
|
</div>
|
|
192
201
|
</div>
|
|
193
202
|
</template>
|
package/nuxt.config.ts
CHANGED
|
@@ -100,6 +100,8 @@ export default defineNuxtConfig({
|
|
|
100
100
|
video: true,
|
|
101
101
|
contests: false,
|
|
102
102
|
contestStageSubmissions: true,
|
|
103
|
+
contestProposals: false,
|
|
104
|
+
contestPii: false,
|
|
103
105
|
events: false,
|
|
104
106
|
learning: true,
|
|
105
107
|
explainers: true,
|
|
@@ -117,6 +119,17 @@ export default defineNuxtConfig({
|
|
|
117
119
|
actAsRegistry: false,
|
|
118
120
|
announceToRegistry: true,
|
|
119
121
|
publicApiMetricsFederation: false,
|
|
122
|
+
// Nested identity sub-flags must be declared here too, or
|
|
123
|
+
// NUXT_PUBLIC_FEATURES_IDENTITY_* env overrides silently drop (same
|
|
124
|
+
// rule as the booleans above). Mirrors @commonpub/config's
|
|
125
|
+
// IdentityFeatures; all default false.
|
|
126
|
+
identity: {
|
|
127
|
+
linkRemoteAccounts: false,
|
|
128
|
+
signInWithRemote: false,
|
|
129
|
+
actingAs: false,
|
|
130
|
+
remoteInteract: false,
|
|
131
|
+
remotePublish: false,
|
|
132
|
+
},
|
|
120
133
|
},
|
|
121
134
|
contentTypes: 'project,blog,explainer',
|
|
122
135
|
contestCreation: 'admin',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.83.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"files": [
|
|
@@ -55,16 +55,16 @@
|
|
|
55
55
|
"vue-router": "^4.3.0",
|
|
56
56
|
"zod": "^4.3.6",
|
|
57
57
|
"@commonpub/auth": "0.8.0",
|
|
58
|
-
"@commonpub/config": "0.
|
|
59
|
-
"@commonpub/editor": "0.
|
|
58
|
+
"@commonpub/config": "0.23.0",
|
|
59
|
+
"@commonpub/editor": "0.8.0",
|
|
60
|
+
"@commonpub/explainer": "0.8.0",
|
|
61
|
+
"@commonpub/docs": "0.6.3",
|
|
62
|
+
"@commonpub/protocol": "0.14.0",
|
|
60
63
|
"@commonpub/learning": "0.5.2",
|
|
61
|
-
"@commonpub/
|
|
62
|
-
"@commonpub/server": "2.
|
|
64
|
+
"@commonpub/schema": "0.46.0",
|
|
65
|
+
"@commonpub/server": "2.90.0",
|
|
63
66
|
"@commonpub/theme-studio": "0.6.1",
|
|
64
|
-
"@commonpub/
|
|
65
|
-
"@commonpub/ui": "0.13.1",
|
|
66
|
-
"@commonpub/explainer": "0.7.15",
|
|
67
|
-
"@commonpub/schema": "0.45.0"
|
|
67
|
+
"@commonpub/ui": "0.13.1"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
70
|
"@testing-library/jest-dom": "^6.9.1",
|
package/pages/[type]/index.vue
CHANGED
|
@@ -23,7 +23,7 @@ useSeoMeta({
|
|
|
23
23
|
const sortBy = ref('recent');
|
|
24
24
|
const sortOptions = ['recent', 'popular'] as const;
|
|
25
25
|
|
|
26
|
-
const { data, pending } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
|
|
26
|
+
const { data, pending, error, refresh } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/content', {
|
|
27
27
|
query: computed(() => ({
|
|
28
28
|
status: 'published',
|
|
29
29
|
type: contentType.value,
|
|
@@ -48,6 +48,11 @@ const { data, pending } = await useFetch<PaginatedResponse<Serialized<ContentLis
|
|
|
48
48
|
</div>
|
|
49
49
|
|
|
50
50
|
<p v-if="pending" class="cpub-listing-empty"><i class="fa-solid fa-circle-notch fa-spin"></i> Loading...</p>
|
|
51
|
+
<div v-else-if="error" class="cpub-fetch-error">
|
|
52
|
+
<div class="cpub-fetch-error-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
|
|
53
|
+
<div class="cpub-fetch-error-msg">Failed to load {{ contentType }}s.</div>
|
|
54
|
+
<button class="cpub-btn cpub-btn-sm" @click="refresh()">Retry</button>
|
|
55
|
+
</div>
|
|
51
56
|
<div v-else-if="data?.items?.length" class="cpub-listing-grid">
|
|
52
57
|
<ContentCard v-for="item in data.items" :key="item.id" :item="item" />
|
|
53
58
|
</div>
|
package/pages/admin/api-keys.vue
CHANGED
|
@@ -6,6 +6,11 @@ definePageMeta({ layout: 'admin', middleware: 'auth' });
|
|
|
6
6
|
|
|
7
7
|
useSeoMeta({ title: `API Keys, Admin, ${useSiteName()}` });
|
|
8
8
|
|
|
9
|
+
// Keys only authenticate when the Public API feature is on (public-api-auth 404s
|
|
10
|
+
// every /api/public/* route otherwise). Surface that so admins don't mint dead keys.
|
|
11
|
+
const { publicApi } = useFeatures();
|
|
12
|
+
const toast = useToast();
|
|
13
|
+
|
|
9
14
|
interface KeyListResponse {
|
|
10
15
|
items: AdminApiKeyView[];
|
|
11
16
|
total: number;
|
|
@@ -122,8 +127,9 @@ async function revoke(id: string, name: string): Promise<void> {
|
|
|
122
127
|
try {
|
|
123
128
|
await $fetch(`/api/admin/api-keys/${id}`, { method: 'DELETE' });
|
|
124
129
|
await refresh();
|
|
130
|
+
toast.success(`Revoked "${name}"`);
|
|
125
131
|
} catch {
|
|
126
|
-
|
|
132
|
+
toast.error('Failed to revoke key');
|
|
127
133
|
}
|
|
128
134
|
}
|
|
129
135
|
|
|
@@ -185,12 +191,16 @@ function fmtErrorRate(rate: number): string {
|
|
|
185
191
|
<input type="checkbox" v-model="includeRevoked" @change="refresh()" />
|
|
186
192
|
Show revoked
|
|
187
193
|
</label>
|
|
188
|
-
<button class="cpub-btn cpub-btn-primary" @click="showCreate = true">
|
|
194
|
+
<button v-if="publicApi" class="cpub-btn cpub-btn-primary" @click="showCreate = true">
|
|
189
195
|
<i class="fa-solid fa-plus"></i> New key
|
|
190
196
|
</button>
|
|
191
197
|
</div>
|
|
192
198
|
</header>
|
|
193
199
|
|
|
200
|
+
<p v-if="!publicApi" class="cpub-admin-sub" role="status">
|
|
201
|
+
The Public API feature is disabled, so keys created here will not authenticate. Turn it on in Features to use API keys.
|
|
202
|
+
</p>
|
|
203
|
+
|
|
194
204
|
<!-- One-time token reveal -->
|
|
195
205
|
<div v-if="createdKey" class="cpub-key-reveal" role="alert">
|
|
196
206
|
<div class="cpub-key-reveal-head">
|
|
@@ -298,7 +308,7 @@ function fmtErrorRate(rate: number): string {
|
|
|
298
308
|
|
|
299
309
|
<!-- List -->
|
|
300
310
|
<div v-if="pending" class="cpub-loading">Loading keys...</div>
|
|
301
|
-
<p v-else-if="listError" class="cpub-form-error">Failed to load keys.</p>
|
|
311
|
+
<p v-else-if="publicApi && listError" class="cpub-form-error">Failed to load keys.</p>
|
|
302
312
|
<p v-else-if="!data?.items?.length" class="cpub-empty">
|
|
303
313
|
No API keys yet. Create one to start consuming <code>/api/public/v1/*</code>.
|
|
304
314
|
</p>
|
package/pages/admin/features.vue
CHANGED
|
@@ -25,6 +25,8 @@ const flagMeta: Record<string, { label: string; description: string; icon: strin
|
|
|
25
25
|
video: { label: 'Video', description: 'Video content and categories', icon: 'fa-solid fa-video' },
|
|
26
26
|
contests: { label: 'Contests', description: 'Contest system with judging', icon: 'fa-solid fa-trophy' },
|
|
27
27
|
contestStageSubmissions: { label: 'Contest Stage Submissions', description: 'Per-stage submission forms for multi-round contests', icon: 'fa-solid fa-file-pen' },
|
|
28
|
+
contestProposals: { label: 'Contest Proposals', description: 'Form-first proposal entries with a draft placeholder project', icon: 'fa-solid fa-clipboard-list' },
|
|
29
|
+
contestPii: { label: 'Contest PII Fields', description: 'Offer personal-data fields (email, address) in submission forms', icon: 'fa-solid fa-user-shield' },
|
|
28
30
|
learning: { label: 'Learning', description: 'Learning paths and courses', icon: 'fa-solid fa-graduation-cap' },
|
|
29
31
|
explainers: { label: 'Explainers', description: 'Interactive explainer modules', icon: 'fa-solid fa-lightbulb' },
|
|
30
32
|
editorial: { label: 'Editorial', description: 'Staff picks and content categories', icon: 'fa-solid fa-pen-fancy' },
|
|
@@ -17,11 +17,13 @@
|
|
|
17
17
|
import type { LayoutRecord } from '@commonpub/server';
|
|
18
18
|
import { PublishStepError } from '../../../composables/useLayoutEditor';
|
|
19
19
|
import { useLayoutAnnouncer, narrateUndo, narrateRedo, narrateUndoEmpty, narrateRedoEmpty, narrateRowAdded, narrateRowRemoved } from '../../../composables/useLayoutAnnouncer';
|
|
20
|
-
import { useLayoutHistory, addRowCommand, removeRowCommand } from '../../../composables/useLayoutHistory';
|
|
20
|
+
import { useLayoutHistory, addRowCommand, removeRowCommand, insertSectionCommand } from '../../../composables/useLayoutHistory';
|
|
21
21
|
import { useLayoutHotkeys } from '../../../composables/useLayoutHotkeys';
|
|
22
22
|
import { DnDProvider } from '@vue-dnd-kit/core';
|
|
23
23
|
import { useSectionRegistry } from '../../../sections/registry';
|
|
24
24
|
import { useLayoutResize } from '../../../composables/useLayoutResize';
|
|
25
|
+
import { createSectionFromSpec, paletteDragPayload } from '../../../composables/useLayoutDrag';
|
|
26
|
+
import type { SectionDefinition } from '@commonpub/ui';
|
|
25
27
|
|
|
26
28
|
definePageMeta({
|
|
27
29
|
layout: 'admin',
|
|
@@ -163,6 +165,32 @@ function onAddRow(zoneSlug: string): void {
|
|
|
163
165
|
}));
|
|
164
166
|
}
|
|
165
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Keyboard-insert from the palette — WCAG 2.1.1 alternative to drag. Mirrors the
|
|
170
|
+
* drop path (createSectionFromSpec + push); appends to the last row of the first
|
|
171
|
+
* populated zone, adding a row if the layout has none. Direct mutation fires the
|
|
172
|
+
* dirty watcher + autosave, exactly like a drop.
|
|
173
|
+
*/
|
|
174
|
+
function onPaletteInsert(section: SectionDefinition): void {
|
|
175
|
+
const draft = editor.draft.value;
|
|
176
|
+
if (!draft || draft.zones.length === 0) return;
|
|
177
|
+
const zone = draft.zones.find((z) => z.rows.length > 0) ?? draft.zones[0]!;
|
|
178
|
+
let row = zone.rows[zone.rows.length - 1];
|
|
179
|
+
// Record each mutation to history so Cmd+Z reverses it and the undo stack
|
|
180
|
+
// stays in sync with the draft (mirrors onAddRow + the LayoutRow drop path).
|
|
181
|
+
if (!row) {
|
|
182
|
+
const position = zone.rows.length;
|
|
183
|
+
row = { id: crypto.randomUUID(), order: position, config: null, sections: [] };
|
|
184
|
+
zone.rows.push(row);
|
|
185
|
+
history.record(addRowCommand({ zoneSlug: zone.zone, position, row, label: `add row to ${zone.zone}` }));
|
|
186
|
+
}
|
|
187
|
+
const newSection = createSectionFromSpec(paletteDragPayload(section));
|
|
188
|
+
const at = row.sections.length;
|
|
189
|
+
row.sections.push(newSection);
|
|
190
|
+
history.record(insertSectionCommand({ rowId: row.id, at, section: newSection, label: `insert ${section.type}` }));
|
|
191
|
+
useLayoutAnnouncer().announce(`Inserted ${section.name} section into ${zone.zone}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
166
194
|
/**
|
|
167
195
|
* Session 164 polish — Remove row. Confirm before removing rows with
|
|
168
196
|
* sections (destructive intent: section data goes away, only restorable
|
|
@@ -702,7 +730,7 @@ async function onConflictForceSave(): Promise<void> {
|
|
|
702
730
|
:on-add-row="onAddRow"
|
|
703
731
|
:on-remove-row="onRemoveRow"
|
|
704
732
|
/>
|
|
705
|
-
<AdminLayoutsPalette v-show="!chrome.paletteHidden.value" />
|
|
733
|
+
<AdminLayoutsPalette v-show="!chrome.paletteHidden.value" @insert="onPaletteInsert" />
|
|
706
734
|
<AdminLayoutsInspector
|
|
707
735
|
v-show="!chrome.inspectorHidden.value"
|
|
708
736
|
:draft="editor.draft.value"
|
package/pages/admin/settings.vue
CHANGED
|
@@ -4,6 +4,7 @@ definePageMeta({ layout: 'admin', middleware: 'auth' });
|
|
|
4
4
|
useSeoMeta({ title: `Settings, Admin, ${useSiteName()}` });
|
|
5
5
|
|
|
6
6
|
const { data: settings, pending, refresh } = await useFetch<Record<string, string>>('/api/admin/settings');
|
|
7
|
+
const toast = useToast();
|
|
7
8
|
|
|
8
9
|
const saving = ref(false);
|
|
9
10
|
const editKey = ref('');
|
|
@@ -40,7 +41,7 @@ async function saveSetting(key: string, value: string): Promise<void> {
|
|
|
40
41
|
editValue.value = '';
|
|
41
42
|
await refresh();
|
|
42
43
|
} catch {
|
|
43
|
-
|
|
44
|
+
toast.error('Failed to save setting');
|
|
44
45
|
} finally {
|
|
45
46
|
saving.value = false;
|
|
46
47
|
}
|
package/pages/admin/users.vue
CHANGED
|
@@ -219,7 +219,7 @@ async function deleteUser(userId: string, username: string): Promise<void> {
|
|
|
219
219
|
.admin-roles-editor { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 4px 0; }
|
|
220
220
|
.admin-roles-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); }
|
|
221
221
|
.admin-roles-check { display: flex; align-items: center; gap: 6px; font-size: 12px; cursor: pointer; }
|
|
222
|
-
.admin-roles-save { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; padding: 3px 10px; border: var(--border-width-default) solid var(--accent); background: var(--accent); color: var(--accent
|
|
222
|
+
.admin-roles-save { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; padding: 3px 10px; border: var(--border-width-default) solid var(--accent); background: var(--accent); color: var(--color-on-accent); cursor: pointer; margin-left: auto; }
|
|
223
223
|
.admin-roles-save:disabled { opacity: 0.6; cursor: default; }
|
|
224
224
|
.admin-empty { color: var(--text-faint); text-align: center; padding: 32px 0; }
|
|
225
225
|
</style>
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
definePageMeta({ layout: 'admin', middleware: 'auth' });
|
|
3
|
+
useSeoMeta({ title: `Video Categories, Admin, ${useSiteName()}` });
|
|
4
|
+
|
|
5
|
+
const toast = useToast();
|
|
6
|
+
|
|
7
|
+
interface VideoCategory {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
slug: string;
|
|
11
|
+
description: string | null;
|
|
12
|
+
sortOrder: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { data: categories, refresh } = await useFetch<VideoCategory[]>('/api/videos/categories');
|
|
16
|
+
|
|
17
|
+
const showForm = ref(false);
|
|
18
|
+
const editingId = ref<string | null>(null);
|
|
19
|
+
const form = ref({
|
|
20
|
+
name: '',
|
|
21
|
+
description: '',
|
|
22
|
+
sortOrder: 0,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function openNew(): void {
|
|
26
|
+
editingId.value = null;
|
|
27
|
+
form.value = { name: '', description: '', sortOrder: categories.value?.length ?? 0 };
|
|
28
|
+
showForm.value = true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function openEdit(cat: VideoCategory): void {
|
|
32
|
+
editingId.value = cat.id;
|
|
33
|
+
form.value = {
|
|
34
|
+
name: cat.name,
|
|
35
|
+
description: cat.description ?? '',
|
|
36
|
+
sortOrder: cat.sortOrder,
|
|
37
|
+
};
|
|
38
|
+
showForm.value = true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cancelForm(): void {
|
|
42
|
+
showForm.value = false;
|
|
43
|
+
editingId.value = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function saveCategory(): Promise<void> {
|
|
47
|
+
const payload = {
|
|
48
|
+
name: form.value.name,
|
|
49
|
+
description: form.value.description || undefined,
|
|
50
|
+
sortOrder: form.value.sortOrder,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
if (editingId.value) {
|
|
55
|
+
await $fetch(`/api/videos/categories/${editingId.value}`, { method: 'PUT', body: payload });
|
|
56
|
+
toast.success('Category updated');
|
|
57
|
+
} else {
|
|
58
|
+
await $fetch('/api/videos/categories', { method: 'POST', body: payload });
|
|
59
|
+
toast.success('Category created');
|
|
60
|
+
}
|
|
61
|
+
showForm.value = false;
|
|
62
|
+
editingId.value = null;
|
|
63
|
+
await refresh();
|
|
64
|
+
} catch {
|
|
65
|
+
toast.error('Failed to save category');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function deleteCategory(cat: VideoCategory): Promise<void> {
|
|
70
|
+
if (!confirm(`Delete "${cat.name}"? Videos using this category will become uncategorized.`)) return;
|
|
71
|
+
try {
|
|
72
|
+
await $fetch(`/api/videos/categories/${cat.id}`, { method: 'DELETE' });
|
|
73
|
+
toast.success('Category deleted');
|
|
74
|
+
await refresh();
|
|
75
|
+
} catch {
|
|
76
|
+
toast.error('Failed to delete category');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
<template>
|
|
82
|
+
<div class="cpub-admin-video-categories">
|
|
83
|
+
<div class="cpub-admin-header">
|
|
84
|
+
<h1 class="cpub-admin-title">Video Categories</h1>
|
|
85
|
+
<button class="cpub-btn cpub-btn-primary cpub-btn-sm" @click="openNew">
|
|
86
|
+
<i class="fa-solid fa-plus"></i> New Category
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<!-- Category Form -->
|
|
91
|
+
<div v-if="showForm" class="cpub-cat-form">
|
|
92
|
+
<h2 class="cpub-cat-form-title">{{ editingId ? 'Edit Category' : 'New Category' }}</h2>
|
|
93
|
+
<div class="cpub-cat-form-grid">
|
|
94
|
+
<div class="cpub-cat-field">
|
|
95
|
+
<label for="cpub-vcat-name" class="cpub-cat-label">Name</label>
|
|
96
|
+
<input id="cpub-vcat-name" v-model="form.name" class="cpub-cat-input" placeholder="e.g. Tutorials" />
|
|
97
|
+
</div>
|
|
98
|
+
<div class="cpub-cat-field">
|
|
99
|
+
<label for="cpub-vcat-order" class="cpub-cat-label">Sort Order</label>
|
|
100
|
+
<input id="cpub-vcat-order" v-model.number="form.sortOrder" type="number" class="cpub-cat-input" min="0" />
|
|
101
|
+
</div>
|
|
102
|
+
<div class="cpub-cat-field cpub-cat-field--wide">
|
|
103
|
+
<label for="cpub-vcat-desc" class="cpub-cat-label">Description</label>
|
|
104
|
+
<input id="cpub-vcat-desc" v-model="form.description" class="cpub-cat-input" placeholder="Optional description" />
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="cpub-cat-form-actions">
|
|
108
|
+
<button class="cpub-btn cpub-btn-primary cpub-btn-sm" :disabled="!form.name.trim()" @click="saveCategory">
|
|
109
|
+
{{ editingId ? 'Update' : 'Create' }}
|
|
110
|
+
</button>
|
|
111
|
+
<button class="cpub-btn cpub-btn-sm" @click="cancelForm">Cancel</button>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<!-- Categories Table -->
|
|
116
|
+
<div v-if="categories?.length" class="cpub-admin-table-wrap">
|
|
117
|
+
<table class="cpub-admin-table">
|
|
118
|
+
<thead>
|
|
119
|
+
<tr>
|
|
120
|
+
<th>Order</th>
|
|
121
|
+
<th>Name</th>
|
|
122
|
+
<th>Slug</th>
|
|
123
|
+
<th>Description</th>
|
|
124
|
+
<th>Actions</th>
|
|
125
|
+
</tr>
|
|
126
|
+
</thead>
|
|
127
|
+
<tbody>
|
|
128
|
+
<tr v-for="cat in categories" :key="cat.id">
|
|
129
|
+
<td class="cpub-admin-num">{{ cat.sortOrder }}</td>
|
|
130
|
+
<td><span class="cpub-cat-name">{{ cat.name }}</span></td>
|
|
131
|
+
<td class="cpub-admin-slug">{{ cat.slug }}</td>
|
|
132
|
+
<td class="cpub-admin-desc">{{ cat.description }}</td>
|
|
133
|
+
<td class="cpub-admin-actions">
|
|
134
|
+
<button class="cpub-admin-action" title="Edit" @click="openEdit(cat)">
|
|
135
|
+
<i class="fa-solid fa-pencil"></i>
|
|
136
|
+
</button>
|
|
137
|
+
<button class="cpub-admin-action cpub-admin-action--danger" title="Delete" @click="deleteCategory(cat)">
|
|
138
|
+
<i class="fa-solid fa-trash"></i>
|
|
139
|
+
</button>
|
|
140
|
+
</td>
|
|
141
|
+
</tr>
|
|
142
|
+
</tbody>
|
|
143
|
+
</table>
|
|
144
|
+
</div>
|
|
145
|
+
<p v-else class="cpub-empty">No video categories found. Create one to get started.</p>
|
|
146
|
+
</div>
|
|
147
|
+
</template>
|
|
148
|
+
|
|
149
|
+
<style scoped>
|
|
150
|
+
.cpub-admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-6); }
|
|
151
|
+
.cpub-admin-title { font-size: var(--text-xl); font-weight: var(--font-weight-bold); }
|
|
152
|
+
|
|
153
|
+
.cpub-cat-form {
|
|
154
|
+
background: var(--surface);
|
|
155
|
+
border: var(--border-width-default) solid var(--border);
|
|
156
|
+
padding: var(--space-5);
|
|
157
|
+
margin-bottom: var(--space-6);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.cpub-cat-form-title { font-size: var(--text-base); font-weight: 600; margin-bottom: var(--space-4); }
|
|
161
|
+
|
|
162
|
+
.cpub-cat-form-grid {
|
|
163
|
+
display: grid;
|
|
164
|
+
grid-template-columns: 1fr 1fr;
|
|
165
|
+
gap: var(--space-3);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.cpub-cat-field { display: flex; flex-direction: column; gap: 4px; }
|
|
169
|
+
.cpub-cat-field--wide { grid-column: 1 / -1; }
|
|
170
|
+
.cpub-cat-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-cat-input {
|
|
172
|
+
font-size: 13px;
|
|
173
|
+
padding: 6px 10px;
|
|
174
|
+
border: var(--border-width-default) solid var(--border);
|
|
175
|
+
background: var(--bg);
|
|
176
|
+
color: var(--text);
|
|
177
|
+
outline: none;
|
|
178
|
+
}
|
|
179
|
+
.cpub-cat-input:focus { border-color: var(--accent); }
|
|
180
|
+
|
|
181
|
+
.cpub-cat-form-actions { display: flex; gap: var(--space-2); margin-top: var(--space-4); }
|
|
182
|
+
|
|
183
|
+
.cpub-admin-table-wrap { overflow-x: auto; }
|
|
184
|
+
.cpub-admin-table { width: 100%; border-collapse: collapse; }
|
|
185
|
+
.cpub-admin-table th { font-family: var(--font-mono); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); text-align: left; padding: 8px 12px; border-bottom: var(--border-width-default) solid var(--border); }
|
|
186
|
+
.cpub-admin-table td { padding: 8px 12px; border-bottom: var(--border-width-default) solid var(--border2); font-size: 13px; }
|
|
187
|
+
.cpub-admin-num { font-family: var(--font-mono); font-size: 11px; color: var(--text-faint); }
|
|
188
|
+
.cpub-admin-slug { font-family: var(--font-mono); font-size: 11px; color: var(--text-dim); }
|
|
189
|
+
.cpub-admin-desc { color: var(--text-dim); }
|
|
190
|
+
.cpub-admin-actions { display: flex; gap: 6px; }
|
|
191
|
+
.cpub-admin-action { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; padding: 4px 6px; }
|
|
192
|
+
.cpub-admin-action:hover { color: var(--accent); }
|
|
193
|
+
.cpub-admin-action--danger:hover { color: var(--red); }
|
|
194
|
+
|
|
195
|
+
.cpub-cat-name { display: flex; align-items: center; gap: 6px; font-weight: 500; }
|
|
196
|
+
|
|
197
|
+
.cpub-empty { color: var(--text-faint); text-align: center; padding: var(--space-10) 0; }
|
|
198
|
+
|
|
199
|
+
@media (max-width: 768px) {
|
|
200
|
+
.cpub-cat-form-grid { grid-template-columns: 1fr; }
|
|
201
|
+
.cpub-admin-header { flex-direction: column; gap: var(--space-3); align-items: flex-start; }
|
|
202
|
+
}
|
|
203
|
+
</style>
|
package/pages/cert/[code].vue
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
const route = useRoute();
|
|
3
3
|
const code = route.params.code as string;
|
|
4
4
|
|
|
5
|
-
const { data: certData } = useLazyFetch(`/api/cert/${code}`);
|
|
5
|
+
const { data: certData, pending } = useLazyFetch(`/api/cert/${code}`);
|
|
6
6
|
|
|
7
7
|
useSeoMeta({
|
|
8
8
|
title: () => certData.value ? `Certificate, ${certData.value.path.title}, ${useSiteName()}` : `Certificate, ${useSiteName()}`,
|
|
@@ -12,7 +12,11 @@ useSeoMeta({
|
|
|
12
12
|
|
|
13
13
|
<template>
|
|
14
14
|
<div class="cert-page">
|
|
15
|
-
<div v-if="
|
|
15
|
+
<div v-if="pending" class="cert-not-found">
|
|
16
|
+
<div class="cert-not-found-icon"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
|
|
17
|
+
<p>Loading certificate...</p>
|
|
18
|
+
</div>
|
|
19
|
+
<div v-else-if="certData" class="cert-card">
|
|
16
20
|
<!-- Certificate Badge -->
|
|
17
21
|
<div class="cert-badge-wrap">
|
|
18
22
|
<div class="cert-badge">
|