@commonpub/layer 0.82.0 → 0.83.1
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/pages/mirror/[id].vue
CHANGED
|
@@ -146,7 +146,7 @@ useSeoMeta({
|
|
|
146
146
|
display: flex; align-items: center; gap: 4px; font-size: 11px;
|
|
147
147
|
}
|
|
148
148
|
.cpub-fed-banner-follow {
|
|
149
|
-
margin-left: auto; background: var(--accent); color: var(--accent
|
|
149
|
+
margin-left: auto; background: var(--accent); color: var(--color-on-accent); border: none;
|
|
150
150
|
font-size: 11px; font-weight: 600; padding: 3px 10px; cursor: pointer;
|
|
151
151
|
display: flex; align-items: center; gap: 4px; white-space: nowrap;
|
|
152
152
|
}
|
|
@@ -1,10 +1,45 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
const route = useRoute();
|
|
3
3
|
const slug = route.params.slug as string;
|
|
4
|
+
const toast = useToast();
|
|
4
5
|
|
|
5
|
-
const { data: product, pending } = useLazyFetch(`/api/products/${slug}`) as { data: Ref<Record<string, any> | null>; pending: Ref<boolean> };
|
|
6
|
+
const { data: product, pending, refresh } = useLazyFetch(`/api/products/${slug}`) as { data: Ref<Record<string, any> | null>; pending: Ref<boolean>; refresh: () => Promise<void> };
|
|
6
7
|
const { data: projectsUsing } = useLazyFetch(`/api/products/${slug}/content`) as { data: Ref<any[] | null> };
|
|
7
8
|
|
|
9
|
+
const { user } = useAuth();
|
|
10
|
+
const canModerate = useCan('content.moderate');
|
|
11
|
+
// PUT is owner-only on the server (updateProduct), so only the owner may edit.
|
|
12
|
+
const canEdit = computed(() => !!user.value && product.value?.createdBy?.id === user.value.id);
|
|
13
|
+
// DELETE allows owner OR content.moderate on the server.
|
|
14
|
+
const canDelete = computed(() => canEdit.value || canModerate.value);
|
|
15
|
+
|
|
16
|
+
const showEdit = ref(false);
|
|
17
|
+
const deleting = ref(false);
|
|
18
|
+
|
|
19
|
+
async function handleUpdated(updated: { slug: string }): Promise<void> {
|
|
20
|
+
// The slug regenerates when the name changes; route to the new slug if so.
|
|
21
|
+
if (updated.slug && updated.slug !== slug) {
|
|
22
|
+
await navigateTo(`/products/${updated.slug}`);
|
|
23
|
+
} else {
|
|
24
|
+
await refresh();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function handleDelete(): Promise<void> {
|
|
29
|
+
if (!product.value) return;
|
|
30
|
+
if (!confirm(`Delete "${product.value.name}"? This cannot be undone.`)) return;
|
|
31
|
+
deleting.value = true;
|
|
32
|
+
try {
|
|
33
|
+
await $fetch(`/api/products/${product.value.id}`, { method: 'DELETE' });
|
|
34
|
+
toast.success('Product deleted');
|
|
35
|
+
await navigateTo('/products');
|
|
36
|
+
} catch {
|
|
37
|
+
toast.error('Failed to delete product');
|
|
38
|
+
} finally {
|
|
39
|
+
deleting.value = false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
8
43
|
useSeoMeta({
|
|
9
44
|
title: () => product.value ? `${product.value.name}, ${useSiteName()}` : `Product, ${useSiteName()}`,
|
|
10
45
|
description: () => product.value?.description ?? '',
|
|
@@ -23,10 +58,18 @@ useSeoMeta({
|
|
|
23
58
|
<div class="product-main">
|
|
24
59
|
<div class="product-header">
|
|
25
60
|
<div class="product-icon"><i class="fa-solid fa-microchip"></i></div>
|
|
26
|
-
<div>
|
|
61
|
+
<div class="product-header-main">
|
|
27
62
|
<h1 class="product-name">{{ product.name }}</h1>
|
|
28
63
|
<span v-if="product.category" class="product-category">{{ product.category }}</span>
|
|
29
64
|
</div>
|
|
65
|
+
<div v-if="canEdit || canDelete" class="product-header-actions">
|
|
66
|
+
<button v-if="canEdit" class="cpub-btn cpub-btn-sm" aria-label="Edit product" @click="showEdit = true">
|
|
67
|
+
<i class="fa-solid fa-pen"></i> Edit
|
|
68
|
+
</button>
|
|
69
|
+
<button v-if="canDelete" class="cpub-btn cpub-btn-sm cpub-product-delete" aria-label="Delete product" :disabled="deleting" @click="handleDelete">
|
|
70
|
+
<i class="fa-solid fa-trash"></i> {{ deleting ? 'Deleting...' : 'Delete' }}
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
30
73
|
</div>
|
|
31
74
|
|
|
32
75
|
<p v-if="product.description" class="product-desc">{{ product.description }}</p>
|
|
@@ -83,6 +126,13 @@ useSeoMeta({
|
|
|
83
126
|
</div>
|
|
84
127
|
</aside>
|
|
85
128
|
</div>
|
|
129
|
+
|
|
130
|
+
<ProductEditModal
|
|
131
|
+
v-if="showEdit"
|
|
132
|
+
:product="{ id: product.id, name: product.name, description: product.description, category: product.category, purchaseUrl: product.purchaseUrl, datasheetUrl: product.datasheetUrl, status: product.status }"
|
|
133
|
+
@close="showEdit = false"
|
|
134
|
+
@updated="handleUpdated"
|
|
135
|
+
/>
|
|
86
136
|
</div>
|
|
87
137
|
<div v-else class="product-not-found">
|
|
88
138
|
<h1>Product not found</h1>
|
|
@@ -97,6 +147,9 @@ useSeoMeta({
|
|
|
97
147
|
.product-layout { display: grid; grid-template-columns: 1fr 280px; gap: 32px; }
|
|
98
148
|
|
|
99
149
|
.product-header { display: flex; align-items: center; gap: 16px; margin-bottom: 16px; }
|
|
150
|
+
.product-header-main { flex: 1; min-width: 0; }
|
|
151
|
+
.product-header-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
|
152
|
+
.cpub-product-delete:hover { color: var(--red); border-color: var(--red); }
|
|
100
153
|
.product-icon { width: 56px; height: 56px; border: var(--border-width-default) solid var(--border); background: var(--accent-bg); display: flex; align-items: center; justify-content: center; font-size: 24px; color: var(--accent); }
|
|
101
154
|
.product-name { font-size: 24px; font-weight: 700; letter-spacing: -0.02em; }
|
|
102
155
|
.product-category { font-size: 11px; font-family: var(--font-mono); color: var(--text-faint); text-transform: capitalize; }
|
package/pages/products/index.vue
CHANGED
|
@@ -4,7 +4,7 @@ useSeoMeta({ title: `Products, ${useSiteName()}`, description: 'Browse products,
|
|
|
4
4
|
const search = ref('');
|
|
5
5
|
const category = ref('');
|
|
6
6
|
|
|
7
|
-
const { data: productsData, pending } = useFetch('/api/products', {
|
|
7
|
+
const { data: productsData, pending, error, refresh } = useFetch('/api/products', {
|
|
8
8
|
query: computed(() => ({
|
|
9
9
|
q: search.value || undefined,
|
|
10
10
|
category: category.value || undefined,
|
|
@@ -57,6 +57,11 @@ const categories = [
|
|
|
57
57
|
|
|
58
58
|
<!-- Grid -->
|
|
59
59
|
<div v-if="pending" class="products-loading">Loading...</div>
|
|
60
|
+
<div v-else-if="error" class="cpub-fetch-error">
|
|
61
|
+
<div class="cpub-fetch-error-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
|
|
62
|
+
<div class="cpub-fetch-error-msg">Failed to load products.</div>
|
|
63
|
+
<button class="cpub-btn cpub-btn-sm" @click="refresh()">Retry</button>
|
|
64
|
+
</div>
|
|
60
65
|
<div v-else-if="products.length" class="products-grid">
|
|
61
66
|
<NuxtLink
|
|
62
67
|
v-for="product in products"
|
|
@@ -72,22 +72,22 @@ async function handleDeleteAccount(): Promise<void> {
|
|
|
72
72
|
<h2 class="cpub-section-title-lg">Account Settings</h2>
|
|
73
73
|
|
|
74
74
|
<div class="cpub-form-group">
|
|
75
|
-
<label class="cpub-form-label">Email</label>
|
|
76
|
-
<input type="email" class="cpub-input" :value="user?.email" disabled />
|
|
75
|
+
<label for="acct-email" class="cpub-form-label">Email</label>
|
|
76
|
+
<input id="acct-email" type="email" class="cpub-input" :value="user?.email" disabled />
|
|
77
77
|
<span class="cpub-form-hint">Contact support to change your email.</span>
|
|
78
78
|
</div>
|
|
79
79
|
|
|
80
80
|
<form class="cpub-form-group" @submit.prevent="handlePasswordChange">
|
|
81
|
-
<label class="cpub-form-label">Change Password</label>
|
|
82
|
-
<input v-model="currentPassword" type="password" class="cpub-input" placeholder="Current password" required />
|
|
83
|
-
<input v-model="newPassword" type="password" class="cpub-input cpub-mt-2" placeholder="New password (min 8 characters)" required minlength="8" />
|
|
81
|
+
<label for="acct-current-password" class="cpub-form-label">Change Password</label>
|
|
82
|
+
<input id="acct-current-password" v-model="currentPassword" type="password" class="cpub-input" placeholder="Current password" aria-label="Current password" required />
|
|
83
|
+
<input v-model="newPassword" type="password" class="cpub-input cpub-mt-2" placeholder="New password (min 8 characters)" aria-label="New password (min 8 characters)" required minlength="8" />
|
|
84
84
|
<button type="submit" class="cpub-btn cpub-btn-sm cpub-mt-2" :disabled="passwordLoading">
|
|
85
85
|
{{ passwordLoading ? 'Updating...' : 'Update Password' }}
|
|
86
86
|
</button>
|
|
87
87
|
</form>
|
|
88
88
|
|
|
89
89
|
<div class="cpub-form-group">
|
|
90
|
-
<
|
|
90
|
+
<span class="cpub-form-label">Your Data</span>
|
|
91
91
|
<p class="cpub-form-hint cpub-mb-2">Download a copy of all your data in JSON format.</p>
|
|
92
92
|
<a href="/api/auth/export-data" download class="cpub-btn cpub-btn-sm">
|
|
93
93
|
<i class="fa-solid fa-download"></i> Download My Data
|
|
@@ -104,8 +104,8 @@ async function handleDeleteAccount(): Promise<void> {
|
|
|
104
104
|
|
|
105
105
|
<template v-if="deleteConfirm">
|
|
106
106
|
<div class="cpub-form-group">
|
|
107
|
-
<label class="cpub-form-label">Type your username <strong>{{ user?.username ?? '' }}</strong> to confirm</label>
|
|
108
|
-
<input v-model="deleteConfirmText" type="text" class="cpub-input" :placeholder="user?.username" autocomplete="off" />
|
|
107
|
+
<label for="acct-delete-confirm" class="cpub-form-label">Type your username <strong>{{ user?.username ?? '' }}</strong> to confirm</label>
|
|
108
|
+
<input id="acct-delete-confirm" v-model="deleteConfirmText" type="text" class="cpub-input" :placeholder="user?.username" autocomplete="off" />
|
|
109
109
|
</div>
|
|
110
110
|
<div class="cpub-danger-actions">
|
|
111
111
|
<button
|
|
@@ -5,6 +5,7 @@ useSeoMeta({ title: `Edit Profile, ${useSiteName()}` });
|
|
|
5
5
|
const { user } = useAuth();
|
|
6
6
|
const toast = useToast();
|
|
7
7
|
const { extract: extractError } = useApiError();
|
|
8
|
+
const { uploadFile } = useFileUpload();
|
|
8
9
|
const saving = ref(false);
|
|
9
10
|
const isDirty = ref(false);
|
|
10
11
|
|
|
@@ -30,7 +31,14 @@ const form = ref({
|
|
|
30
31
|
bannerUrl: '',
|
|
31
32
|
});
|
|
32
33
|
|
|
34
|
+
// Stable row ids so v-for keys survive splice-removal (keying by array index
|
|
35
|
+
// rebinds v-model to the wrong row after deleting an entry).
|
|
36
|
+
let rowIdCounter = 0;
|
|
37
|
+
function nextRowId(): string { return `row-${rowIdCounter++}`; }
|
|
38
|
+
|
|
33
39
|
const skills = ref<string[]>([]);
|
|
40
|
+
// Parallel id list kept in lockstep with `skills` for stable v-for keys.
|
|
41
|
+
const skillIds = ref<string[]>([]);
|
|
34
42
|
const socialLinks = ref({
|
|
35
43
|
github: '',
|
|
36
44
|
twitter: '',
|
|
@@ -41,7 +49,7 @@ const socialLinks = ref({
|
|
|
41
49
|
discord: '',
|
|
42
50
|
});
|
|
43
51
|
const pronouns = ref('');
|
|
44
|
-
const experience = ref<Array<{ title: string; company: string; startDate: string; endDate: string; description: string }>>([]);
|
|
52
|
+
const experience = ref<Array<{ _id: string; title: string; company: string; startDate: string; endDate: string; description: string }>>([]);
|
|
45
53
|
|
|
46
54
|
const emailNotifications = ref<{
|
|
47
55
|
digest: 'daily' | 'weekly' | 'none';
|
|
@@ -80,6 +88,7 @@ if (profile.value) {
|
|
|
80
88
|
|
|
81
89
|
if (Array.isArray(p.skills)) {
|
|
82
90
|
skills.value = (p.skills as unknown[]).filter((s): s is string => typeof s === 'string');
|
|
91
|
+
skillIds.value = skills.value.map(() => nextRowId());
|
|
83
92
|
}
|
|
84
93
|
pronouns.value = p.pronouns || '';
|
|
85
94
|
if (p.socialLinks) {
|
|
@@ -95,6 +104,7 @@ if (profile.value) {
|
|
|
95
104
|
const profileRecord = p as Record<string, unknown>;
|
|
96
105
|
if (Array.isArray(profileRecord.experience)) {
|
|
97
106
|
experience.value = (profileRecord.experience as Array<Record<string, unknown>>).map((e) => ({
|
|
107
|
+
_id: nextRowId(),
|
|
98
108
|
title: String(e.title || ''),
|
|
99
109
|
company: String(e.company || ''),
|
|
100
110
|
startDate: String(e.startDate || ''),
|
|
@@ -123,14 +133,17 @@ onMounted(() => {
|
|
|
123
133
|
|
|
124
134
|
function addSkill(): void {
|
|
125
135
|
skills.value.push('');
|
|
136
|
+
skillIds.value.push(nextRowId());
|
|
126
137
|
}
|
|
127
138
|
|
|
128
139
|
function removeSkill(index: number): void {
|
|
129
140
|
skills.value.splice(index, 1);
|
|
141
|
+
skillIds.value.splice(index, 1);
|
|
130
142
|
}
|
|
131
143
|
|
|
132
144
|
function addExperience(): void {
|
|
133
145
|
experience.value.push({
|
|
146
|
+
_id: nextRowId(),
|
|
134
147
|
title: '',
|
|
135
148
|
company: '',
|
|
136
149
|
startDate: '',
|
|
@@ -146,11 +159,8 @@ function removeExperience(index: number): void {
|
|
|
146
159
|
async function handleAvatarUpload(event: Event): Promise<void> {
|
|
147
160
|
const file = (event.target as HTMLInputElement).files?.[0];
|
|
148
161
|
if (!file) return;
|
|
149
|
-
const formData = new FormData();
|
|
150
|
-
formData.append('file', file);
|
|
151
|
-
formData.append('purpose', 'avatar');
|
|
152
162
|
try {
|
|
153
|
-
const result = await
|
|
163
|
+
const result = await uploadFile(file, 'avatar');
|
|
154
164
|
form.value.avatarUrl = result.url;
|
|
155
165
|
} catch (err: unknown) {
|
|
156
166
|
toast.error(extractError(err));
|
|
@@ -160,11 +170,8 @@ async function handleAvatarUpload(event: Event): Promise<void> {
|
|
|
160
170
|
async function handleBannerUpload(event: Event): Promise<void> {
|
|
161
171
|
const file = (event.target as HTMLInputElement).files?.[0];
|
|
162
172
|
if (!file) return;
|
|
163
|
-
const formData = new FormData();
|
|
164
|
-
formData.append('file', file);
|
|
165
|
-
formData.append('purpose', 'banner');
|
|
166
173
|
try {
|
|
167
|
-
const result = await
|
|
174
|
+
const result = await uploadFile(file, 'banner');
|
|
168
175
|
form.value.bannerUrl = result.url;
|
|
169
176
|
} catch (err: unknown) {
|
|
170
177
|
toast.error(extractError(err));
|
|
@@ -179,7 +186,9 @@ async function handleSave(): Promise<void> {
|
|
|
179
186
|
body: {
|
|
180
187
|
...form.value,
|
|
181
188
|
skills: skills.value.filter((s) => s.trim()),
|
|
182
|
-
experience: experience.value
|
|
189
|
+
experience: experience.value
|
|
190
|
+
.filter((e) => e.title.trim())
|
|
191
|
+
.map(({ _id, ...rest }) => rest),
|
|
183
192
|
pronouns: pronouns.value || undefined,
|
|
184
193
|
socialLinks: socialLinks.value,
|
|
185
194
|
...(emailNotificationsEnabled.value ? { emailNotifications: emailNotifications.value } : {}),
|
|
@@ -206,7 +215,7 @@ async function handleSave(): Promise<void> {
|
|
|
206
215
|
|
|
207
216
|
<!-- Banner upload -->
|
|
208
217
|
<div class="cpub-form-group">
|
|
209
|
-
<
|
|
218
|
+
<span class="cpub-form-label">Banner Image</span>
|
|
210
219
|
<button
|
|
211
220
|
type="button"
|
|
212
221
|
class="cpub-banner-upload"
|
|
@@ -236,7 +245,7 @@ async function handleSave(): Promise<void> {
|
|
|
236
245
|
|
|
237
246
|
<!-- Avatar upload -->
|
|
238
247
|
<div class="cpub-form-group">
|
|
239
|
-
<
|
|
248
|
+
<span class="cpub-form-label">Avatar</span>
|
|
240
249
|
<button
|
|
241
250
|
type="button"
|
|
242
251
|
class="cpub-avatar-upload"
|
|
@@ -361,7 +370,7 @@ async function handleSave(): Promise<void> {
|
|
|
361
370
|
|
|
362
371
|
<div
|
|
363
372
|
v-for="(_skill, index) in skills"
|
|
364
|
-
:key="index"
|
|
373
|
+
:key="skillIds[index]"
|
|
365
374
|
class="cpub-skill-row"
|
|
366
375
|
>
|
|
367
376
|
<input
|
|
@@ -456,7 +465,7 @@ async function handleSave(): Promise<void> {
|
|
|
456
465
|
|
|
457
466
|
<div
|
|
458
467
|
v-for="(entry, index) in experience"
|
|
459
|
-
:key="
|
|
468
|
+
:key="entry._id"
|
|
460
469
|
class="cpub-experience-card"
|
|
461
470
|
>
|
|
462
471
|
<div class="cpub-experience-header">
|
|
@@ -121,11 +121,9 @@ const { errors: publishErrors, showErrors: showPublishErrors, validate, dismiss:
|
|
|
121
121
|
});
|
|
122
122
|
|
|
123
123
|
// --- Provide upload + search handlers to block components via inject ---
|
|
124
|
+
const { uploadFile } = useFileUpload();
|
|
124
125
|
provide(UPLOAD_HANDLER_KEY, async (file: File) => {
|
|
125
|
-
const
|
|
126
|
-
formData.append('file', file);
|
|
127
|
-
formData.append('purpose', 'content');
|
|
128
|
-
const res = await $fetch<{ url: string; width?: number | null; height?: number | null }>('/api/files/upload', { method: 'POST', body: formData });
|
|
126
|
+
const res = await uploadFile<{ url: string; width?: number | null; height?: number | null }>(file, 'content');
|
|
129
127
|
return { url: res.url, width: res.width ?? null, height: res.height ?? null };
|
|
130
128
|
});
|
|
131
129
|
|
|
@@ -306,7 +304,7 @@ async function handlePublish(): Promise<void> {
|
|
|
306
304
|
}
|
|
307
305
|
|
|
308
306
|
// --- Schedule (deferred publish) ---
|
|
309
|
-
// metadata.scheduledAt is set by the editor's schedule control (e.g.
|
|
307
|
+
// metadata.scheduledAt is set by the editor's schedule control (e.g. ArticleEditor).
|
|
310
308
|
const canSchedule = computed(() => !!(metadata.value.scheduledAt as string | undefined));
|
|
311
309
|
async function handleSchedule(): Promise<void> {
|
|
312
310
|
await doSchedule(validate);
|
|
@@ -345,6 +343,15 @@ if (import.meta.client) {
|
|
|
345
343
|
onUnmounted(() => { window.removeEventListener('beforeunload', onBeforeUnload); });
|
|
346
344
|
}
|
|
347
345
|
|
|
346
|
+
// --- Warn before in-app navigation (beforeunload only covers full-page unload) ---
|
|
347
|
+
onBeforeRouteLeave((_to, _from, next) => {
|
|
348
|
+
if (isDirty.value && !window.confirm('You have unsaved changes. Leave anyway?')) {
|
|
349
|
+
next(false);
|
|
350
|
+
} else {
|
|
351
|
+
next();
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
348
355
|
// --- Markdown import ---
|
|
349
356
|
const showImportDialog = ref(false);
|
|
350
357
|
const { importing, importMarkdown } = useMarkdownImport(blockEditor);
|
|
@@ -4,12 +4,20 @@ const username = route.params.username as string;
|
|
|
4
4
|
|
|
5
5
|
useSeoMeta({ title: `Followers, @${username}, ${useSiteName()}` });
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
type FollowRow = { id: string; username: string; displayName: string | null; avatarUrl: string | null; isFollowing?: boolean };
|
|
8
|
+
const { data: followers } = useLazyFetch<{ items: FollowRow[]; total: number }>(`/api/users/${username}/followers`);
|
|
8
9
|
const { isAuthenticated, user } = useAuth();
|
|
9
10
|
const toast = useToast();
|
|
10
11
|
|
|
11
12
|
const followingState = ref<Record<string, boolean>>({});
|
|
12
13
|
|
|
14
|
+
// Seed each row's button from the VIEWER's real relationship (server-provided).
|
|
15
|
+
watch(followers, (d) => {
|
|
16
|
+
for (const f of d?.items ?? []) {
|
|
17
|
+
followingState.value[f.username] = !!f.isFollowing;
|
|
18
|
+
}
|
|
19
|
+
}, { immediate: true });
|
|
20
|
+
|
|
13
21
|
async function toggleFollow(targetUsername: string, isFollowing: boolean): Promise<void> {
|
|
14
22
|
try {
|
|
15
23
|
if (isFollowing) {
|
|
@@ -32,8 +40,8 @@ async function toggleFollow(targetUsername: string, isFollowing: boolean): Promi
|
|
|
32
40
|
<h1 class="follow-title">Followers</h1>
|
|
33
41
|
</div>
|
|
34
42
|
|
|
35
|
-
<div v-if="followers?.length" class="follow-list">
|
|
36
|
-
<div v-for="f in
|
|
43
|
+
<div v-if="followers?.items?.length" class="follow-list">
|
|
44
|
+
<div v-for="f in followers.items" :key="f.id" class="follow-item">
|
|
37
45
|
<NuxtLink :to="`/u/${f.username}`" class="follow-user">
|
|
38
46
|
<div class="follow-avatar">
|
|
39
47
|
<img v-if="f.avatarUrl" :src="f.avatarUrl" :alt="f.displayName || f.username" class="follow-avatar-img" />
|
|
@@ -4,17 +4,19 @@ const username = route.params.username as string;
|
|
|
4
4
|
|
|
5
5
|
useSeoMeta({ title: `Following, @${username}, ${useSiteName()}` });
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
type FollowRow = { id: string; username: string; displayName: string | null; avatarUrl: string | null; isFollowing?: boolean };
|
|
8
|
+
const { data: following } = useLazyFetch<{ items: FollowRow[]; total: number }>(`/api/users/${username}/following`);
|
|
8
9
|
const { isAuthenticated, user } = useAuth();
|
|
9
10
|
const toast = useToast();
|
|
10
11
|
|
|
11
12
|
const followingState = ref<Record<string, boolean>>({});
|
|
12
13
|
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
// Seed each row from the VIEWER's real relationship. (This is the profile owner's
|
|
15
|
+
// following list — whether the VIEWER also follows each account is server-provided
|
|
16
|
+
// per row, NOT implied by membership in this list.)
|
|
17
|
+
watch(following, (d) => {
|
|
18
|
+
for (const u of d?.items ?? []) {
|
|
19
|
+
followingState.value[u.username] = !!u.isFollowing;
|
|
18
20
|
}
|
|
19
21
|
}, { immediate: true });
|
|
20
22
|
|
|
@@ -40,8 +42,8 @@ async function toggleFollow(targetUsername: string, isFollowing: boolean): Promi
|
|
|
40
42
|
<h1 class="follow-title">Following</h1>
|
|
41
43
|
</div>
|
|
42
44
|
|
|
43
|
-
<div v-if="following?.length" class="follow-list">
|
|
44
|
-
<div v-for="f in
|
|
45
|
+
<div v-if="following?.items?.length" class="follow-list">
|
|
46
|
+
<div v-for="f in following.items" :key="f.id" class="follow-item">
|
|
45
47
|
<NuxtLink :to="`/u/${f.username}`" class="follow-user">
|
|
46
48
|
<div class="follow-avatar">
|
|
47
49
|
<img v-if="f.avatarUrl" :src="f.avatarUrl" :alt="f.displayName || f.username" class="follow-avatar-img" />
|
|
@@ -41,6 +41,11 @@ const tabDefs = computed(() => {
|
|
|
41
41
|
if (learningEnabled.value) {
|
|
42
42
|
tabs.push({ value: 'learning', label: 'Learning', icon: 'fa-solid fa-graduation-cap' });
|
|
43
43
|
}
|
|
44
|
+
// Owner-only: their unpublished work. Draft visibility is also enforced
|
|
45
|
+
// server-side from the authenticated viewer, so this tab is the surface only.
|
|
46
|
+
if (isOwnProfile.value) {
|
|
47
|
+
tabs.push({ value: 'drafts', label: 'Drafts', icon: 'fa-solid fa-file-pen' });
|
|
48
|
+
}
|
|
44
49
|
tabs.push({ value: 'about', label: 'About', icon: 'fa-solid fa-id-card' });
|
|
45
50
|
return tabs;
|
|
46
51
|
});
|
|
@@ -97,6 +102,34 @@ const isOwnProfile = computed(() => user.value?.username === username);
|
|
|
97
102
|
const following = ref(false);
|
|
98
103
|
const followLoading = ref(false);
|
|
99
104
|
|
|
105
|
+
// Per-tab paginated content (projects/articles/explainers/drafts). The single
|
|
106
|
+
// `content` fetch above still feeds the heatmap + About-tab teaser; these tabs
|
|
107
|
+
// get their own keyset-paginated list with a Load more button.
|
|
108
|
+
const FEED_TAB_TYPE: Record<string, string | undefined> = {
|
|
109
|
+
projects: 'project',
|
|
110
|
+
articles: 'blog', // the 'blog' filter also matches legacy 'article' rows server-side
|
|
111
|
+
explainers: 'explainer',
|
|
112
|
+
drafts: undefined, // the owner's drafts of any type
|
|
113
|
+
};
|
|
114
|
+
const FEED_TABS = ['projects', 'articles', 'explainers', 'drafts'];
|
|
115
|
+
const isFeedTab = computed(() => FEED_TABS.includes(activeTab.value));
|
|
116
|
+
const feedQuery = computed(() => {
|
|
117
|
+
const isDrafts = activeTab.value === 'drafts';
|
|
118
|
+
const type = FEED_TAB_TYPE[activeTab.value];
|
|
119
|
+
return {
|
|
120
|
+
...(type ? { type } : {}),
|
|
121
|
+
...(isDrafts ? { drafts: 'true' } : {}),
|
|
122
|
+
limit: 12,
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
const {
|
|
126
|
+
items: tabItems,
|
|
127
|
+
pending: tabPending,
|
|
128
|
+
loadMore: loadMoreTab,
|
|
129
|
+
canLoadMore: canLoadMoreTab,
|
|
130
|
+
loadingMore: loadingMoreTab,
|
|
131
|
+
} = useProfileContent(username, feedQuery);
|
|
132
|
+
|
|
100
133
|
// Initialize follow state from API response
|
|
101
134
|
watch(() => profile.value, (profileData) => {
|
|
102
135
|
if (profileData && typeof (profileData as Record<string, unknown>).isFollowing === 'boolean') {
|
|
@@ -277,8 +310,8 @@ async function handleReport(): Promise<void> {
|
|
|
277
310
|
|
|
278
311
|
<!-- Content -->
|
|
279
312
|
<div class="cpub-profile-main">
|
|
280
|
-
<!-- Projects / Articles / Explainers /
|
|
281
|
-
<template v-if="
|
|
313
|
+
<!-- Projects / Articles / Explainers / Drafts tabs -->
|
|
314
|
+
<template v-if="isFeedTab">
|
|
282
315
|
<!-- Section header -->
|
|
283
316
|
<div class="cpub-sec-head">
|
|
284
317
|
<h2>
|
|
@@ -287,12 +320,23 @@ async function handleReport(): Promise<void> {
|
|
|
287
320
|
</h2>
|
|
288
321
|
</div>
|
|
289
322
|
|
|
290
|
-
<div v-if="
|
|
291
|
-
<
|
|
292
|
-
</div>
|
|
293
|
-
<div v-else class="cpub-empty-state">
|
|
294
|
-
<p class="cpub-empty-state-title">No {{ activeTab }} yet</p>
|
|
323
|
+
<div v-if="tabPending && !tabItems.length" class="cpub-empty-state">
|
|
324
|
+
<p class="cpub-empty-state-title">Loading...</p>
|
|
295
325
|
</div>
|
|
326
|
+
<template v-else>
|
|
327
|
+
<div v-if="tabItems.length" class="cpub-grid-3">
|
|
328
|
+
<ContentCard v-for="item in tabItems" :key="item.id" :item="item" />
|
|
329
|
+
</div>
|
|
330
|
+
<div v-else class="cpub-empty-state">
|
|
331
|
+
<p class="cpub-empty-state-title">{{ activeTab === 'drafts' ? 'No drafts yet' : `No ${activeTab} yet` }}</p>
|
|
332
|
+
</div>
|
|
333
|
+
<div v-if="canLoadMoreTab" class="cpub-load-more-row">
|
|
334
|
+
<button class="cpub-btn-load-more" :disabled="loadingMoreTab" @click="loadMoreTab">
|
|
335
|
+
<i :class="loadingMoreTab ? 'fa-solid fa-circle-notch fa-spin' : 'fa-solid fa-rotate'"></i>
|
|
336
|
+
{{ loadingMoreTab ? 'Loading...' : 'Load more' }}
|
|
337
|
+
</button>
|
|
338
|
+
</div>
|
|
339
|
+
</template>
|
|
296
340
|
</template>
|
|
297
341
|
|
|
298
342
|
<!-- Learning tab — Certificates + In-progress paths -->
|
|
@@ -894,4 +938,26 @@ async function handleReport(): Promise<void> {
|
|
|
894
938
|
.cpub-profile-stat { min-width: 100%; padding: 10px 16px; }
|
|
895
939
|
.cpub-profile-name { font-size: 18px; }
|
|
896
940
|
}
|
|
941
|
+
|
|
942
|
+
.cpub-load-more-row { display: flex; justify-content: center; padding: 20px 0 4px; }
|
|
943
|
+
.cpub-btn-load-more {
|
|
944
|
+
padding: 8px 28px;
|
|
945
|
+
background: var(--surface);
|
|
946
|
+
border: var(--border-width-default) solid var(--border);
|
|
947
|
+
color: var(--text-dim);
|
|
948
|
+
font-size: 12px;
|
|
949
|
+
font-family: var(--font-mono);
|
|
950
|
+
display: flex;
|
|
951
|
+
align-items: center;
|
|
952
|
+
gap: 8px;
|
|
953
|
+
transition: all 0.15s;
|
|
954
|
+
box-shadow: var(--shadow-sm);
|
|
955
|
+
cursor: pointer;
|
|
956
|
+
}
|
|
957
|
+
.cpub-btn-load-more:hover {
|
|
958
|
+
background: var(--surface2);
|
|
959
|
+
color: var(--text);
|
|
960
|
+
box-shadow: var(--shadow-md);
|
|
961
|
+
transform: translate(-1px, -1px);
|
|
962
|
+
}
|
|
897
963
|
</style>
|
package/pages/videos/index.vue
CHANGED
|
@@ -22,6 +22,9 @@ const { data: videosData, pending: loadingVideos } = useFetch<{ items: any[]; to
|
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
const videos = computed(() => videosData.value?.items ?? []);
|
|
25
|
+
// The first video is shown in the Featured hero, so the grid below skips it
|
|
26
|
+
// (otherwise the most-recent video renders twice).
|
|
27
|
+
const gridVideos = computed(() => videos.value.slice(1));
|
|
25
28
|
const totalVideos = computed(() => videosData.value?.total ?? 0);
|
|
26
29
|
|
|
27
30
|
const filterOptions = computed(() => {
|
|
@@ -75,8 +78,7 @@ function formatDate(dateStr: string): string {
|
|
|
75
78
|
<select v-model="sortOption" class="cpub-sort-select">
|
|
76
79
|
<option value="recent">Sort: Most Recent</option>
|
|
77
80
|
<option value="viewed">Sort: Most Viewed</option>
|
|
78
|
-
<option value="
|
|
79
|
-
<option value="shortest">Sort: Shortest First</option>
|
|
81
|
+
<option value="liked">Sort: Most Liked</option>
|
|
80
82
|
</select>
|
|
81
83
|
</div>
|
|
82
84
|
</div>
|
|
@@ -113,10 +115,10 @@ function formatDate(dateStr: string): string {
|
|
|
113
115
|
</div>
|
|
114
116
|
</div>
|
|
115
117
|
|
|
116
|
-
<hr v-if="
|
|
118
|
+
<hr v-if="gridVideos.length" class="cpub-divider" style="margin:20px 0;" />
|
|
117
119
|
|
|
118
|
-
<!-- VIDEO GRID -->
|
|
119
|
-
<div class="cpub-sec-head">
|
|
120
|
+
<!-- VIDEO GRID — shown when there are non-featured videos, or while loading -->
|
|
121
|
+
<div v-if="loadingVideos || gridVideos.length" class="cpub-sec-head">
|
|
120
122
|
<h2>Recent Videos</h2>
|
|
121
123
|
<span class="cpub-sec-sub">{{ totalVideos }} videos</span>
|
|
122
124
|
</div>
|
|
@@ -134,15 +136,16 @@ function formatDate(dateStr: string): string {
|
|
|
134
136
|
</div>
|
|
135
137
|
</div>
|
|
136
138
|
|
|
137
|
-
<!-- Real data -->
|
|
138
|
-
<div v-else-if="
|
|
139
|
-
<NuxtLink v-for="v in
|
|
139
|
+
<!-- Real data (skip videos[0] — it's the Featured hero above) -->
|
|
140
|
+
<div v-else-if="gridVideos.length" class="cpub-video-grid">
|
|
141
|
+
<NuxtLink v-for="v in gridVideos" :key="v.id" :to="`/videos/${v.id}`" style="text-decoration: none;">
|
|
140
142
|
<VideoCard :video="v" />
|
|
141
143
|
</NuxtLink>
|
|
142
144
|
</div>
|
|
143
145
|
|
|
144
|
-
<!-- Empty state
|
|
145
|
-
|
|
146
|
+
<!-- Empty state — only when there are genuinely no videos (not just an
|
|
147
|
+
empty grid because the sole video is in the Featured hero above). -->
|
|
148
|
+
<div v-else-if="!videos.length" class="cpub-empty-state">
|
|
146
149
|
<div class="cpub-empty-icon"><i class="fa-solid fa-film"></i></div>
|
|
147
150
|
<p class="cpub-empty-title">No videos yet</p>
|
|
148
151
|
<p class="cpub-empty-sub">Be the first to upload a video to the community.</p>
|
|
@@ -6,9 +6,9 @@ const querySchema = z.object({
|
|
|
6
6
|
});
|
|
7
7
|
|
|
8
8
|
export default defineEventHandler(async (event) => {
|
|
9
|
+
requireFeature('publicApi');
|
|
9
10
|
requirePermission(event, 'apikeys.manage');
|
|
10
|
-
const id =
|
|
11
|
-
if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing id' });
|
|
11
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
12
12
|
const parsed = querySchema.safeParse(getQuery(event));
|
|
13
13
|
if (!parsed.success) {
|
|
14
14
|
throw createError({ statusCode: 400, statusMessage: 'Invalid query parameters' });
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { revokeApiKey, createAuditEntry } from '@commonpub/server';
|
|
2
2
|
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
|
+
requireFeature('publicApi');
|
|
4
5
|
const user = requirePermission(event, 'apikeys.manage');
|
|
5
|
-
const id =
|
|
6
|
-
if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing id' });
|
|
6
|
+
const { id } = parseParams(event, { id: 'uuid' });
|
|
7
7
|
const db = useDB();
|
|
8
8
|
const result = await revokeApiKey(db, id, user.id);
|
|
9
9
|
if (!result) throw createError({ statusCode: 404, statusMessage: 'Key not found or already revoked' });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { listApiKeys } from '@commonpub/server';
|
|
2
2
|
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
|
+
requireFeature('publicApi');
|
|
4
5
|
requirePermission(event, 'apikeys.manage');
|
|
5
6
|
const query = getQuery(event);
|
|
6
7
|
const includeRevoked = query.includeRevoked === 'true' || query.includeRevoked === '1';
|
|
@@ -14,6 +14,7 @@ import { createApiKeySchema } from '@commonpub/schema';
|
|
|
14
14
|
* land in the metadata column.
|
|
15
15
|
*/
|
|
16
16
|
export default defineEventHandler(async (event) => {
|
|
17
|
+
requireFeature('publicApi');
|
|
17
18
|
const user = requirePermission(event, 'apikeys.manage');
|
|
18
19
|
const body = await readBody(event);
|
|
19
20
|
const parsed = createApiKeySchema.safeParse(body);
|