@commonpub/layer 0.81.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 +11 -3
- package/components/contest/ContestStageSubmission.vue +10 -36
- package/components/contest/ContestStagesEditor.vue +141 -65
- package/components/contest/ContestStakeholderManager.vue +54 -20
- 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/useAuth.ts +13 -0
- package/composables/useCan.ts +23 -0
- 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 +43 -18
- package/layouts/default.vue +18 -9
- package/nuxt.config.ts +13 -0
- package/package.json +8 -8
- 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/roles.vue +286 -0
- package/pages/admin/settings.vue +2 -1
- package/pages/admin/users.vue +81 -1
- package/pages/admin/video-categories.vue +203 -0
- package/pages/cert/[code].vue +6 -2
- package/pages/contests/[slug]/edit.vue +4 -764
- package/pages/contests/[slug]/entries/[entryId].vue +34 -1
- package/pages/contests/[slug]/index.vue +97 -8
- 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/admin/permissions.get.ts +14 -0
- package/server/api/admin/roles/[id]/index.delete.ts +25 -0
- package/server/api/admin/roles/[id]/index.put.ts +24 -0
- package/server/api/admin/roles/index.get.ts +10 -0
- package/server/api/admin/roles/index.post.ts +27 -0
- package/server/api/admin/users/[id]/role.put.ts +20 -1
- package/server/api/admin/users/[id]/roles.get.ts +10 -0
- package/server/api/admin/users/[id]/roles.put.ts +17 -0
- 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]/advance.post.ts +10 -5
- 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]/index.get.ts +10 -2
- package/server/api/contests/[slug]/index.put.ts +11 -2
- 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]/stakeholders/index.post.ts +12 -3
- package/server/api/contests/[slug]/transition.post.ts +8 -3
- 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/me.get.ts +7 -0
- 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/admin/users.vue
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import type { RoleWithPermissions } from '@commonpub/server';
|
|
3
|
+
|
|
2
4
|
definePageMeta({ layout: 'admin', middleware: 'auth' });
|
|
3
5
|
useSeoMeta({ title: `Users, Admin, ${useSiteName()}` });
|
|
4
6
|
|
|
@@ -9,6 +11,11 @@ const { data: users, refresh } = await useFetch('/api/admin/users', {
|
|
|
9
11
|
query: computed(() => ({ search: search.value || undefined })),
|
|
10
12
|
});
|
|
11
13
|
|
|
14
|
+
// Custom (non-system) roles — for per-user assignment. Requires `roles.manage`;
|
|
15
|
+
// useFetch won't crash the page if the viewer lacks it (data stays null).
|
|
16
|
+
const { data: allRoles } = await useFetch<RoleWithPermissions[]>('/api/admin/roles');
|
|
17
|
+
const customRoles = computed<RoleWithPermissions[]>(() => (allRoles.value ?? []).filter((r) => !r.isSystem));
|
|
18
|
+
|
|
12
19
|
interface AdminUser {
|
|
13
20
|
id: string;
|
|
14
21
|
username: string;
|
|
@@ -39,6 +46,49 @@ async function changeRole(userId: string, role: string): Promise<void> {
|
|
|
39
46
|
}
|
|
40
47
|
}
|
|
41
48
|
|
|
49
|
+
// --- Custom-role assignment (expand a row to edit a user's custom roles) ---
|
|
50
|
+
const expandedUserId = ref<string | null>(null);
|
|
51
|
+
const editingRoleIds = ref<Set<string>>(new Set());
|
|
52
|
+
const savingRoles = ref(false);
|
|
53
|
+
|
|
54
|
+
async function toggleRolesEditor(userId: string): Promise<void> {
|
|
55
|
+
if (expandedUserId.value === userId) {
|
|
56
|
+
expandedUserId.value = null;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
expandedUserId.value = userId;
|
|
60
|
+
editingRoleIds.value = new Set();
|
|
61
|
+
try {
|
|
62
|
+
const { roleIds } = await $fetch<{ roleIds: string[] }>(`/api/admin/users/${userId}/roles`);
|
|
63
|
+
editingRoleIds.value = new Set(roleIds);
|
|
64
|
+
} catch {
|
|
65
|
+
toast.error('Could not load this user’s roles');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function toggleRoleId(roleId: string): void {
|
|
70
|
+
const next = new Set(editingRoleIds.value);
|
|
71
|
+
if (next.has(roleId)) next.delete(roleId);
|
|
72
|
+
else next.add(roleId);
|
|
73
|
+
editingRoleIds.value = next;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function saveRoles(userId: string): Promise<void> {
|
|
77
|
+
savingRoles.value = true;
|
|
78
|
+
try {
|
|
79
|
+
// Only custom role ids are sent; the system/primary role is the dropdown above.
|
|
80
|
+
const customIds = new Set(customRoles.value.map((r) => r.id));
|
|
81
|
+
const roleIds = [...editingRoleIds.value].filter((id) => customIds.has(id));
|
|
82
|
+
await $fetch(`/api/admin/users/${userId}/roles`, { method: 'PUT', body: { roleIds } });
|
|
83
|
+
toast.success('Custom roles updated');
|
|
84
|
+
expandedUserId.value = null;
|
|
85
|
+
} catch {
|
|
86
|
+
toast.error('Failed to update custom roles');
|
|
87
|
+
} finally {
|
|
88
|
+
savingRoles.value = false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
42
92
|
async function toggleStatus(userId: string, currentStatus: string): Promise<void> {
|
|
43
93
|
const newStatus = currentStatus === 'active' ? 'suspended' : 'active';
|
|
44
94
|
try {
|
|
@@ -78,13 +128,15 @@ async function deleteUser(userId: string, username: string): Promise<void> {
|
|
|
78
128
|
<th>Username</th>
|
|
79
129
|
<th>Email</th>
|
|
80
130
|
<th>Role</th>
|
|
131
|
+
<th v-if="customRoles.length">Custom roles</th>
|
|
81
132
|
<th>Status</th>
|
|
82
133
|
<th>Joined</th>
|
|
83
134
|
<th>Actions</th>
|
|
84
135
|
</tr>
|
|
85
136
|
</thead>
|
|
86
137
|
<tbody>
|
|
87
|
-
<
|
|
138
|
+
<template v-for="u in userList" :key="u.id">
|
|
139
|
+
<tr>
|
|
88
140
|
<td>
|
|
89
141
|
<NuxtLink :to="`/u/${u.username}`" class="admin-link">@{{ u.username }}</NuxtLink>
|
|
90
142
|
</td>
|
|
@@ -98,6 +150,11 @@ async function deleteUser(userId: string, username: string): Promise<void> {
|
|
|
98
150
|
<option v-for="r in roles" :key="r" :value="r">{{ r }}</option>
|
|
99
151
|
</select>
|
|
100
152
|
</td>
|
|
153
|
+
<td v-if="customRoles.length">
|
|
154
|
+
<button class="admin-roles-btn" :aria-expanded="expandedUserId === u.id" @click="toggleRolesEditor(u.id)">
|
|
155
|
+
<i class="fa-solid fa-user-shield"></i> {{ expandedUserId === u.id ? 'Close' : 'Assign' }}
|
|
156
|
+
</button>
|
|
157
|
+
</td>
|
|
101
158
|
<td>
|
|
102
159
|
<button
|
|
103
160
|
class="admin-status-btn"
|
|
@@ -114,6 +171,21 @@ async function deleteUser(userId: string, username: string): Promise<void> {
|
|
|
114
171
|
</button>
|
|
115
172
|
</td>
|
|
116
173
|
</tr>
|
|
174
|
+
<tr v-if="expandedUserId === u.id && customRoles.length" class="admin-roles-row">
|
|
175
|
+
<td :colspan="7">
|
|
176
|
+
<div class="admin-roles-editor">
|
|
177
|
+
<span class="admin-roles-label">Custom roles for @{{ u.username }}:</span>
|
|
178
|
+
<label v-for="r in customRoles" :key="r.id" class="admin-roles-check">
|
|
179
|
+
<input type="checkbox" :checked="editingRoleIds.has(r.id)" @change="toggleRoleId(r.id)" />
|
|
180
|
+
<span>{{ r.name }}</span>
|
|
181
|
+
</label>
|
|
182
|
+
<button class="admin-roles-save" :disabled="savingRoles" @click="saveRoles(u.id)">
|
|
183
|
+
{{ savingRoles ? 'Saving...' : 'Save' }}
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
</td>
|
|
187
|
+
</tr>
|
|
188
|
+
</template>
|
|
117
189
|
</tbody>
|
|
118
190
|
</table>
|
|
119
191
|
</div>
|
|
@@ -141,5 +213,13 @@ async function deleteUser(userId: string, username: string): Promise<void> {
|
|
|
141
213
|
.admin-status-btn:hover { opacity: 0.8; }
|
|
142
214
|
.admin-delete-btn { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 12px; padding: 4px 6px; }
|
|
143
215
|
.admin-delete-btn:hover { color: var(--red); }
|
|
216
|
+
.admin-roles-btn { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; padding: 3px 8px; border: var(--border-width-default) solid var(--border2); background: var(--surface); color: var(--text-dim); cursor: pointer; }
|
|
217
|
+
.admin-roles-btn:hover { border-color: var(--accent); color: var(--text); }
|
|
218
|
+
.admin-roles-row td { background: var(--surface2); }
|
|
219
|
+
.admin-roles-editor { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 4px 0; }
|
|
220
|
+
.admin-roles-label { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); }
|
|
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(--color-on-accent); cursor: pointer; margin-left: auto; }
|
|
223
|
+
.admin-roles-save:disabled { opacity: 0.6; cursor: default; }
|
|
144
224
|
.admin-empty { color: var(--text-faint); text-align: center; padding: 32px 0; }
|
|
145
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">
|