@commonpub/layer 0.82.0 → 0.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/AppToast.vue +1 -1
- package/components/ContentAvatar.vue +98 -0
- package/components/CpubCriteriaBar.vue +88 -0
- package/components/CpubDateTimeField.vue +73 -0
- package/components/CpubMarkdown.vue +3 -1
- package/components/FormatToggle.vue +2 -2
- package/components/ImageUpload.vue +5 -8
- package/components/MirrorDetailModal.vue +3 -1
- package/components/MirrorRequestApproveModal.vue +3 -1
- package/components/ProductEditModal.vue +184 -0
- package/components/RemoteFollowDialog.vue +2 -2
- package/components/SearchSidebar.vue +14 -21
- package/components/ShareToHubModal.vue +3 -1
- package/components/admin/layouts/AdminLayoutsPalette.vue +5 -1
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +7 -1
- package/components/admin/layouts/AdminLayoutsToolbar.vue +1 -1
- package/components/blocks/BlockCompareColumnsView.vue +92 -0
- package/components/blocks/BlockContentRenderer.vue +17 -0
- package/components/blocks/BlockCriteriaBarView.vue +25 -0
- package/components/blocks/BlockGalleryView.vue +5 -0
- package/components/blocks/BlockHtmlView.vue +26 -0
- package/components/blocks/BlockImageView.vue +4 -0
- package/components/blocks/BlockJudgesShowcaseView.vue +52 -0
- package/components/blocks/BlockRoadmapView.vue +84 -0
- package/components/blocks/BlockSponsorsView.vue +89 -0
- package/components/blocks/BlockTableView.vue +49 -0
- package/components/blocks/BlockTabsView.vue +121 -0
- package/components/contest/ContestBodyCanvas.vue +155 -0
- package/components/contest/ContestCriteriaEditor.vue +79 -0
- package/components/contest/ContestEditor.vue +948 -0
- package/components/contest/ContestEntries.vue +1 -1
- package/components/contest/ContestEntryPrivateData.vue +126 -0
- package/components/contest/ContestHero.vue +114 -186
- package/components/contest/ContestJudgeManager.vue +6 -4
- package/components/contest/ContestJudgingCriteria.vue +5 -21
- package/components/contest/ContestPrizes.vue +8 -1
- package/components/contest/ContestProposalForm.vue +88 -0
- package/components/contest/ContestRules.vue +8 -1
- package/components/contest/ContestSidebar.vue +8 -2
- package/components/contest/ContestStageSubmission.vue +10 -36
- package/components/contest/ContestStagesEditor.vue +141 -65
- package/components/contest/ContestStakeholderManager.vue +3 -2
- package/components/contest/ContestSubmissionField.vue +141 -0
- package/components/contest/blocks/CompareColumnsBlock.vue +127 -0
- package/components/contest/blocks/ContestTabPanel.vue +27 -0
- package/components/contest/blocks/CriteriaBarBlock.vue +118 -0
- package/components/contest/blocks/HtmlBlock.vue +61 -0
- package/components/contest/blocks/JudgesShowcaseBlock.vue +96 -0
- package/components/contest/blocks/RoadmapBlock.vue +127 -0
- package/components/contest/blocks/SponsorsBlock.vue +127 -0
- package/components/contest/blocks/TableBlock.vue +101 -0
- package/components/contest/blocks/TabsBlock.vue +168 -0
- package/components/editors/ArticleEditor.vue +9 -16
- package/components/editors/ExplainerEditor.vue +8 -5
- package/components/editors/ProjectEditor.vue +13 -10
- package/components/homepage/CustomHtmlSection.vue +11 -2
- package/components/hub/HubProducts.vue +4 -2
- package/components/nav/NavDropdown.vue +1 -5
- package/components/nav/NavLink.vue +2 -0
- package/components/views/ArticleView.vue +3 -56
- package/components/views/ExplainerView.vue +4 -0
- package/components/views/ProjectView.vue +83 -245
- package/composables/useContestEditor.ts +388 -0
- package/composables/useDocsPageTree.ts +154 -0
- package/composables/useDocsSiteSettings.ts +107 -0
- package/composables/useEditorAutosave.ts +131 -0
- package/composables/useEngagement.ts +13 -6
- package/composables/useFeatures.ts +9 -1
- package/composables/useFileUpload.ts +60 -0
- package/composables/useProfileContent.ts +84 -0
- package/composables/useSanitize.ts +38 -4
- package/composables/useScrollSpy.ts +87 -0
- package/layouts/admin.vue +41 -19
- package/layouts/default.vue +18 -9
- package/nuxt.config.ts +13 -0
- package/package.json +9 -9
- package/pages/[type]/index.vue +6 -1
- package/pages/admin/api-keys.vue +13 -3
- package/pages/admin/features.vue +2 -0
- package/pages/admin/federation.vue +1 -1
- package/pages/admin/layouts/[id].vue +30 -2
- package/pages/admin/settings.vue +2 -1
- package/pages/admin/users.vue +1 -1
- package/pages/admin/video-categories.vue +203 -0
- package/pages/cert/[code].vue +6 -2
- package/pages/contests/[slug]/edit.vue +4 -769
- package/pages/contests/[slug]/entries/[entryId].vue +34 -1
- package/pages/contests/[slug]/index.vue +93 -7
- package/pages/contests/[slug]/judge.vue +49 -26
- package/pages/contests/create.vue +5 -466
- package/pages/contests/index.vue +7 -2
- package/pages/cookies.vue +1 -1
- package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
- package/pages/docs/[siteSlug]/edit.vue +93 -231
- package/pages/events/[slug]/edit.vue +20 -20
- package/pages/events/create.vue +18 -18
- package/pages/events/index.vue +7 -2
- package/pages/hubs/[slug]/index.vue +34 -9
- package/pages/hubs/[slug]/invites.vue +312 -0
- package/pages/hubs/[slug]/members.vue +128 -0
- package/pages/hubs/[slug]/posts/[postId].vue +2 -2
- package/pages/hubs/index.vue +6 -1
- package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
- package/pages/learn/index.vue +8 -1
- package/pages/messages/index.vue +1 -1
- package/pages/mirror/[id].vue +1 -1
- package/pages/products/[slug].vue +55 -2
- package/pages/products/index.vue +6 -1
- package/pages/settings/account.vue +8 -8
- package/pages/settings/profile.vue +23 -14
- package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
- package/pages/u/[username]/followers.vue +11 -3
- package/pages/u/[username]/following.vue +10 -8
- package/pages/u/[username]/index.vue +73 -7
- package/pages/videos/index.vue +13 -10
- package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
- package/server/api/admin/api-keys/[id].delete.ts +2 -2
- package/server/api/admin/api-keys/index.get.ts +1 -0
- package/server/api/admin/api-keys/index.post.ts +1 -0
- package/server/api/admin/federation/refederate.post.ts +18 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
- package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
- package/server/api/admin/layouts/[id].delete.ts +1 -4
- package/server/api/admin/layouts/[id].get.ts +1 -4
- package/server/api/admin/layouts/[id].put.ts +1 -4
- package/server/api/auth/federated/login.post.ts +12 -5
- package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
- package/server/api/content/[id]/build.get.ts +11 -0
- package/server/api/content/[id]/report.post.ts +2 -0
- package/server/api/content/[id]/versions.get.ts +15 -0
- package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
- package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
- package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
- package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
- package/server/api/contests/[slug]/export.get.ts +43 -0
- package/server/api/contests/[slug]/judge.post.ts +8 -2
- package/server/api/contests/[slug]/proposal.post.ts +36 -0
- package/server/api/contests/[slug]/user-search.get.ts +30 -0
- package/server/api/contests/index.post.ts +1 -1
- package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
- package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
- package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
- package/server/api/docs/[siteSlug]/search.get.ts +7 -1
- package/server/api/events/[slug]/attendees.get.ts +10 -0
- package/server/api/events/[slug].get.ts +9 -0
- package/server/api/events/index.get.ts +8 -1
- package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
- package/server/api/federation/content/[id]/build.get.ts +10 -0
- package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
- package/server/api/hubs/[slug]/invites.get.ts +5 -3
- package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
- package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
- package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
- package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
- package/server/api/hubs/[slug]/requests.get.ts +20 -0
- package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
- package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
- package/server/api/products/[id].delete.ts +22 -2
- package/server/api/registry/ping.post.ts +17 -3
- package/server/api/search/index.get.ts +5 -3
- package/server/api/social/bookmark.get.ts +1 -0
- package/server/api/social/bookmark.post.ts +1 -0
- package/server/api/social/bookmarks.get.ts +1 -0
- package/server/api/social/comments/[id].delete.ts +1 -0
- package/server/api/social/comments.get.ts +1 -0
- package/server/api/social/comments.post.ts +1 -0
- package/server/api/social/like.get.ts +1 -0
- package/server/api/social/like.post.ts +1 -0
- package/server/api/users/[username]/content.get.ts +15 -3
- package/server/api/users/[username]/follow.delete.ts +1 -0
- package/server/api/users/[username]/follow.post.ts +1 -0
- package/server/api/users/[username]/followers.get.ts +2 -1
- package/server/api/users/[username]/following.get.ts +2 -1
- package/server/middleware/content-ap.ts +8 -3
- package/server/middleware/csrf.ts +93 -0
- package/server/plugins/federation-hub-sync.ts +48 -17
- package/server/plugins/notification-email.ts +22 -3
- package/server/routes/hubs/[slug]/inbox.ts +13 -1
- package/server/routes/inbox.ts +14 -1
- package/server/routes/users/[username]/inbox.ts +13 -1
- package/server/utils/inbox.ts +7 -2
- package/server/utils/validate.ts +22 -0
- package/theme/base.css +5 -0
- package/theme/prose.css +20 -0
- package/theme/stoa-dark.css +4 -0
- package/types/contestBlocks.ts +122 -0
- package/utils/contestBlocks.ts +107 -0
- package/utils/contestBody.ts +25 -0
- package/utils/contestStages.ts +62 -0
- package/utils/contestSubmission.ts +97 -0
- package/utils/datetime.ts +45 -0
- package/utils/projectBlocks.ts +162 -0
- package/components/editors/BlogEditor.vue +0 -648
|
@@ -14,6 +14,9 @@ function updateMeta(key: string, value: unknown): void {
|
|
|
14
14
|
emit('update:metadata', { ...props.metadata, [key]: value });
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
const { uploadFile } = useFileUpload();
|
|
18
|
+
const toast = useToast();
|
|
19
|
+
|
|
17
20
|
const blockTypes: BlockTypeGroup[] = [
|
|
18
21
|
{
|
|
19
22
|
name: 'Basic',
|
|
@@ -75,12 +78,12 @@ function onCoverUpload(event: Event): void {
|
|
|
75
78
|
if (!input.files?.length) return;
|
|
76
79
|
const file = input.files[0];
|
|
77
80
|
if (!file) return;
|
|
78
|
-
|
|
79
|
-
formData.append('file', file);
|
|
80
|
-
formData.append('purpose', 'cover');
|
|
81
|
-
$fetch<{ url: string }>('/api/files/upload', { method: 'POST', body: formData })
|
|
81
|
+
uploadFile(file, 'cover')
|
|
82
82
|
.then((res) => { updateMeta('coverImageUrl', res.url); })
|
|
83
|
-
.catch(() => {
|
|
83
|
+
.catch((err: unknown) => {
|
|
84
|
+
const msg = (err as { data?: { statusMessage?: string } })?.data?.statusMessage;
|
|
85
|
+
toast.error(msg || 'Cover image upload failed');
|
|
86
|
+
});
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
function onCoverUrl(): void {
|
|
@@ -100,12 +103,12 @@ function onBannerUpload(event: Event): void {
|
|
|
100
103
|
if (!input.files?.length) return;
|
|
101
104
|
const file = input.files[0];
|
|
102
105
|
if (!file) return;
|
|
103
|
-
|
|
104
|
-
formData.append('file', file);
|
|
105
|
-
formData.append('purpose', 'cover');
|
|
106
|
-
$fetch<{ url: string }>('/api/files/upload', { method: 'POST', body: formData })
|
|
106
|
+
uploadFile(file, 'cover')
|
|
107
107
|
.then((res) => { updateMeta('bannerUrl', res.url); })
|
|
108
|
-
.catch(() => {
|
|
108
|
+
.catch((err: unknown) => {
|
|
109
|
+
const msg = (err as { data?: { statusMessage?: string } })?.data?.statusMessage;
|
|
110
|
+
toast.error(msg || 'Banner image upload failed');
|
|
111
|
+
});
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
function removeBanner(): void {
|
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
2
3
|
import type { HomepageSectionConfig } from '@commonpub/server';
|
|
4
|
+
import { sanitizeRichHtml } from '../../composables/useSanitize';
|
|
3
5
|
|
|
4
6
|
const props = defineProps<{
|
|
5
7
|
config: HomepageSectionConfig;
|
|
6
8
|
title?: string;
|
|
7
9
|
}>();
|
|
10
|
+
|
|
11
|
+
// Admin-authored raw HTML renders on the PUBLIC homepage with v-html; strip
|
|
12
|
+
// scripts/event-handlers/javascript: before injecting (CSP allows unsafe-inline,
|
|
13
|
+
// so this is the only XSS barrier). (audit session 204 — P1)
|
|
14
|
+
const safeHtml = computed(() =>
|
|
15
|
+
typeof props.config.html === 'string' ? sanitizeRichHtml(props.config.html) : '',
|
|
16
|
+
);
|
|
8
17
|
</script>
|
|
9
18
|
|
|
10
19
|
<template>
|
|
11
|
-
<section v-if="
|
|
20
|
+
<section v-if="safeHtml" class="cpub-custom-section">
|
|
12
21
|
<h2 v-if="title" class="cpub-custom-title">{{ title }}</h2>
|
|
13
|
-
<div class="cpub-custom-content" v-html="
|
|
22
|
+
<div class="cpub-custom-content" v-html="safeHtml" />
|
|
14
23
|
</section>
|
|
15
24
|
</template>
|
|
16
25
|
|
|
@@ -6,6 +6,7 @@ const props = defineProps<{
|
|
|
6
6
|
}>();
|
|
7
7
|
|
|
8
8
|
const emit = defineEmits<{ 'product-created': [] }>();
|
|
9
|
+
const toast = useToast();
|
|
9
10
|
|
|
10
11
|
const canManage = computed(() => ['owner', 'admin', 'moderator'].includes(props.currentUserRole ?? ''));
|
|
11
12
|
const showForm = ref(false);
|
|
@@ -29,8 +30,9 @@ async function handleCreate(): Promise<void> {
|
|
|
29
30
|
formPurchaseUrl.value = '';
|
|
30
31
|
showForm.value = false;
|
|
31
32
|
emit('product-created');
|
|
32
|
-
} catch {
|
|
33
|
-
|
|
33
|
+
} catch {
|
|
34
|
+
toast.error('Failed to create product');
|
|
35
|
+
} finally { creating.value = false; }
|
|
34
36
|
}
|
|
35
37
|
</script>
|
|
36
38
|
|
|
@@ -47,7 +47,6 @@ function handleKeydown(e: KeyboardEvent): void {
|
|
|
47
47
|
class="cpub-nav-link cpub-nav-trigger"
|
|
48
48
|
:class="{ 'cpub-nav-trigger--open': open }"
|
|
49
49
|
:aria-label="`${item.label} menu`"
|
|
50
|
-
aria-haspopup="true"
|
|
51
50
|
:aria-expanded="open"
|
|
52
51
|
@click.stop="emit('toggle')"
|
|
53
52
|
@keydown.enter.stop="emit('toggle')"
|
|
@@ -56,12 +55,11 @@ function handleKeydown(e: KeyboardEvent): void {
|
|
|
56
55
|
<i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
|
|
57
56
|
<i class="fa-solid fa-chevron-down cpub-nav-caret" />
|
|
58
57
|
</button>
|
|
59
|
-
<div v-if="open" class="cpub-nav-panel"
|
|
58
|
+
<div v-if="open" class="cpub-nav-panel">
|
|
60
59
|
<template v-for="child in visibleChildren" :key="child.id">
|
|
61
60
|
<span
|
|
62
61
|
v-if="child.disabled"
|
|
63
62
|
class="cpub-nav-panel-item cpub-nav-panel-item--disabled"
|
|
64
|
-
role="menuitem"
|
|
65
63
|
aria-disabled="true"
|
|
66
64
|
>
|
|
67
65
|
<i v-if="child.icon" :class="child.icon"></i> {{ child.label }}
|
|
@@ -72,7 +70,6 @@ function handleKeydown(e: KeyboardEvent): void {
|
|
|
72
70
|
target="_blank"
|
|
73
71
|
rel="noopener"
|
|
74
72
|
class="cpub-nav-panel-item"
|
|
75
|
-
role="menuitem"
|
|
76
73
|
@click="emit('close')"
|
|
77
74
|
>
|
|
78
75
|
<i v-if="child.icon" :class="child.icon"></i> {{ child.label }}
|
|
@@ -82,7 +79,6 @@ function handleKeydown(e: KeyboardEvent): void {
|
|
|
82
79
|
v-else-if="child.route"
|
|
83
80
|
:to="child.route"
|
|
84
81
|
class="cpub-nav-panel-item"
|
|
85
|
-
role="menuitem"
|
|
86
82
|
@click="emit('close')"
|
|
87
83
|
>
|
|
88
84
|
<i v-if="child.icon" :class="child.icon"></i> {{ child.label }}
|
|
@@ -26,6 +26,8 @@ const isExternal = computed(() => props.item.type === 'external' && props.item.h
|
|
|
26
26
|
v-else-if="item.route"
|
|
27
27
|
:to="item.route"
|
|
28
28
|
class="cpub-nav-link"
|
|
29
|
+
:active-class="item.route === '/' ? '' : undefined"
|
|
30
|
+
:exact-active-class="item.route === '/' ? 'router-link-active' : undefined"
|
|
29
31
|
>
|
|
30
32
|
<i v-if="item.icon" :class="item.icon"></i> {{ item.label }}
|
|
31
33
|
</NuxtLink>
|
|
@@ -117,8 +117,7 @@ useJsonLd({
|
|
|
117
117
|
<!-- AUTHOR ROW -->
|
|
118
118
|
<div class="cpub-author-row">
|
|
119
119
|
<NuxtLink v-if="content.author" :to="authorUrl" :external="isFederated" :target="isFederated ? '_blank' : undefined" style="text-decoration:none;">
|
|
120
|
-
<
|
|
121
|
-
<div v-else class="cpub-av cpub-av-lg">{{ content.author?.displayName?.slice(0, 2).toUpperCase() || 'CP' }}</div>
|
|
120
|
+
<ContentAvatar :src="content.author?.avatarUrl" :name="content.author?.displayName ?? content.author?.username ?? ''" :size="44" :font-size="14" />
|
|
122
121
|
</NuxtLink>
|
|
123
122
|
<div class="cpub-author-info">
|
|
124
123
|
<NuxtLink v-if="content.author" :to="authorUrl" :external="isFederated" :target="isFederated ? '_blank' : undefined" class="cpub-author-name">
|
|
@@ -239,8 +238,7 @@ useJsonLd({
|
|
|
239
238
|
|
|
240
239
|
<!-- AUTHOR CARD -->
|
|
241
240
|
<div v-if="content.author" class="cpub-author-card">
|
|
242
|
-
<
|
|
243
|
-
<div v-else class="cpub-av cpub-av-xl">{{ content.author.displayName?.slice(0, 2).toUpperCase() || 'CP' }}</div>
|
|
241
|
+
<ContentAvatar :src="content.author.avatarUrl" :name="content.author.displayName ?? content.author.username ?? ''" :size="64" :font-size="18" />
|
|
244
242
|
<div class="cpub-author-card-info">
|
|
245
243
|
<div class="cpub-author-card-label">Written by</div>
|
|
246
244
|
<div class="cpub-author-card-name">
|
|
@@ -419,58 +417,7 @@ useJsonLd({
|
|
|
419
417
|
font-weight: 400;
|
|
420
418
|
}
|
|
421
419
|
|
|
422
|
-
/*
|
|
423
|
-
* Two render modes share the .cpub-av class:
|
|
424
|
-
* <img class="cpub-av cpub-av-lg" ...> ← avatar photo
|
|
425
|
-
* <div class="cpub-av cpub-av-lg">JD</div> ← initials fallback when no avatar
|
|
426
|
-
*
|
|
427
|
-
* Sizing + border-radius is shared. But `display: flex` MUST NOT apply to
|
|
428
|
-
* the <img> — when a replaced element gets `display: flex` set, browsers
|
|
429
|
-
* (notably Chromium) treat the img content render-box inconsistently and
|
|
430
|
-
* the inline `object-fit: cover` is silently dropped, producing a squished
|
|
431
|
-
* (stretched-to-box) image instead of a center-cropped one. Visible on
|
|
432
|
-
* deveco.io blog pages where author avatars are vertical photos (e.g.
|
|
433
|
-
* 816×1456) rendered into a 44×44 square.
|
|
434
|
-
*
|
|
435
|
-
* Fix: scope display:flex centering to the div variant only.
|
|
436
|
-
*/
|
|
437
|
-
.cpub-av {
|
|
438
|
-
--cpub-av-size: 28px;
|
|
439
|
-
width: var(--cpub-av-size);
|
|
440
|
-
height: var(--cpub-av-size);
|
|
441
|
-
/* Hard-lock to a square. Without min/max clamps, a global img reset or a
|
|
442
|
-
dropped dimension lets the <img> fall back to its intrinsic aspect ratio,
|
|
443
|
-
so a portrait photo renders as a tall oval (the deveco blog-avatar bug -
|
|
444
|
-
visible even on wide viewports, so it's not flex compression). min/max on
|
|
445
|
-
BOTH axes clamp the used size regardless of what sets width/height. */
|
|
446
|
-
min-width: var(--cpub-av-size);
|
|
447
|
-
max-width: var(--cpub-av-size);
|
|
448
|
-
min-height: var(--cpub-av-size);
|
|
449
|
-
max-height: var(--cpub-av-size);
|
|
450
|
-
border-radius: 50%;
|
|
451
|
-
background: var(--surface3);
|
|
452
|
-
border: var(--border-width-default) solid var(--border);
|
|
453
|
-
flex-shrink: 0;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
div.cpub-av {
|
|
457
|
-
display: flex;
|
|
458
|
-
align-items: center;
|
|
459
|
-
justify-content: center;
|
|
460
|
-
font-size: 10px;
|
|
461
|
-
font-weight: 600;
|
|
462
|
-
color: var(--text-dim);
|
|
463
|
-
font-family: var(--font-mono);
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/* Defensive: even when consumers forget the inline `object-fit:cover`,
|
|
467
|
-
img.cpub-av crops instead of stretching. */
|
|
468
|
-
img.cpub-av {
|
|
469
|
-
object-fit: cover;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
.cpub-av-lg { --cpub-av-size: 44px; font-size: 14px; }
|
|
473
|
-
.cpub-av-xl { --cpub-av-size: 64px; font-size: 18px; }
|
|
420
|
+
/* Author avatar lives in <ContentAvatar> (its .cpub-av CSS travels with it). */
|
|
474
421
|
|
|
475
422
|
/* ── AUTHOR ROW ── */
|
|
476
423
|
.cpub-author-row {
|
|
@@ -345,6 +345,10 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
|
|
|
345
345
|
</div>
|
|
346
346
|
</div>
|
|
347
347
|
</div>
|
|
348
|
+
|
|
349
|
+
<!-- Discussion — explainers are a federating content type like projects/blogs,
|
|
350
|
+
so readers can comment (parity with ProjectView/ArticleView). -->
|
|
351
|
+
<CommentSection :target-type="content.type" :target-id="content.id" :federated-content-id="federatedId" />
|
|
348
352
|
</main>
|
|
349
353
|
</div>
|
|
350
354
|
</div>
|
|
@@ -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;
|