@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
|
@@ -14,6 +14,8 @@ 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
|
+
|
|
17
19
|
const blockTypes: BlockTypeGroup[] = [
|
|
18
20
|
{
|
|
19
21
|
name: 'Text',
|
|
@@ -130,10 +132,7 @@ function onAssetUpload(event: Event): void {
|
|
|
130
132
|
}
|
|
131
133
|
|
|
132
134
|
uploading.value = true;
|
|
133
|
-
|
|
134
|
-
formData.append('file', file);
|
|
135
|
-
formData.append('purpose', 'content');
|
|
136
|
-
$fetch<{ url: string; originalName: string; sizeBytes: number; mimeType: string }>('/api/files/upload', { method: 'POST', body: formData })
|
|
135
|
+
uploadFile<{ url: string; originalName: string; sizeBytes: number; mimeType: string }>(file, 'content')
|
|
137
136
|
.then((res) => {
|
|
138
137
|
uploadedFiles.value.unshift({
|
|
139
138
|
name: res.originalName || file.name,
|
|
@@ -181,12 +180,9 @@ function onCoverUpload(event: Event): void {
|
|
|
181
180
|
if (!input.files?.length) return;
|
|
182
181
|
const file = input.files[0];
|
|
183
182
|
if (!file) return;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
$fetch<{ url: string }>('/api/files/upload', { method: 'POST', body: formData })
|
|
188
|
-
.then((res) => { updateMeta('coverImageUrl', res.url); })
|
|
189
|
-
.catch(() => { /* silent fallback */ });
|
|
183
|
+
uploadFile(file, 'cover')
|
|
184
|
+
.then((res) => { updateMeta('coverImageUrl', res.url); uploadError.value = ''; })
|
|
185
|
+
.catch((err) => { uploadError.value = err?.data?.statusMessage || 'Cover image upload failed'; });
|
|
190
186
|
}
|
|
191
187
|
|
|
192
188
|
function onCoverUrl(): void {
|
|
@@ -206,12 +202,9 @@ function onBannerUpload(event: Event): void {
|
|
|
206
202
|
if (!input.files?.length) return;
|
|
207
203
|
const file = input.files[0];
|
|
208
204
|
if (!file) return;
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
$fetch<{ url: string }>('/api/files/upload', { method: 'POST', body: formData })
|
|
213
|
-
.then((res) => { updateMeta('bannerUrl', res.url); })
|
|
214
|
-
.catch(() => {});
|
|
205
|
+
uploadFile(file, 'cover')
|
|
206
|
+
.then((res) => { updateMeta('bannerUrl', res.url); uploadError.value = ''; })
|
|
207
|
+
.catch((err) => { uploadError.value = err?.data?.statusMessage || 'Banner image upload failed'; });
|
|
215
208
|
}
|
|
216
209
|
|
|
217
210
|
function removeBanner(): void {
|
|
@@ -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 activeLeftTab = ref<'modules' | 'structure' | 'assets'>('modules');
|
|
18
21
|
|
|
19
22
|
// Interactive blocks FIRST — they're the core of an explainer
|
|
@@ -118,10 +121,7 @@ function onAssetUpload(event: Event): void {
|
|
|
118
121
|
if (!input.files?.length) return;
|
|
119
122
|
const file = input.files[0];
|
|
120
123
|
if (!file) return;
|
|
121
|
-
|
|
122
|
-
formData.append('file', file);
|
|
123
|
-
formData.append('purpose', 'content');
|
|
124
|
-
$fetch<{ url: string; originalName: string; size: number }>('/api/files/upload', { method: 'POST', body: formData })
|
|
124
|
+
uploadFile<{ url: string; originalName: string; size: number }>(file, 'content')
|
|
125
125
|
.then((res) => {
|
|
126
126
|
uploadedFiles.value.unshift({
|
|
127
127
|
name: res.originalName || file.name,
|
|
@@ -129,7 +129,10 @@ function onAssetUpload(event: Event): void {
|
|
|
129
129
|
type: file.type.startsWith('image/') ? 'image' : 'file',
|
|
130
130
|
});
|
|
131
131
|
})
|
|
132
|
-
.catch(() => {
|
|
132
|
+
.catch((err: unknown) => {
|
|
133
|
+
const msg = (err as { data?: { statusMessage?: string } })?.data?.statusMessage;
|
|
134
|
+
toast.error(msg || 'Upload failed');
|
|
135
|
+
});
|
|
133
136
|
}
|
|
134
137
|
|
|
135
138
|
const openSections = ref<Record<string, boolean>>({
|
|
@@ -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>
|