@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
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { ContentViewData } from '../../composables/useEngagement';
|
|
3
|
+
import {
|
|
4
|
+
extractParts,
|
|
5
|
+
extractBuildSteps,
|
|
6
|
+
extractCodeBlocks,
|
|
7
|
+
extractDownloadFiles,
|
|
8
|
+
extractTocEntries,
|
|
9
|
+
} from '../../utils/projectBlocks';
|
|
10
|
+
// Explicit import (not Nuxt auto-import): ProjectView.test.ts mounts the SFC with
|
|
11
|
+
// no Nuxt transform, so an auto-imported composable would be undefined there.
|
|
12
|
+
import { useScrollSpy } from '../../composables/useScrollSpy';
|
|
3
13
|
|
|
4
14
|
const { hubs: hubsEnabled } = useFeatures();
|
|
5
15
|
const { user: authUser } = useAuth();
|
|
@@ -36,6 +46,23 @@ const tabs = computed(() => {
|
|
|
36
46
|
return result;
|
|
37
47
|
});
|
|
38
48
|
|
|
49
|
+
// Roving-tabindex keyboard nav for the tablist (WCAG 4.1.2 / APG tabs pattern):
|
|
50
|
+
// Arrow keys move + activate the adjacent tab, Home/End jump to the ends.
|
|
51
|
+
function onTabKeydown(e: KeyboardEvent, idx: number): void {
|
|
52
|
+
const count = tabs.value.length;
|
|
53
|
+
let next = -1;
|
|
54
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (idx + 1) % count;
|
|
55
|
+
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (idx - 1 + count) % count;
|
|
56
|
+
else if (e.key === 'Home') next = 0;
|
|
57
|
+
else if (e.key === 'End') next = count - 1;
|
|
58
|
+
else return;
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
const tab = tabs.value[next];
|
|
61
|
+
if (!tab) return;
|
|
62
|
+
activeTab.value = tab.value;
|
|
63
|
+
nextTick(() => document.getElementById(`cpub-tab-${tab.value}`)?.focus());
|
|
64
|
+
}
|
|
65
|
+
|
|
39
66
|
const contentId = computed(() => props.content?.id);
|
|
40
67
|
const contentType = computed(() => props.content?.type ?? 'project');
|
|
41
68
|
const fedId = computed(() => props.federatedId);
|
|
@@ -43,6 +70,7 @@ const { liked, bookmarked, likeCount, isFederated, toggleLike, toggleBookmark, s
|
|
|
43
70
|
|
|
44
71
|
onMounted(() => {
|
|
45
72
|
fetchInitialState(props.content?.likeCount ?? 0);
|
|
73
|
+
hydrateBuildState();
|
|
46
74
|
});
|
|
47
75
|
|
|
48
76
|
const config = useRuntimeConfig();
|
|
@@ -93,203 +121,27 @@ const formattedDate = computed(() => {
|
|
|
93
121
|
return new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
94
122
|
});
|
|
95
123
|
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
productId: part.productId as string | undefined,
|
|
116
|
-
notes: (part.notes as string) || '',
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return items;
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
// Extract build steps from content
|
|
125
|
-
interface BuildStep {
|
|
126
|
-
number: number;
|
|
127
|
-
title: string;
|
|
128
|
-
children: Array<[string, Record<string, unknown>]>;
|
|
129
|
-
time?: string;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const buildStepsFromBlocks = computed<BuildStep[]>(() => {
|
|
133
|
-
const blocks = props.content?.content;
|
|
134
|
-
if (!Array.isArray(blocks)) return [];
|
|
135
|
-
const steps: BuildStep[] = [];
|
|
136
|
-
let stepNum = 0;
|
|
137
|
-
for (const block of blocks) {
|
|
138
|
-
const [type, data] = block as [string, Record<string, unknown>];
|
|
139
|
-
if (type === 'buildStep') {
|
|
140
|
-
stepNum++;
|
|
141
|
-
// Migrate old format (instructions + image) to children
|
|
142
|
-
let children: Array<[string, Record<string, unknown>]> = [];
|
|
143
|
-
if (data.children && Array.isArray(data.children) && data.children.length > 0) {
|
|
144
|
-
children = data.children as Array<[string, Record<string, unknown>]>;
|
|
145
|
-
} else {
|
|
146
|
-
const instructions = data.instructions as string | undefined;
|
|
147
|
-
if (instructions && instructions.trim()) {
|
|
148
|
-
const html = instructions.startsWith('<') ? instructions : `<p>${instructions}</p>`;
|
|
149
|
-
children.push(['paragraph', { html }]);
|
|
150
|
-
}
|
|
151
|
-
const image = data.image as string | undefined;
|
|
152
|
-
if (image && image.trim()) {
|
|
153
|
-
children.push(['image', { src: image, alt: `Step ${stepNum}`, caption: '' }]);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
steps.push({
|
|
157
|
-
number: (data.stepNumber as number) || stepNum,
|
|
158
|
-
title: (data.title as string) || `Step ${stepNum}`,
|
|
159
|
-
children,
|
|
160
|
-
time: data.time as string | undefined,
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return steps;
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
// Extract code blocks for code tab
|
|
168
|
-
interface CodeSnippet {
|
|
169
|
-
language: string;
|
|
170
|
-
filename: string;
|
|
171
|
-
code: string;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const codeBlocks = computed<CodeSnippet[]>(() => {
|
|
175
|
-
const blocks = props.content?.content;
|
|
176
|
-
if (!Array.isArray(blocks)) return [];
|
|
177
|
-
const snippets: CodeSnippet[] = [];
|
|
178
|
-
for (const block of blocks) {
|
|
179
|
-
const [type, data] = block as [string, Record<string, unknown>];
|
|
180
|
-
if (type === 'code_block' || type === 'codeBlock') {
|
|
181
|
-
snippets.push({
|
|
182
|
-
language: (data.language as string) || '',
|
|
183
|
-
filename: (data.filename as string) || '',
|
|
184
|
-
code: (data.code as string) || '',
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return snippets;
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
// Extract download blocks for files tab
|
|
192
|
-
interface FileItem {
|
|
193
|
-
name: string;
|
|
194
|
-
url: string;
|
|
195
|
-
size?: string;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const downloadFiles = computed<FileItem[]>(() => {
|
|
199
|
-
const blocks = props.content?.content;
|
|
200
|
-
if (!Array.isArray(blocks)) return [];
|
|
201
|
-
const files: FileItem[] = [];
|
|
202
|
-
for (const block of blocks) {
|
|
203
|
-
const [type, data] = block as [string, Record<string, unknown>];
|
|
204
|
-
if (type === 'downloads' && Array.isArray(data.files)) {
|
|
205
|
-
for (const file of data.files as Array<Record<string, unknown>>) {
|
|
206
|
-
files.push({
|
|
207
|
-
name: (file.name as string) || 'Unknown',
|
|
208
|
-
url: (file.url as string) || '',
|
|
209
|
-
size: (file.size as string) || '',
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
return files;
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
// Extract headings from content for table of contents
|
|
218
|
-
interface TocEntry { id: string; text: string; level: number }
|
|
219
|
-
const tocEntries = computed<TocEntry[]>(() => {
|
|
220
|
-
const blocks = props.content?.content;
|
|
221
|
-
if (!Array.isArray(blocks)) return [];
|
|
222
|
-
const entries: TocEntry[] = [];
|
|
223
|
-
for (const block of blocks) {
|
|
224
|
-
const [type, data] = block as [string, Record<string, unknown>];
|
|
225
|
-
if (type === 'heading' && data.text) {
|
|
226
|
-
const text = String(data.text).replace(/<[^>]+>/g, '');
|
|
227
|
-
if (text.trim()) {
|
|
228
|
-
entries.push({
|
|
229
|
-
id: text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''),
|
|
230
|
-
text: text.trim(),
|
|
231
|
-
level: (data.level as number) ?? 2,
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
return entries;
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
const tocActiveId = ref('');
|
|
240
|
-
|
|
241
|
-
function scrollToHeading(id: string): void {
|
|
242
|
-
const el = document.getElementById(id);
|
|
243
|
-
if (el) {
|
|
244
|
-
// CSS scroll-behavior is reduced-motion-gated in base.css, but the JS
|
|
245
|
-
// smooth option ignores that — honour the preference explicitly.
|
|
246
|
-
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
247
|
-
el.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' });
|
|
248
|
-
tocActiveId.value = id;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Scroll-spy: highlight active TOC entry based on which heading is in view
|
|
253
|
-
let observer: IntersectionObserver | null = null;
|
|
254
|
-
|
|
255
|
-
onMounted(() => {
|
|
256
|
-
nextTick(() => {
|
|
257
|
-
setupScrollSpy();
|
|
258
|
-
});
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
onUnmounted(() => {
|
|
262
|
-
observer?.disconnect();
|
|
124
|
+
// Structured views of the project's block content. The parsing lives in the
|
|
125
|
+
// pure, unit-tested helpers in utils/projectBlocks.ts (imported above); the BOM,
|
|
126
|
+
// build-steps, code, files, and TOC tabs all read from these computeds.
|
|
127
|
+
const partsFromBlocks = computed(() => extractParts(props.content?.content));
|
|
128
|
+
const buildStepsFromBlocks = computed(() => extractBuildSteps(props.content?.content));
|
|
129
|
+
const codeBlocks = computed(() => extractCodeBlocks(props.content?.content));
|
|
130
|
+
const downloadFiles = computed(() => extractDownloadFiles(props.content?.content));
|
|
131
|
+
const tocEntries = computed(() => extractTocEntries(props.content?.content));
|
|
132
|
+
|
|
133
|
+
// TOC scroll-spy + smooth scroll, shared with the docs viewer via useScrollSpy.
|
|
134
|
+
// Re-observes when the heading set changes (the inline version never did, so the
|
|
135
|
+
// highlight went stale on content change) and disconnects on unmount.
|
|
136
|
+
const { activeId: tocActiveId, scrollTo: scrollToHeading } = useScrollSpy({
|
|
137
|
+
source: () => tocEntries.value,
|
|
138
|
+
getHeadingElements: () =>
|
|
139
|
+
tocEntries.value
|
|
140
|
+
.map((e) => document.getElementById(e.id))
|
|
141
|
+
.filter((el): el is HTMLElement => !!el),
|
|
142
|
+
rootMargin: '-80px 0px -70% 0px',
|
|
263
143
|
});
|
|
264
144
|
|
|
265
|
-
function setupScrollSpy(): void {
|
|
266
|
-
if (!tocEntries.value.length) return;
|
|
267
|
-
observer?.disconnect();
|
|
268
|
-
|
|
269
|
-
const headingEls = tocEntries.value
|
|
270
|
-
.map((e) => document.getElementById(e.id))
|
|
271
|
-
.filter((el): el is HTMLElement => !!el);
|
|
272
|
-
|
|
273
|
-
if (!headingEls.length) return;
|
|
274
|
-
|
|
275
|
-
observer = new IntersectionObserver(
|
|
276
|
-
(entries) => {
|
|
277
|
-
// Find the topmost visible heading
|
|
278
|
-
for (const entry of entries) {
|
|
279
|
-
if (entry.isIntersecting) {
|
|
280
|
-
tocActiveId.value = entry.target.id;
|
|
281
|
-
break;
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
},
|
|
285
|
-
{ rootMargin: '-80px 0px -70% 0px', threshold: 0 },
|
|
286
|
-
);
|
|
287
|
-
|
|
288
|
-
for (const el of headingEls) {
|
|
289
|
-
observer.observe(el);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
145
|
// Fork
|
|
294
146
|
const forking = ref(false);
|
|
295
147
|
async function handleFork(): Promise<void> {
|
|
@@ -310,6 +162,21 @@ async function handleFork(): Promise<void> {
|
|
|
310
162
|
// I Built This
|
|
311
163
|
const buildMarked = ref(false);
|
|
312
164
|
const localBuildCount = ref(props.content?.buildCount ?? 0);
|
|
165
|
+
|
|
166
|
+
// Hydrate the "I Built This" state on load — without this the button always
|
|
167
|
+
// renders inactive after a reload and a re-click un-marks + decrements.
|
|
168
|
+
async function hydrateBuildState(): Promise<void> {
|
|
169
|
+
if (!props.content?.id && !props.federatedId) return;
|
|
170
|
+
const url = isFederated.value
|
|
171
|
+
? `/api/federation/content/${props.federatedId}/build`
|
|
172
|
+
: `/api/content/${props.content.id}/build`;
|
|
173
|
+
try {
|
|
174
|
+
const res = await $fetch<{ marked: boolean }>(url);
|
|
175
|
+
buildMarked.value = res.marked;
|
|
176
|
+
} catch {
|
|
177
|
+
// logged-out (401) or not-found → leave unmarked
|
|
178
|
+
}
|
|
179
|
+
}
|
|
313
180
|
const buildToggling = ref(false);
|
|
314
181
|
async function handleBuild(): Promise<void> {
|
|
315
182
|
buildToggling.value = true;
|
|
@@ -371,13 +238,12 @@ async function handleBuild(): Promise<void> {
|
|
|
371
238
|
<!-- Author Row -->
|
|
372
239
|
<div class="cpub-author-row">
|
|
373
240
|
<NuxtLink :to="authorUrl" :external="isFederated" :target="isFederated ? '_blank' : undefined" class="cpub-av-link">
|
|
374
|
-
<
|
|
375
|
-
|
|
376
|
-
:
|
|
377
|
-
:
|
|
378
|
-
|
|
241
|
+
<ContentAvatar
|
|
242
|
+
:src="content.author?.avatarUrl"
|
|
243
|
+
:name="content.author?.displayName || content.author?.username || ''"
|
|
244
|
+
:size="36"
|
|
245
|
+
:font-size="12"
|
|
379
246
|
/>
|
|
380
|
-
<div v-else class="cpub-av cpub-av-lg">{{ content.author?.displayName?.slice(0, 2).toUpperCase() || 'CP' }}</div>
|
|
381
247
|
</NuxtLink>
|
|
382
248
|
<div>
|
|
383
249
|
<NuxtLink :to="authorUrl" :external="isFederated" :target="isFederated ? '_blank' : undefined" class="cpub-author-name cpub-author-link">
|
|
@@ -416,13 +282,19 @@ async function handleBuild(): Promise<void> {
|
|
|
416
282
|
|
|
417
283
|
<!-- STICKY TABS -->
|
|
418
284
|
<div class="cpub-tabs-sticky">
|
|
419
|
-
<div class="cpub-tabs-inner">
|
|
285
|
+
<div class="cpub-tabs-inner" role="tablist" aria-label="Project sections">
|
|
420
286
|
<button
|
|
421
|
-
v-for="tab in tabs"
|
|
287
|
+
v-for="(tab, idx) in tabs"
|
|
422
288
|
:key="tab.value"
|
|
289
|
+
:id="`cpub-tab-${tab.value}`"
|
|
423
290
|
class="cpub-tab"
|
|
424
291
|
:class="{ active: activeTab === tab.value }"
|
|
292
|
+
role="tab"
|
|
293
|
+
:aria-selected="activeTab === tab.value"
|
|
294
|
+
aria-controls="cpub-project-tabpanel"
|
|
295
|
+
:tabindex="activeTab === tab.value ? 0 : -1"
|
|
425
296
|
@click="activeTab = tab.value"
|
|
297
|
+
@keydown="onTabKeydown($event, idx)"
|
|
426
298
|
>
|
|
427
299
|
{{ tab.label }}
|
|
428
300
|
<span v-if="tab.count" class="cpub-tab-badge">{{ tab.count }}</span>
|
|
@@ -452,7 +324,13 @@ async function handleBuild(): Promise<void> {
|
|
|
452
324
|
</nav>
|
|
453
325
|
|
|
454
326
|
<!-- CENTER: CONTENT -->
|
|
455
|
-
<div
|
|
327
|
+
<div
|
|
328
|
+
class="cpub-content-col"
|
|
329
|
+
role="tabpanel"
|
|
330
|
+
id="cpub-project-tabpanel"
|
|
331
|
+
:aria-labelledby="`cpub-tab-${activeTab}`"
|
|
332
|
+
tabindex="0"
|
|
333
|
+
>
|
|
456
334
|
<!-- OVERVIEW TAB -->
|
|
457
335
|
<template v-if="activeTab === 'overview'">
|
|
458
336
|
<!-- Cover photo (in-body featured image) -->
|
|
@@ -763,41 +641,7 @@ async function handleBuild(): Promise<void> {
|
|
|
763
641
|
flex-wrap: wrap;
|
|
764
642
|
}
|
|
765
643
|
|
|
766
|
-
/*
|
|
767
|
-
* to the div-variant only — stops img-variant from squishing portrait
|
|
768
|
-
* avatars (object-fit:cover gets dropped on flex-set replaced elements). */
|
|
769
|
-
.cpub-av {
|
|
770
|
-
--cpub-av-size: 28px;
|
|
771
|
-
width: var(--cpub-av-size);
|
|
772
|
-
height: var(--cpub-av-size);
|
|
773
|
-
/* Hard-lock to a square (min/max on both axes) so a portrait photo can't
|
|
774
|
-
render as an oval if a global reset or dropped dimension lets the <img>
|
|
775
|
-
take its intrinsic aspect ratio. See ArticleView.vue. */
|
|
776
|
-
min-width: var(--cpub-av-size);
|
|
777
|
-
max-width: var(--cpub-av-size);
|
|
778
|
-
min-height: var(--cpub-av-size);
|
|
779
|
-
max-height: var(--cpub-av-size);
|
|
780
|
-
border-radius: 50%;
|
|
781
|
-
background: var(--surface3);
|
|
782
|
-
border: var(--border-width-default) solid var(--border);
|
|
783
|
-
flex-shrink: 0;
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
div.cpub-av {
|
|
787
|
-
display: flex;
|
|
788
|
-
align-items: center;
|
|
789
|
-
justify-content: center;
|
|
790
|
-
font-size: 10px;
|
|
791
|
-
font-weight: 700;
|
|
792
|
-
color: var(--text-dim);
|
|
793
|
-
font-family: var(--font-mono);
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
img.cpub-av {
|
|
797
|
-
object-fit: cover;
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
.cpub-av-lg { --cpub-av-size: 36px; font-size: 12px; }
|
|
644
|
+
/* Author avatar lives in <ContentAvatar> (its .cpub-av CSS travels with it). */
|
|
801
645
|
|
|
802
646
|
.cpub-author-name {
|
|
803
647
|
font-size: 13px;
|
|
@@ -814,12 +658,6 @@ img.cpub-av {
|
|
|
814
658
|
.cpub-av-link {
|
|
815
659
|
text-decoration: none;
|
|
816
660
|
}
|
|
817
|
-
.cpub-av-img {
|
|
818
|
-
width: 36px;
|
|
819
|
-
height: 36px;
|
|
820
|
-
object-fit: cover;
|
|
821
|
-
border: var(--border-width-default) solid var(--border);
|
|
822
|
-
}
|
|
823
661
|
|
|
824
662
|
.cpub-author-meta-row {
|
|
825
663
|
display: flex;
|
package/composables/useAuth.ts
CHANGED
|
@@ -23,6 +23,9 @@ export interface ClientAuthSession {
|
|
|
23
23
|
interface AuthResponse {
|
|
24
24
|
user: ClientAuthUser | null;
|
|
25
25
|
session: ClientAuthSession | null;
|
|
26
|
+
/** Effective RBAC permission grants (advisory — server enforces). */
|
|
27
|
+
permissions?: string[];
|
|
28
|
+
roleKeys?: string[];
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
// `$fetch<T>(url, options)` instantiates Nuxt's NitroFetchRequest generic
|
|
@@ -49,6 +52,10 @@ async function authGet(url: string): Promise<AuthResponse | null> {
|
|
|
49
52
|
export function useAuth() {
|
|
50
53
|
const user = useState<ClientAuthUser | null>('auth-user', () => null);
|
|
51
54
|
const session = useState<ClientAuthSession | null>('auth-session', () => null);
|
|
55
|
+
// Effective RBAC permission grants for the current user (advisory, UX-only —
|
|
56
|
+
// the server is always the enforcement boundary). Populated by refreshSession.
|
|
57
|
+
const permissions = useState<string[]>('auth-permissions', () => []);
|
|
58
|
+
const roleKeys = useState<string[]>('auth-role-keys', () => []);
|
|
52
59
|
|
|
53
60
|
const isAuthenticated = computed(() => !!user.value);
|
|
54
61
|
const isAdmin = computed(() => user.value?.role === 'admin');
|
|
@@ -69,6 +76,8 @@ export function useAuth() {
|
|
|
69
76
|
await authPost('/api/auth/sign-out', {});
|
|
70
77
|
user.value = null;
|
|
71
78
|
session.value = null;
|
|
79
|
+
permissions.value = [];
|
|
80
|
+
roleKeys.value = [];
|
|
72
81
|
await navigateTo('/');
|
|
73
82
|
}
|
|
74
83
|
|
|
@@ -84,6 +93,8 @@ export function useAuth() {
|
|
|
84
93
|
// (a logged-out user gets `{ user: null }`), so mirror it exactly.
|
|
85
94
|
user.value = data?.user ?? null;
|
|
86
95
|
session.value = data?.session ?? null;
|
|
96
|
+
permissions.value = data?.permissions ?? [];
|
|
97
|
+
roleKeys.value = data?.roleKeys ?? [];
|
|
87
98
|
} catch {
|
|
88
99
|
// A *thrown* error means we couldn't reach /api/me (network blip, 5xx, a
|
|
89
100
|
// slow/overloaded server timing out). That is NOT evidence the session is
|
|
@@ -96,6 +107,8 @@ export function useAuth() {
|
|
|
96
107
|
return {
|
|
97
108
|
user: readonly(user),
|
|
98
109
|
session: readonly(session),
|
|
110
|
+
permissions: readonly(permissions),
|
|
111
|
+
roleKeys: readonly(roleKeys),
|
|
99
112
|
isAuthenticated,
|
|
100
113
|
isAdmin,
|
|
101
114
|
signIn,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side permission check (UX only — the server enforces via
|
|
3
|
+
* `requirePermission`). Mirrors `hasPermissionPure` (@commonpub/auth): admin
|
|
4
|
+
* floor → `*` → exact match → `<prefix>.*` segment wildcard.
|
|
5
|
+
*
|
|
6
|
+
* const canModerate = useCan('content.moderate')
|
|
7
|
+
* <button v-if="canModerate">…</button>
|
|
8
|
+
*
|
|
9
|
+
* Never gate real access on this alone — always have a server guard behind it.
|
|
10
|
+
*/
|
|
11
|
+
export function useCan(key: string): ComputedRef<boolean> {
|
|
12
|
+
const { isAdmin, permissions } = useAuth();
|
|
13
|
+
return computed(() => {
|
|
14
|
+
// Admin floor — admins pass everything (their resolved set is empty server-side).
|
|
15
|
+
if (isAdmin.value) return true;
|
|
16
|
+
const granted = permissions.value;
|
|
17
|
+
if (!granted.length) return false;
|
|
18
|
+
if (granted.includes('*')) return true;
|
|
19
|
+
if (granted.includes(key)) return true;
|
|
20
|
+
const prefix = key.includes('.') ? key.slice(0, key.indexOf('.')) : null;
|
|
21
|
+
return prefix ? granted.includes(`${prefix}.*`) : false;
|
|
22
|
+
});
|
|
23
|
+
}
|