@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
|
@@ -1,471 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
useSeoMeta({ title: `Create Contest, ${useSiteName()}` });
|
|
7
|
-
|
|
8
|
-
const toast = useToast();
|
|
9
|
-
const { extract: extractError } = useApiError();
|
|
10
|
-
const saving = ref(false);
|
|
11
|
-
|
|
12
|
-
const title = ref('');
|
|
13
|
-
// Slug auto-derives from the title until the operator edits it manually.
|
|
14
|
-
const slug = ref('');
|
|
15
|
-
const slugTouched = ref(false);
|
|
16
|
-
function slugify(s: string): string {
|
|
17
|
-
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/(^-+)|(-+$)/g, '').slice(0, 255);
|
|
18
|
-
}
|
|
19
|
-
watch(title, (t) => { if (!slugTouched.value) slug.value = slugify(t); });
|
|
20
|
-
const subheading = ref('');
|
|
21
|
-
const description = ref('');
|
|
22
|
-
const rules = ref('');
|
|
23
|
-
const descriptionFormat = ref<'markdown' | 'html'>('markdown');
|
|
24
|
-
const rulesFormat = ref<'markdown' | 'html'>('markdown');
|
|
25
|
-
const prizesDescriptionFormat = ref<'markdown' | 'html'>('markdown');
|
|
26
|
-
const bannerUrl = ref('');
|
|
27
|
-
const coverImageUrl = ref('');
|
|
28
|
-
const startDate = ref('');
|
|
29
|
-
const endDate = ref('');
|
|
30
|
-
const judgingEndDate = ref('');
|
|
31
|
-
const communityVotingEnabled = ref(false);
|
|
32
|
-
const judgingVisibility = ref<'public' | 'judges-only' | 'private'>('judges-only');
|
|
33
|
-
|
|
34
|
-
// Visibility & access
|
|
35
|
-
const visibility = ref<'public' | 'unlisted' | 'private'>('public');
|
|
36
|
-
const visibleToRoles = ref<string[]>([]);
|
|
37
|
-
const ROLE_OPTIONS = ['member', 'pro', 'verified', 'staff', 'admin'];
|
|
38
|
-
function toggleRole(r: string): void {
|
|
39
|
-
const i = visibleToRoles.value.indexOf(r);
|
|
40
|
-
if (i >= 0) visibleToRoles.value.splice(i, 1);
|
|
41
|
-
else visibleToRoles.value.push(r);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Entry rules
|
|
45
|
-
const { enabledTypeMeta } = useContentTypes();
|
|
46
|
-
const eligibleContentTypes = ref<string[]>([]); // empty = all types allowed
|
|
47
|
-
const maxEntriesPerUser = ref<number | null>(null);
|
|
48
|
-
function toggleType(type: string): void {
|
|
49
|
-
const i = eligibleContentTypes.value.indexOf(type);
|
|
50
|
-
if (i >= 0) eligibleContentTypes.value.splice(i, 1);
|
|
51
|
-
else eligibleContentTypes.value.push(type);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Prizes
|
|
55
|
-
interface Prize {
|
|
56
|
-
place: number | null;
|
|
57
|
-
category: string;
|
|
58
|
-
title: string;
|
|
59
|
-
description: string;
|
|
60
|
-
value: string;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const showPrizes = ref(true);
|
|
64
|
-
const prizesDescription = ref('');
|
|
65
|
-
|
|
66
|
-
// Phase B1 — explicit stage timeline (empty ⇒ standard synthesized flow).
|
|
67
|
-
const stages = ref<ContestStage[]>([]);
|
|
68
|
-
const currentStageIdRef = ref<string | null>(null);
|
|
69
|
-
// Prizes are entirely optional — start empty so a contest has NO prizes unless
|
|
70
|
-
// the operator explicitly adds them (the old 3 pre-filled rows forced prizes
|
|
71
|
-
// onto every contest, since their non-empty titles survived the submit filter).
|
|
72
|
-
const prizes = ref<Prize[]>([]);
|
|
73
|
-
|
|
74
|
-
function addPrize(): void {
|
|
75
|
-
prizes.value.push({ place: null, category: '', title: '', description: '', value: '' });
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function removePrize(index: number): void {
|
|
79
|
-
prizes.value.splice(index, 1);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Judging criteria (rubric)
|
|
83
|
-
interface Criterion { label: string; weight: number | null; description: string }
|
|
84
|
-
const criteria = ref<Criterion[]>([]);
|
|
85
|
-
function addCriterion(): void {
|
|
86
|
-
criteria.value.push({ label: '', weight: null, description: '' });
|
|
87
|
-
}
|
|
88
|
-
function removeCriterion(index: number): void {
|
|
89
|
-
criteria.value.splice(index, 1);
|
|
90
|
-
}
|
|
91
|
-
const criteriaTotal = computed(() => criteria.value.reduce((s, c) => s + (c.weight ?? 0), 0));
|
|
92
|
-
|
|
93
|
-
const dateError = computed(() => {
|
|
94
|
-
if (startDate.value && endDate.value && new Date(endDate.value) <= new Date(startDate.value)) {
|
|
95
|
-
return 'End date must be after the start date.';
|
|
96
|
-
}
|
|
97
|
-
if (judgingEndDate.value && endDate.value && new Date(judgingEndDate.value) < new Date(endDate.value)) {
|
|
98
|
-
return 'Judging end date must be on or after the end date.';
|
|
99
|
-
}
|
|
100
|
-
return '';
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
async function handleCreate(): Promise<void> {
|
|
104
|
-
if (!title.value.trim() || !startDate.value || !endDate.value) return;
|
|
105
|
-
if (dateError.value) { toast.error(dateError.value); return; }
|
|
106
|
-
saving.value = true;
|
|
107
|
-
try {
|
|
108
|
-
const result = await $fetch<{ slug: string }>('/api/contests', {
|
|
109
|
-
method: 'POST',
|
|
110
|
-
body: {
|
|
111
|
-
title: title.value,
|
|
112
|
-
slug: slugify(slug.value) || undefined,
|
|
113
|
-
subheading: subheading.value || undefined,
|
|
114
|
-
description: description.value || undefined,
|
|
115
|
-
rules: rules.value || undefined,
|
|
116
|
-
descriptionFormat: descriptionFormat.value,
|
|
117
|
-
rulesFormat: rulesFormat.value,
|
|
118
|
-
prizesDescriptionFormat: prizesDescriptionFormat.value,
|
|
119
|
-
bannerUrl: bannerUrl.value || undefined,
|
|
120
|
-
coverImageUrl: coverImageUrl.value || undefined,
|
|
121
|
-
startDate: new Date(startDate.value).toISOString(),
|
|
122
|
-
endDate: new Date(endDate.value).toISOString(),
|
|
123
|
-
judgingEndDate: judgingEndDate.value ? new Date(judgingEndDate.value).toISOString() : undefined,
|
|
124
|
-
communityVotingEnabled: communityVotingEnabled.value,
|
|
125
|
-
judgingVisibility: judgingVisibility.value,
|
|
126
|
-
visibility: visibility.value,
|
|
127
|
-
visibleToRoles: visibility.value === 'private' && visibleToRoles.value.length ? visibleToRoles.value : undefined,
|
|
128
|
-
eligibleContentTypes: eligibleContentTypes.value.length ? eligibleContentTypes.value : undefined,
|
|
129
|
-
maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
|
|
130
|
-
showPrizes: showPrizes.value,
|
|
131
|
-
stages: stages.value.length ? stages.value : undefined,
|
|
132
|
-
currentStageId: currentStageIdRef.value ?? undefined,
|
|
133
|
-
prizesDescription: prizesDescription.value || undefined,
|
|
134
|
-
prizes: prizes.value
|
|
135
|
-
.filter(p => p.title.trim() || p.description.trim() || p.category.trim() || (typeof p.place === 'number' && p.place > 0))
|
|
136
|
-
.map(p => ({
|
|
137
|
-
place: typeof p.place === 'number' && Number.isFinite(p.place) && p.place > 0 ? p.place : undefined,
|
|
138
|
-
category: p.category.trim() || undefined,
|
|
139
|
-
title: p.title.trim() || undefined,
|
|
140
|
-
description: p.description.trim() || undefined,
|
|
141
|
-
value: p.value.trim() || undefined,
|
|
142
|
-
})),
|
|
143
|
-
judgingCriteria: criteria.value
|
|
144
|
-
.filter(c => c.label.trim())
|
|
145
|
-
.map(c => ({
|
|
146
|
-
label: c.label.trim(),
|
|
147
|
-
weight: typeof c.weight === 'number' && Number.isFinite(c.weight) ? c.weight : undefined,
|
|
148
|
-
description: c.description.trim() || undefined,
|
|
149
|
-
})),
|
|
150
|
-
},
|
|
151
|
-
});
|
|
152
|
-
toast.success('Contest created!');
|
|
153
|
-
await navigateTo(`/contests/${result.slug}`);
|
|
154
|
-
} catch (err: unknown) {
|
|
155
|
-
toast.error(extractError(err));
|
|
156
|
-
} finally {
|
|
157
|
-
saving.value = false;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function prizeLabel(prize: Prize): string {
|
|
162
|
-
if (prize.category.trim()) return prize.category;
|
|
163
|
-
if (prize.place && prize.place > 0) {
|
|
164
|
-
const labels = ['1st', '2nd', '3rd', '4th', '5th', '6th'];
|
|
165
|
-
return `${labels[prize.place - 1] || `${prize.place}th`} Place`;
|
|
166
|
-
}
|
|
167
|
-
return 'Prize';
|
|
168
|
-
}
|
|
2
|
+
// Thin route shell — the editor lives in the shared ContestEditor component
|
|
3
|
+
// (mode-aware), so creating a contest is identical to editing one (block body,
|
|
4
|
+
// canvas tabs, the same settings). SEO + auth are handled inside.
|
|
5
|
+
definePageMeta({ middleware: 'auth', layout: false });
|
|
169
6
|
</script>
|
|
170
7
|
|
|
171
8
|
<template>
|
|
172
|
-
<
|
|
173
|
-
<NuxtLink to="/contests" class="cpub-back-link"><i class="fa-solid fa-arrow-left"></i> Contests</NuxtLink>
|
|
174
|
-
<h1 class="cpub-page-title">Create Contest</h1>
|
|
175
|
-
|
|
176
|
-
<form class="cpub-contest-form" @submit.prevent="handleCreate" aria-label="Create contest">
|
|
177
|
-
<!-- Basic Info -->
|
|
178
|
-
<section class="cpub-form-section">
|
|
179
|
-
<h2 class="cpub-form-section-title">Contest Details</h2>
|
|
180
|
-
<div class="cpub-form-field">
|
|
181
|
-
<label for="contest-title" class="cpub-form-label">Title</label>
|
|
182
|
-
<input id="contest-title" v-model="title" type="text" class="cpub-form-input" required placeholder="Maker Challenge 2026" />
|
|
183
|
-
</div>
|
|
184
|
-
<div class="cpub-form-field">
|
|
185
|
-
<label for="contest-slug" class="cpub-form-label">URL Slug</label>
|
|
186
|
-
<input id="contest-slug" v-model="slug" type="text" class="cpub-form-input" placeholder="auto-generated from title" @input="slugTouched = true" @blur="slug = slugify(slug)" />
|
|
187
|
-
<p class="cpub-form-hint">Auto-fills from the title. Edit to set a custom URL: <code>/contests/{{ slugify(slug) || 'your-contest' }}</code></p>
|
|
188
|
-
</div>
|
|
189
|
-
<div class="cpub-form-field">
|
|
190
|
-
<label for="contest-subheading" class="cpub-form-label">Subheading</label>
|
|
191
|
-
<input id="contest-subheading" v-model="subheading" type="text" maxlength="300" class="cpub-form-input" placeholder="One-line tagline shown in the contest header" />
|
|
192
|
-
<p class="cpub-form-hint">Short plain-text tagline shown under the title in the hero. The Description below is the full body.</p>
|
|
193
|
-
</div>
|
|
194
|
-
<div class="cpub-form-field">
|
|
195
|
-
<div class="cpub-field-head">
|
|
196
|
-
<label for="contest-desc" class="cpub-form-label">Description</label>
|
|
197
|
-
<FormatToggle v-model="descriptionFormat" />
|
|
198
|
-
</div>
|
|
199
|
-
<textarea id="contest-desc" v-model="description" class="cpub-form-textarea" rows="4" maxlength="50000" placeholder="Describe your contest. Supports Markdown, # headings, - lists, **bold**, [links](url)…" />
|
|
200
|
-
<p class="cpub-form-hint">{{ descriptionFormat === 'html' ? 'Rendered as your raw HTML, CSS, and SVG (scripts removed for safety).' : 'Supports Markdown (headings, lists, bold, links) and inline HTML.' }} Shown formatted on the contest page.</p>
|
|
201
|
-
</div>
|
|
202
|
-
<div class="cpub-form-field">
|
|
203
|
-
<div class="cpub-field-head">
|
|
204
|
-
<label for="contest-rules" class="cpub-form-label">Rules</label>
|
|
205
|
-
<FormatToggle v-model="rulesFormat" />
|
|
206
|
-
</div>
|
|
207
|
-
<textarea id="contest-rules" v-model="rules" class="cpub-form-textarea" rows="6" maxlength="50000" placeholder="Contest rules and requirements. Supports Markdown, one rule per line, or full Markdown." />
|
|
208
|
-
<p class="cpub-form-hint">{{ rulesFormat === 'html' ? 'Rendered as your raw HTML, CSS, and SVG (scripts removed for safety).' : 'Supports Markdown. Plain one-rule-per-line text renders as a list.' }}</p>
|
|
209
|
-
</div>
|
|
210
|
-
<div class="cpub-form-field">
|
|
211
|
-
<ImageUpload v-model="bannerUrl" purpose="banner" label="Banner Image" hint="Wide hero image across the top of the contest page (~4:1)." />
|
|
212
|
-
</div>
|
|
213
|
-
<div class="cpub-form-field">
|
|
214
|
-
<ImageUpload v-model="coverImageUrl" purpose="cover" label="Cover Image (optional)" hint="Card/thumbnail image shown in listings (~4:3). Falls back to the banner if unset." />
|
|
215
|
-
</div>
|
|
216
|
-
</section>
|
|
217
|
-
|
|
218
|
-
<!-- Dates -->
|
|
219
|
-
<section class="cpub-form-section">
|
|
220
|
-
<h2 class="cpub-form-section-title">Schedule</h2>
|
|
221
|
-
<div class="cpub-form-row">
|
|
222
|
-
<div class="cpub-form-field">
|
|
223
|
-
<label for="start-date" class="cpub-form-label">Start Date</label>
|
|
224
|
-
<input id="start-date" v-model="startDate" type="datetime-local" class="cpub-form-input" required />
|
|
225
|
-
</div>
|
|
226
|
-
<div class="cpub-form-field">
|
|
227
|
-
<label for="end-date" class="cpub-form-label">End Date</label>
|
|
228
|
-
<input id="end-date" v-model="endDate" type="datetime-local" class="cpub-form-input" required />
|
|
229
|
-
</div>
|
|
230
|
-
<div class="cpub-form-field">
|
|
231
|
-
<label for="judging-date" class="cpub-form-label">Judging Ends</label>
|
|
232
|
-
<input id="judging-date" v-model="judgingEndDate" type="datetime-local" class="cpub-form-input" />
|
|
233
|
-
</div>
|
|
234
|
-
</div>
|
|
235
|
-
<p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
|
|
236
|
-
</section>
|
|
237
|
-
|
|
238
|
-
<!-- Stages -->
|
|
239
|
-
<section class="cpub-form-section">
|
|
240
|
-
<h2 class="cpub-form-section-title">Stages <span style="color: var(--text-faint); font-weight: 400; font-size: 0.75em; font-family: var(--font-mono);">- optional</span></h2>
|
|
241
|
-
<p class="cpub-form-hint">The standard flow (Submissions → Judging → Results) is derived from the schedule above. Add custom stages for multi-round contests, proposal rounds, a Top-N selection, a build sprint, multiple judging rounds, or a showcase event.</p>
|
|
242
|
-
<ContestStagesEditor
|
|
243
|
-
v-model="stages"
|
|
244
|
-
v-model:current-stage-id="currentStageIdRef"
|
|
245
|
-
:start-date="startDate"
|
|
246
|
-
:end-date="endDate"
|
|
247
|
-
:judging-end-date="judgingEndDate"
|
|
248
|
-
/>
|
|
249
|
-
</section>
|
|
250
|
-
|
|
251
|
-
<!-- Visibility & Access -->
|
|
252
|
-
<section class="cpub-form-section">
|
|
253
|
-
<h2 class="cpub-form-section-title">Visibility & Access</h2>
|
|
254
|
-
<div class="cpub-form-field">
|
|
255
|
-
<label for="visibility" class="cpub-form-label">Who can see this contest</label>
|
|
256
|
-
<select id="visibility" v-model="visibility" class="cpub-form-input">
|
|
257
|
-
<option value="public">Public, listed and visible to everyone</option>
|
|
258
|
-
<option value="unlisted">Unlisted, visible by direct link, hidden from listings</option>
|
|
259
|
-
<option value="private">Private, restricted (you can publish it later)</option>
|
|
260
|
-
</select>
|
|
261
|
-
</div>
|
|
262
|
-
<div v-if="visibility === 'private'" class="cpub-form-field">
|
|
263
|
-
<span class="cpub-form-label">Also visible to roles</span>
|
|
264
|
-
<p class="cpub-form-hint">Owner, admins, judges, and reviewers (added after creation) can always see it. Optionally grant whole roles too.</p>
|
|
265
|
-
<div class="cpub-type-options" role="group" aria-label="Roles that can view">
|
|
266
|
-
<label v-for="r in ROLE_OPTIONS" :key="r" class="cpub-form-check">
|
|
267
|
-
<input type="checkbox" :checked="visibleToRoles.includes(r)" @change="toggleRole(r)" />
|
|
268
|
-
<span>{{ r }}</span>
|
|
269
|
-
</label>
|
|
270
|
-
</div>
|
|
271
|
-
</div>
|
|
272
|
-
<p v-if="visibility === 'private'" class="cpub-form-hint">Add named reviewers (stakeholders) from the contest's Edit page after creating it.</p>
|
|
273
|
-
</section>
|
|
274
|
-
|
|
275
|
-
<!-- Entry Rules -->
|
|
276
|
-
<section class="cpub-form-section">
|
|
277
|
-
<h2 class="cpub-form-section-title">Entries</h2>
|
|
278
|
-
<div class="cpub-form-field">
|
|
279
|
-
<span class="cpub-form-label">Eligible content types</span>
|
|
280
|
-
<p class="cpub-form-hint">Leave all unchecked to accept any published content the entrant owns.</p>
|
|
281
|
-
<div class="cpub-type-options" role="group" aria-label="Eligible content types">
|
|
282
|
-
<label v-for="t in enabledTypeMeta" :key="t.type" class="cpub-form-check">
|
|
283
|
-
<input type="checkbox" :checked="eligibleContentTypes.includes(t.type)" @change="toggleType(t.type)" />
|
|
284
|
-
<span>{{ t.label }}</span>
|
|
285
|
-
</label>
|
|
286
|
-
</div>
|
|
287
|
-
</div>
|
|
288
|
-
<div class="cpub-form-field">
|
|
289
|
-
<label for="max-entries" class="cpub-form-label">Max entries per person</label>
|
|
290
|
-
<input id="max-entries" v-model.number="maxEntriesPerUser" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" style="max-width: 160px;" />
|
|
291
|
-
</div>
|
|
292
|
-
</section>
|
|
293
|
-
|
|
294
|
-
<!-- Judging -->
|
|
295
|
-
<section class="cpub-form-section">
|
|
296
|
-
<h2 class="cpub-form-section-title">Judging</h2>
|
|
297
|
-
<div class="cpub-form-field">
|
|
298
|
-
<label for="judging-visibility" class="cpub-form-label">Score Visibility</label>
|
|
299
|
-
<select id="judging-visibility" v-model="judgingVisibility" class="cpub-form-input">
|
|
300
|
-
<option value="judges-only">Judges only, scores hidden until results</option>
|
|
301
|
-
<option value="public">Public, show scores during judging</option>
|
|
302
|
-
<option value="private">Private, scores stay with organizers</option>
|
|
303
|
-
</select>
|
|
304
|
-
</div>
|
|
305
|
-
<label class="cpub-form-check">
|
|
306
|
-
<input v-model="communityVotingEnabled" type="checkbox" />
|
|
307
|
-
<span>Enable community voting (let signed-in members upvote entries)</span>
|
|
308
|
-
</label>
|
|
309
|
-
|
|
310
|
-
<div class="cpub-form-section-header" style="margin-top: 16px;">
|
|
311
|
-
<h3 class="cpub-form-subtitle">Judging Criteria <span v-if="criteriaTotal" class="cpub-form-hint-inline">{{ criteriaTotal }} pts</span></h3>
|
|
312
|
-
<button type="button" class="cpub-btn cpub-btn-sm" @click="addCriterion"><i class="fa-solid fa-plus"></i> Add Criterion</button>
|
|
313
|
-
</div>
|
|
314
|
-
<p v-if="!criteria.length" class="cpub-form-hint">Optional rubric shown to entrants and judges (e.g. Documentation, 20 pts).</p>
|
|
315
|
-
<div v-for="(crit, ci) in criteria" :key="ci" class="cpub-criterion-row">
|
|
316
|
-
<div class="cpub-form-row">
|
|
317
|
-
<div class="cpub-form-field" style="flex: 3">
|
|
318
|
-
<label class="cpub-form-label">Criterion</label>
|
|
319
|
-
<input v-model="crit.label" type="text" class="cpub-form-input" placeholder="e.g. Documentation" />
|
|
320
|
-
</div>
|
|
321
|
-
<div class="cpub-form-field" style="flex: 1">
|
|
322
|
-
<label class="cpub-form-label">Points</label>
|
|
323
|
-
<input v-model.number="crit.weight" type="number" min="0" max="100" class="cpub-form-input" placeholder="20" />
|
|
324
|
-
</div>
|
|
325
|
-
<button type="button" class="cpub-delete-btn cpub-criterion-del" aria-label="Remove criterion" @click="removeCriterion(ci)"><i class="fa-solid fa-xmark"></i></button>
|
|
326
|
-
</div>
|
|
327
|
-
<div class="cpub-form-field">
|
|
328
|
-
<input v-model="crit.description" type="text" class="cpub-form-input" placeholder="What judges look for (optional)" />
|
|
329
|
-
</div>
|
|
330
|
-
</div>
|
|
331
|
-
</section>
|
|
332
|
-
|
|
333
|
-
<!-- Prizes -->
|
|
334
|
-
<section class="cpub-form-section">
|
|
335
|
-
<div class="cpub-form-section-header">
|
|
336
|
-
<h2 class="cpub-form-section-title">Prizes <span style="color: var(--text-faint); font-weight: 400; font-size: 0.75em; font-family: var(--font-mono);">- optional</span></h2>
|
|
337
|
-
<button type="button" class="cpub-btn cpub-btn-sm" @click="addPrize">
|
|
338
|
-
<i class="fa-solid fa-plus"></i> Add Prize
|
|
339
|
-
</button>
|
|
340
|
-
</div>
|
|
341
|
-
|
|
342
|
-
<label class="cpub-form-check">
|
|
343
|
-
<input v-model="showPrizes" type="checkbox" />
|
|
344
|
-
<span>Show the Prizes tab on the contest page</span>
|
|
345
|
-
</label>
|
|
346
|
-
<p v-if="!showPrizes" class="cpub-form-hint">The Prizes tab is hidden, any prizes below are saved but not shown to visitors.</p>
|
|
347
|
-
|
|
348
|
-
<p class="cpub-form-hint">Contests don't need prizes, leave this empty to skip them entirely. If you do add prizes, every field is optional: use <strong>place</strong> for ranked prizes (1st/2nd/3rd), a <strong>category</strong> for themed awards (e.g. "Best in Show"), or just a <strong>description</strong>. Cash value is optional.</p>
|
|
349
|
-
<div class="cpub-form-field">
|
|
350
|
-
<div class="cpub-field-head">
|
|
351
|
-
<label for="prizes-desc" class="cpub-form-label">Prizes overview (optional)</label>
|
|
352
|
-
<FormatToggle v-model="prizesDescriptionFormat" />
|
|
353
|
-
</div>
|
|
354
|
-
<textarea id="prizes-desc" v-model="prizesDescription" class="cpub-form-textarea" rows="3" maxlength="50000" placeholder="Intro shown above the prize cards." />
|
|
355
|
-
<p class="cpub-form-hint">{{ prizesDescriptionFormat === 'html' ? 'Rendered as your raw HTML, CSS, and SVG (scripts removed for safety).' : 'Markdown intro' }} displayed on the Prizes tab, above the individual prizes.</p>
|
|
356
|
-
</div>
|
|
357
|
-
<div v-for="(prize, idx) in prizes" :key="idx" class="cpub-prize-card">
|
|
358
|
-
<div class="cpub-prize-header">
|
|
359
|
-
<span class="cpub-prize-place">
|
|
360
|
-
<i class="fa-solid fa-trophy"></i> {{ prizeLabel(prize) }}
|
|
361
|
-
</span>
|
|
362
|
-
<button type="button" class="cpub-delete-btn" aria-label="Remove prize" @click="removePrize(idx)">
|
|
363
|
-
<i class="fa-solid fa-xmark"></i>
|
|
364
|
-
</button>
|
|
365
|
-
</div>
|
|
366
|
-
<div class="cpub-form-row">
|
|
367
|
-
<div class="cpub-form-field" style="flex: 1">
|
|
368
|
-
<label class="cpub-form-label">Place</label>
|
|
369
|
-
<input v-model.number="prize.place" type="number" min="1" class="cpub-form-input" placeholder="1" />
|
|
370
|
-
</div>
|
|
371
|
-
<div class="cpub-form-field" style="flex: 2">
|
|
372
|
-
<label class="cpub-form-label">Category (optional)</label>
|
|
373
|
-
<input v-model="prize.category" type="text" class="cpub-form-input" placeholder="e.g. Best in Show" />
|
|
374
|
-
</div>
|
|
375
|
-
</div>
|
|
376
|
-
<div class="cpub-form-row">
|
|
377
|
-
<div class="cpub-form-field" style="flex: 2">
|
|
378
|
-
<label class="cpub-form-label">Title</label>
|
|
379
|
-
<input v-model="prize.title" type="text" class="cpub-form-input" placeholder="Prize title" />
|
|
380
|
-
</div>
|
|
381
|
-
<div class="cpub-form-field" style="flex: 1">
|
|
382
|
-
<label class="cpub-form-label">Value</label>
|
|
383
|
-
<input v-model="prize.value" type="text" class="cpub-form-input" placeholder="$500" />
|
|
384
|
-
</div>
|
|
385
|
-
</div>
|
|
386
|
-
<div class="cpub-form-field">
|
|
387
|
-
<label class="cpub-form-label">Description</label>
|
|
388
|
-
<input v-model="prize.description" type="text" class="cpub-form-input" placeholder="What the winner receives..." />
|
|
389
|
-
</div>
|
|
390
|
-
</div>
|
|
391
|
-
</section>
|
|
392
|
-
|
|
393
|
-
<div class="cpub-edit-actionbar">
|
|
394
|
-
<span class="cpub-edit-actionbar-hint">Required: title, start & end dates.</span>
|
|
395
|
-
<div class="cpub-edit-actionbar-btns">
|
|
396
|
-
<NuxtLink to="/contests" class="cpub-btn">Cancel</NuxtLink>
|
|
397
|
-
<button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !title.trim() || !startDate || !endDate || !!dateError">
|
|
398
|
-
<i class="fa-solid fa-trophy"></i> {{ saving ? 'Creating…' : 'Create Contest' }}
|
|
399
|
-
</button>
|
|
400
|
-
</div>
|
|
401
|
-
</div>
|
|
402
|
-
</form>
|
|
403
|
-
</div>
|
|
9
|
+
<ContestEditor mode="create" />
|
|
404
10
|
</template>
|
|
405
|
-
|
|
406
|
-
<style scoped>
|
|
407
|
-
.cpub-contest-create { max-width: 720px; margin: 0 auto; padding: 32px; }
|
|
408
|
-
|
|
409
|
-
.cpub-back-link { font-size: 11px; font-family: var(--font-mono); color: var(--text-faint); text-decoration: none; display: inline-flex; align-items: center; gap: 6px; margin-bottom: 16px; }
|
|
410
|
-
.cpub-back-link:hover { color: var(--accent); }
|
|
411
|
-
|
|
412
|
-
.cpub-page-title { font-size: 24px; font-weight: 700; margin-bottom: 24px; letter-spacing: -0.02em; }
|
|
413
|
-
|
|
414
|
-
.cpub-contest-form { display: flex; flex-direction: column; gap: 20px; }
|
|
415
|
-
|
|
416
|
-
.cpub-form-section { border: var(--border-width-default) solid var(--border); background: var(--surface); padding: 20px; box-shadow: var(--shadow-md); }
|
|
417
|
-
.cpub-form-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
|
418
|
-
.cpub-form-section-title { font-size: 14px; font-weight: 700; margin-bottom: 16px; }
|
|
419
|
-
.cpub-form-section-header .cpub-form-section-title { margin-bottom: 0; }
|
|
420
|
-
|
|
421
|
-
.cpub-form-field { display: flex; flex-direction: column; gap: var(--space-1); margin-bottom: var(--space-3); }
|
|
422
|
-
.cpub-field-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
423
|
-
.cpub-form-field:last-child { margin-bottom: 0; }
|
|
424
|
-
.cpub-form-input, .cpub-form-textarea { width: 100%; padding: var(--space-2) var(--space-3); border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: var(--text-sm); font-family: var(--font-sans); }
|
|
425
|
-
.cpub-form-input:focus, .cpub-form-textarea:focus { border-color: var(--accent); outline: none; box-shadow: var(--shadow-accent); }
|
|
426
|
-
.cpub-form-textarea { resize: vertical; }
|
|
427
|
-
.cpub-form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-3); }
|
|
428
|
-
|
|
429
|
-
.cpub-prize-card { border: var(--border-width-default) solid var(--border); padding: 14px; margin-bottom: 10px; background: var(--surface2); }
|
|
430
|
-
.cpub-prize-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
|
431
|
-
.cpub-prize-place { font-size: 12px; font-weight: 700; font-family: var(--font-mono); display: flex; align-items: center; gap: 6px; color: var(--accent); }
|
|
432
|
-
|
|
433
|
-
.cpub-form-error { font-size: 12px; color: var(--red); margin-top: 8px; }
|
|
434
|
-
.cpub-form-check { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); cursor: pointer; margin-top: 4px; }
|
|
435
|
-
.cpub-form-check input { width: 14px; height: 14px; }
|
|
436
|
-
.cpub-type-options { display: flex; gap: 16px; flex-wrap: wrap; margin-top: 6px; }
|
|
437
|
-
.cpub-form-subtitle { font-size: 12px; font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-dim); display: flex; align-items: center; gap: 8px; }
|
|
438
|
-
.cpub-form-hint-inline { font-size: 10px; color: var(--accent); }
|
|
439
|
-
.cpub-form-hint { font-size: 11px; color: var(--text-faint); margin: 8px 0; line-height: 1.5; }
|
|
440
|
-
.cpub-criterion-row { border: var(--border-width-default) solid var(--border); padding: 12px; margin-bottom: 8px; background: var(--surface2); }
|
|
441
|
-
.cpub-criterion-row .cpub-form-row { align-items: flex-end; }
|
|
442
|
-
.cpub-criterion-del { align-self: flex-end; margin-bottom: 12px; }
|
|
443
|
-
.cpub-delete-btn { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 14px; }
|
|
444
|
-
.cpub-delete-btn:hover { color: var(--red); }
|
|
445
|
-
|
|
446
|
-
/* Sticky create bar — Create button always reachable on the long form. */
|
|
447
|
-
.cpub-edit-actionbar {
|
|
448
|
-
position: sticky;
|
|
449
|
-
bottom: 0;
|
|
450
|
-
z-index: 20;
|
|
451
|
-
display: flex;
|
|
452
|
-
align-items: center;
|
|
453
|
-
justify-content: space-between;
|
|
454
|
-
gap: 12px;
|
|
455
|
-
margin: 4px -32px -32px;
|
|
456
|
-
padding: 14px 32px;
|
|
457
|
-
background: var(--surface);
|
|
458
|
-
border-top: 2px solid var(--border);
|
|
459
|
-
box-shadow: var(--shadow-lg);
|
|
460
|
-
}
|
|
461
|
-
.cpub-edit-actionbar-hint { font-size: 11px; color: var(--text-faint); }
|
|
462
|
-
.cpub-edit-actionbar-btns { display: flex; align-items: center; gap: 8px; }
|
|
463
|
-
|
|
464
|
-
@media (max-width: 768px) {
|
|
465
|
-
.cpub-contest-create { padding: 16px; }
|
|
466
|
-
.cpub-page-title { font-size: 20px; }
|
|
467
|
-
.cpub-form-section { padding: 14px; }
|
|
468
|
-
.cpub-form-row { grid-template-columns: 1fr; }
|
|
469
|
-
.cpub-edit-actionbar { margin: 4px -16px -16px; padding: 12px 16px; }
|
|
470
|
-
}
|
|
471
|
-
</style>
|
package/pages/contests/index.vue
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
useSeoMeta({ title: `Contests, ${useSiteName()}` });
|
|
3
3
|
|
|
4
|
-
const { data: contests } = await useFetch('/api/contests');
|
|
4
|
+
const { data: contests, error, refresh } = await useFetch('/api/contests');
|
|
5
5
|
const { isAuthenticated, isAdmin, user } = useAuth();
|
|
6
6
|
|
|
7
7
|
// Card blurb: prefer the short subheading; otherwise a plain-text, markdown-
|
|
@@ -44,7 +44,12 @@ const canCreateContest = computed(() => {
|
|
|
44
44
|
<i class="fa-solid fa-plus"></i> Create Contest
|
|
45
45
|
</NuxtLink>
|
|
46
46
|
</div>
|
|
47
|
-
<div v-if="
|
|
47
|
+
<div v-if="error" class="cpub-fetch-error">
|
|
48
|
+
<div class="cpub-fetch-error-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
|
|
49
|
+
<div class="cpub-fetch-error-msg">Failed to load contests.</div>
|
|
50
|
+
<button class="cpub-btn cpub-btn-sm" @click="refresh()">Retry</button>
|
|
51
|
+
</div>
|
|
52
|
+
<div v-else-if="contests?.items?.length" class="cpub-grid-3">
|
|
48
53
|
<NuxtLink
|
|
49
54
|
v-for="contest in contests.items"
|
|
50
55
|
:key="contest.id"
|
package/pages/cookies.vue
CHANGED
|
@@ -15,7 +15,7 @@ const analyticsCookies = computed(() => cookies.value.filter((c) => c.category =
|
|
|
15
15
|
<div class="cpub-legal">
|
|
16
16
|
<div class="cpub-legal-header">
|
|
17
17
|
<h1 class="cpub-legal-title">Cookie Policy</h1>
|
|
18
|
-
<p class="cpub-legal-updated">Last updated:
|
|
18
|
+
<p class="cpub-legal-updated">Last updated: June 2026</p>
|
|
19
19
|
</div>
|
|
20
20
|
|
|
21
21
|
<div class="cpub-legal-body">
|
|
@@ -107,32 +107,19 @@ watch(pagePath, () => {
|
|
|
107
107
|
|
|
108
108
|
// TOC from rendered page
|
|
109
109
|
const toc = computed<TocEntry[]>(() => renderedPage.value?.toc ?? []);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
},
|
|
125
|
-
{ rootMargin: '-80px 0px -60% 0px', threshold: 0 },
|
|
126
|
-
);
|
|
127
|
-
headings.forEach(h => observer.observe(h));
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
onMounted(() => {
|
|
131
|
-
nextTick(setupScrollSpy);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
watch(renderedPage, () => {
|
|
135
|
-
nextTick(setupScrollSpy);
|
|
110
|
+
// TOC scroll-spy, shared with ProjectView via useScrollSpy. Re-observes when the
|
|
111
|
+
// rendered page changes and disconnects the previous observer each time (the
|
|
112
|
+
// inline version leaked one observer per navigation). TOC links are native
|
|
113
|
+
// anchors, so only the active-id highlight is wired here.
|
|
114
|
+
const { activeId: activeHeadingId } = useScrollSpy({
|
|
115
|
+
source: () => renderedPage.value,
|
|
116
|
+
getHeadingElements: () =>
|
|
117
|
+
Array.from(
|
|
118
|
+
document.querySelectorAll<HTMLElement>(
|
|
119
|
+
'.docs-content h2[id], .docs-content h3[id], .docs-content .cpub-block-heading[id]',
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
rootMargin: '-80px 0px -60% 0px',
|
|
136
123
|
});
|
|
137
124
|
|
|
138
125
|
// Prev/Next links
|