@commonpub/layer 0.56.0 → 0.58.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/LayoutRow.vue +8 -8
- package/components/LayoutSection.vue +8 -8
- package/components/LayoutSlot.vue +3 -3
- package/components/MirrorDetailModal.vue +3 -3
- package/components/MirrorRequestApproveModal.vue +3 -3
- package/components/PollDisplay.vue +1 -1
- package/components/RegistryDirectory.vue +2 -2
- package/components/admin/layouts/AdminLayoutsAutoForm.vue +1 -1
- package/components/admin/layouts/AdminLayoutsCanvas.vue +2 -2
- package/components/admin/layouts/AdminLayoutsConflictModal.vue +1 -1
- package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +1 -1
- package/components/admin/layouts/AdminLayoutsInspectorPage.vue +1 -1
- package/components/admin/layouts/AdminLayoutsToolbar.vue +5 -5
- package/components/admin/theme/AdminThemeSceneGallery.vue +3 -3
- package/components/admin/theme/AdminThemeSceneProse.vue +3 -3
- package/components/admin/theme/AdminThemeTokenInput.vue +1 -1
- package/components/blocks/BlockCodeView.vue +2 -2
- package/components/blocks/BlockDividerView.vue +1 -1
- package/components/blocks/BlockPartsListView.vue +1 -1
- package/components/blocks/BlockQuizView.vue +1 -1
- package/components/blocks/BlockQuoteView.vue +1 -1
- package/components/contest/ContestHero.vue +2 -2
- package/components/contest/ContestStagesEditor.vue +13 -4
- package/components/editors/ArticleEditor.vue +1 -1
- package/components/editors/ExplainerEditor.vue +1 -1
- package/components/sections/SectionLearning.vue +1 -1
- package/components/views/ArticleView.vue +2 -2
- package/components/views/ProjectView.vue +3 -3
- package/composables/useAdminSidebar.ts +3 -3
- package/composables/useLayoutEditor.ts +1 -1
- package/composables/useLayoutHotkeys.ts +1 -1
- package/composables/useLayoutResize.ts +1 -1
- package/composables/usePublishValidation.ts +1 -1
- package/composables/useThemeAdmin.ts +2 -2
- package/error.vue +1 -1
- package/layouts/admin.vue +2 -2
- package/layouts/default.vue +2 -2
- package/package.json +7 -7
- package/pages/[type]/index.vue +1 -1
- package/pages/about.vue +3 -3
- package/pages/admin/api-keys.vue +5 -5
- package/pages/admin/audit.vue +2 -2
- package/pages/admin/categories.vue +1 -1
- package/pages/admin/content.vue +2 -2
- package/pages/admin/features.vue +1 -1
- package/pages/admin/federation.vue +9 -9
- package/pages/admin/homepage.vue +4 -4
- package/pages/admin/index.vue +1 -1
- package/pages/admin/layouts/[id].vue +18 -18
- package/pages/admin/layouts/index.vue +4 -4
- package/pages/admin/navigation.vue +1 -1
- package/pages/admin/reports.vue +1 -1
- package/pages/admin/settings.vue +2 -2
- package/pages/admin/theme/edit/[id].vue +2 -2
- package/pages/admin/theme/index.vue +5 -5
- package/pages/admin/users.vue +1 -1
- package/pages/auth/forgot-password.vue +1 -1
- package/pages/auth/login.vue +3 -3
- package/pages/auth/register.vue +1 -1
- package/pages/auth/reset-password.vue +1 -1
- package/pages/auth/verify-email.vue +1 -1
- package/pages/cert/[code].vue +1 -1
- package/pages/contests/[slug]/edit.vue +20 -14
- package/pages/contests/[slug]/index.vue +7 -7
- package/pages/contests/[slug]/judge.vue +15 -3
- package/pages/contests/[slug]/results.vue +5 -5
- package/pages/contests/create.vue +15 -15
- package/pages/contests/index.vue +2 -2
- package/pages/cookies.vue +1 -1
- package/pages/create.vue +2 -2
- package/pages/dashboard.vue +1 -1
- package/pages/docs/[siteSlug]/[...pagePath].vue +1 -1
- package/pages/docs/[siteSlug]/edit.vue +1 -1
- package/pages/docs/[siteSlug]/index.vue +1 -1
- package/pages/docs/create.vue +1 -1
- package/pages/docs/index.vue +1 -1
- package/pages/events/[slug]/edit.vue +1 -1
- package/pages/events/[slug]/index.vue +2 -2
- package/pages/events/create.vue +1 -1
- package/pages/events/index.vue +1 -1
- package/pages/explore.vue +1 -1
- package/pages/federated-hubs/[id]/index.vue +3 -3
- package/pages/federated-hubs/[id]/posts/[postId].vue +1 -1
- package/pages/federation/search.vue +1 -1
- package/pages/feed.vue +1 -1
- package/pages/hubs/[slug]/members.vue +1 -1
- package/pages/hubs/[slug]/posts/[postId].vue +1 -1
- package/pages/hubs/[slug]/settings.vue +5 -5
- package/pages/hubs/create.vue +6 -6
- package/pages/hubs/index.vue +1 -1
- package/pages/index.vue +2 -2
- package/pages/learn/[slug]/[lessonSlug]/edit.vue +1 -1
- package/pages/learn/[slug]/[lessonSlug]/index.vue +4 -4
- package/pages/learn/[slug]/edit.vue +1 -1
- package/pages/learn/[slug]/index.vue +1 -1
- package/pages/learn/create.vue +1 -1
- package/pages/learn/index.vue +2 -2
- package/pages/messages/[conversationId].vue +1 -1
- package/pages/messages/index.vue +1 -1
- package/pages/notifications.vue +1 -1
- package/pages/privacy.vue +5 -5
- package/pages/products/[slug].vue +1 -1
- package/pages/products/index.vue +1 -1
- package/pages/search.vue +1 -1
- package/pages/settings/profile.vue +1 -1
- package/pages/settings.vue +1 -1
- package/pages/tags/[slug].vue +1 -1
- package/pages/tags/index.vue +1 -1
- package/pages/terms.vue +1 -1
- package/pages/u/[username]/[type]/[slug]/edit.vue +3 -3
- package/pages/u/[username]/[type]/[slug]/index.vue +1 -1
- package/pages/u/[username]/followers.vue +1 -1
- package/pages/u/[username]/following.vue +1 -1
- package/pages/u/[username]/index.vue +3 -3
- package/pages/videos/[id].vue +1 -1
- package/pages/videos/index.vue +1 -1
- package/pages/videos/submit.vue +2 -2
- package/sections/builtin/hero.ts +1 -1
- package/sections/builtin/markdown.ts +1 -1
- package/server/api/admin/homepage/sections.put.ts +1 -1
- package/server/api/admin/layouts/[id].put.ts +1 -1
- package/server/api/contests/[slug]/entries.post.ts +3 -3
- package/server/api/hubs/[slug]/feed.xml.get.ts +1 -1
- package/server/api/users/[username]/feed.xml.get.ts +1 -1
- package/server/middleware/content-redirect.ts +1 -1
- package/server/plugins/federation-delivery.ts +1 -1
- package/server/plugins/federation-hub-sync.ts +1 -1
- package/server/plugins/registry-heartbeat.ts +3 -3
- package/server/plugins/search-index.ts +1 -1
- package/server/utils/email.ts +3 -3
- package/server/utils/instanceTheme.ts +1 -1
- package/utils/contestStages.ts +3 -3
|
@@ -30,7 +30,7 @@ import { onMounted, ref, computed, watch } from 'vue';
|
|
|
30
30
|
// detectAppliedOverrides ← utils/themeDiscovery.ts
|
|
31
31
|
|
|
32
32
|
definePageMeta({ layout: 'admin', middleware: 'auth' });
|
|
33
|
-
useSeoMeta({ title: `Theme
|
|
33
|
+
useSeoMeta({ title: `Theme, Admin, ${useSiteName()}` });
|
|
34
34
|
|
|
35
35
|
const themesApi = useThemeAdmin();
|
|
36
36
|
const router = useRouter();
|
|
@@ -121,7 +121,7 @@ async function duplicateTheme(themeId: string): Promise<void> {
|
|
|
121
121
|
const detected = detectAppliedOverrides();
|
|
122
122
|
seed = {
|
|
123
123
|
id: nextAvailableId(themeId.replace(/^cpub-custom-/, '') + '-fork'),
|
|
124
|
-
name: `Custom
|
|
124
|
+
name: `Custom, based on ${themeId}`,
|
|
125
125
|
description: '',
|
|
126
126
|
family: `custom-${themeId.replace(/^cpub-custom-/, '')}`,
|
|
127
127
|
isDark: detected.isDark,
|
|
@@ -144,7 +144,7 @@ async function removeTheme(themeId: string): Promise<void> {
|
|
|
144
144
|
method: 'DELETE',
|
|
145
145
|
});
|
|
146
146
|
await Promise.all([themesApi.refresh(), refreshSettings()]);
|
|
147
|
-
notify(res.resetDefault ? 'Theme deleted
|
|
147
|
+
notify(res.resetDefault ? 'Theme deleted, default reset to Classic' : 'Theme deleted', 'success');
|
|
148
148
|
} catch (err) {
|
|
149
149
|
notify(err instanceof Error ? err.message : 'Failed to delete', 'error');
|
|
150
150
|
} finally {
|
|
@@ -177,7 +177,7 @@ function captureCurrent(): void {
|
|
|
177
177
|
const seed = {
|
|
178
178
|
id: nextAvailableId(`captured-${new Date().toISOString().slice(0, 10)}`),
|
|
179
179
|
name: 'Captured current site theme',
|
|
180
|
-
description: `Auto-captured from the live :root on ${new Date().toLocaleDateString()}
|
|
180
|
+
description: `Auto-captured from the live :root on ${new Date().toLocaleDateString()}, ${detected.count} tokens.`,
|
|
181
181
|
family: 'captured',
|
|
182
182
|
isDark: detected.isDark,
|
|
183
183
|
parentTheme: detected.isDark ? 'dark' : 'base',
|
|
@@ -362,7 +362,7 @@ async function saveOverrides(overrides: Record<string, string>): Promise<void> {
|
|
|
362
362
|
<h2 class="admin-theme-discovery-title">Your site has a custom theme</h2>
|
|
363
363
|
<p class="admin-theme-discovery-desc">
|
|
364
364
|
We detected <strong>{{ discovery.count }}</strong> CSS token{{ discovery.count === 1 ? '' : 's' }}
|
|
365
|
-
on <code>:root</code> that differ from the built-in defaults
|
|
365
|
+
on <code>:root</code> that differ from the built-in defaults, probably from
|
|
366
366
|
a CSS file your layer app loads. Capture it into an editable custom theme so
|
|
367
367
|
you can tweak it from this admin panel.
|
|
368
368
|
</p>
|
package/pages/admin/users.vue
CHANGED
package/pages/auth/login.vue
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
definePageMeta({ layout: 'auth' });
|
|
3
3
|
|
|
4
4
|
useSeoMeta({
|
|
5
|
-
title: `Log in
|
|
5
|
+
title: `Log in, ${useSiteName()}`,
|
|
6
6
|
description: 'Log in to your CommonPub account.',
|
|
7
7
|
});
|
|
8
8
|
|
|
@@ -241,7 +241,7 @@ function handleMastodonLogin(): void {
|
|
|
241
241
|
</div>
|
|
242
242
|
|
|
243
243
|
<!--
|
|
244
|
-
Mastodon-API login section (Phase 2b)
|
|
244
|
+
Mastodon-API login section (Phase 2b), gated by features.identity.signInWithRemote.
|
|
245
245
|
Works with any Mastodon-API-compatible host: Mastodon, Pleroma, Akkoma, GoToSocial,
|
|
246
246
|
Firefish, and other CommonPub instances. On submit, parses the input to extract a
|
|
247
247
|
host and navigates to /api/auth/mastodon/start, which registers our OAuth client at
|
|
@@ -257,7 +257,7 @@ function handleMastodonLogin(): void {
|
|
|
257
257
|
|
|
258
258
|
<label for="mastodon-handle" class="field-label">
|
|
259
259
|
Sign in with Mastodon
|
|
260
|
-
<span class="field-label-note"
|
|
260
|
+
<span class="field-label-note">- or Pleroma, GoToSocial, Akkoma, Firefish</span>
|
|
261
261
|
</label>
|
|
262
262
|
<div class="cpub-federated-input-group">
|
|
263
263
|
<input
|
package/pages/auth/register.vue
CHANGED
package/pages/cert/[code].vue
CHANGED
|
@@ -5,7 +5,7 @@ const code = route.params.code as string;
|
|
|
5
5
|
const { data: certData } = useLazyFetch(`/api/cert/${code}`);
|
|
6
6
|
|
|
7
7
|
useSeoMeta({
|
|
8
|
-
title: () => certData.value ? `Certificate
|
|
8
|
+
title: () => certData.value ? `Certificate, ${certData.value.path.title}, ${useSiteName()}` : `Certificate, ${useSiteName()}`,
|
|
9
9
|
description: () => certData.value ? `Certificate of completion for ${certData.value.path.title}` : '',
|
|
10
10
|
});
|
|
11
11
|
</script>
|
|
@@ -11,7 +11,7 @@ const { user, isAdmin } = useAuth();
|
|
|
11
11
|
|
|
12
12
|
const { data: contest, refresh } = useLazyFetch(`/api/contests/${slug}`);
|
|
13
13
|
const isOwner = computed(() => isAdmin.value || !!(user.value?.id && contest.value?.createdById === user.value.id));
|
|
14
|
-
useSeoMeta({ title: () => `Edit: ${contest.value?.title ?? 'Contest'}
|
|
14
|
+
useSeoMeta({ title: () => `Edit: ${contest.value?.title ?? 'Contest'}, ${useSiteName()}` });
|
|
15
15
|
|
|
16
16
|
const saving = ref(false);
|
|
17
17
|
const title = ref('');
|
|
@@ -60,6 +60,9 @@ const criteria = ref<Criterion[]>([]);
|
|
|
60
60
|
// Phase B1 — explicit stage timeline (empty ⇒ standard synthesized flow).
|
|
61
61
|
const stages = ref<ContestStage[]>([]);
|
|
62
62
|
const currentStageIdRef = ref<string | null>(null);
|
|
63
|
+
// Declared before the contest loader (below) since the loader pre-fills advanceN.
|
|
64
|
+
const advancing = ref<string | null>(null);
|
|
65
|
+
const advanceN = ref<Record<string, number>>({});
|
|
63
66
|
|
|
64
67
|
// Load current data
|
|
65
68
|
watch(contest, (c) => {
|
|
@@ -83,6 +86,10 @@ watch(contest, (c) => {
|
|
|
83
86
|
showPrizes.value = c.showPrizes !== false;
|
|
84
87
|
stages.value = Array.isArray(c.stages) ? [...c.stages] : [];
|
|
85
88
|
currentStageIdRef.value = c.currentStageId ?? null;
|
|
89
|
+
// Pre-fill the Advancement control from each review stage's defined cut.
|
|
90
|
+
for (const s of stages.value) {
|
|
91
|
+
if (s.kind === 'review' && typeof s.advanceCount === 'number') advanceN.value[s.id] = s.advanceCount;
|
|
92
|
+
}
|
|
86
93
|
prizesDescription.value = c.prizesDescription ?? '';
|
|
87
94
|
prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title?: string; description?: string; value?: string }) => ({
|
|
88
95
|
place: p.place ?? null,
|
|
@@ -218,8 +225,6 @@ const statusAction = contestStatusAction;
|
|
|
218
225
|
|
|
219
226
|
// Phase B2 — advancement cuts. Operates on the PERSISTED stages (contest.value),
|
|
220
227
|
// not the editable `stages` ref, since it acts on real entries.
|
|
221
|
-
const advancing = ref<string | null>(null);
|
|
222
|
-
const advanceN = ref<Record<string, number>>({});
|
|
223
228
|
const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) => s.kind === 'review'));
|
|
224
229
|
async function advanceStage(stageId: string): Promise<void> {
|
|
225
230
|
const topN = advanceN.value[stageId];
|
|
@@ -279,7 +284,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
279
284
|
<div class="cpub-form-field">
|
|
280
285
|
<label class="cpub-form-label">URL Slug</label>
|
|
281
286
|
<input v-model="slugInput" type="text" class="cpub-form-input" @blur="slugInput = slugify(slugInput)" />
|
|
282
|
-
<p class="cpub-form-hint">The contest URL: <code>/contests/{{ slugify(slugInput) || 'your-contest' }}</code>. Changing it breaks old links
|
|
287
|
+
<p class="cpub-form-hint">The contest URL: <code>/contests/{{ slugify(slugInput) || 'your-contest' }}</code>. Changing it breaks old links, they won't redirect.</p>
|
|
283
288
|
</div>
|
|
284
289
|
<div class="cpub-form-field">
|
|
285
290
|
<label class="cpub-form-label">Subheading</label>
|
|
@@ -325,7 +330,8 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
325
330
|
|
|
326
331
|
<section class="cpub-form-section">
|
|
327
332
|
<h2 class="cpub-form-section-title">Stages</h2>
|
|
328
|
-
<p class="cpub-form-hint">Optional. The standard flow (Submissions → Judging → Results) is derived from the schedule above. Add custom stages for multi-round contests
|
|
333
|
+
<p class="cpub-form-hint">Optional. 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>
|
|
334
|
+
<p class="cpub-form-hint">How the pieces fit: <strong>Stages</strong> are the public timeline entrants see. The <strong>Status</strong> control (right) is what's actually open right now (accepting entries / judging / completed). <strong>Advancement</strong> (below) runs each review round's Top-N cut. Mark a stage <strong>Current</strong> to point judges + the countdown at it.</p>
|
|
329
335
|
<ContestStagesEditor
|
|
330
336
|
v-model="stages"
|
|
331
337
|
v-model:current-stage-id="currentStageIdRef"
|
|
@@ -356,8 +362,8 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
356
362
|
<input v-model="showPrizes" type="checkbox" />
|
|
357
363
|
<span>Show the Prizes tab on the contest page</span>
|
|
358
364
|
</label>
|
|
359
|
-
<p v-if="!showPrizes" class="cpub-form-hint">The Prizes tab is hidden
|
|
360
|
-
<p class="cpub-form-hint">Every field is optional. Use <strong>place</strong> for ranked prizes, a <strong>category</strong> for themed awards, or just a <strong>description</strong
|
|
365
|
+
<p v-if="!showPrizes" class="cpub-form-hint">The Prizes tab is hidden, any prizes below are saved but not shown to visitors.</p>
|
|
366
|
+
<p class="cpub-form-hint">Every field is optional. Use <strong>place</strong> for ranked prizes, a <strong>category</strong> for themed awards, or just a <strong>description</strong>, whatever fits. Cash value is optional.</p>
|
|
361
367
|
<div class="cpub-form-field">
|
|
362
368
|
<label class="cpub-form-label">Prizes overview (optional)</label>
|
|
363
369
|
<textarea v-model="prizesDescription" class="cpub-form-textarea" rows="3" placeholder="Intro shown above the prize cards. Supports Markdown." />
|
|
@@ -401,9 +407,9 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
401
407
|
<div class="cpub-form-field">
|
|
402
408
|
<label class="cpub-form-label">Score Visibility</label>
|
|
403
409
|
<select v-model="judgingVisibility" class="cpub-form-input">
|
|
404
|
-
<option value="judges-only">Judges only
|
|
405
|
-
<option value="public">Public
|
|
406
|
-
<option value="private">Private
|
|
410
|
+
<option value="judges-only">Judges only, scores hidden until results</option>
|
|
411
|
+
<option value="public">Public, show scores during judging</option>
|
|
412
|
+
<option value="private">Private, scores stay with organizers</option>
|
|
407
413
|
</select>
|
|
408
414
|
</div>
|
|
409
415
|
<label class="cpub-form-check">
|
|
@@ -439,9 +445,9 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
439
445
|
<div class="cpub-form-field">
|
|
440
446
|
<label class="cpub-form-label">Who can see this contest</label>
|
|
441
447
|
<select v-model="visibility" class="cpub-form-input">
|
|
442
|
-
<option value="public">Public
|
|
443
|
-
<option value="unlisted">Unlisted
|
|
444
|
-
<option value="private">Private
|
|
448
|
+
<option value="public">Public, listed and visible to everyone</option>
|
|
449
|
+
<option value="unlisted">Unlisted, visible by direct link, hidden from listings</option>
|
|
450
|
+
<option value="private">Private, restricted</option>
|
|
445
451
|
</select>
|
|
446
452
|
</div>
|
|
447
453
|
<div v-if="visibility === 'private'" class="cpub-form-field">
|
|
@@ -456,7 +462,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
|
|
|
456
462
|
<div class="cpub-subhead">
|
|
457
463
|
<h3 class="cpub-form-subtitle">Reviewers</h3>
|
|
458
464
|
</div>
|
|
459
|
-
<p class="cpub-form-hint">Reviewers can view this contest (even while it's private or in draft) without being a judge or an admin
|
|
465
|
+
<p class="cpub-form-hint">Reviewers can view this contest (even while it's private or in draft) without being a judge or an admin, view access scoped to this contest only. They can't edit or score entries.</p>
|
|
460
466
|
<ContestStakeholderManager :contest-slug="slug" />
|
|
461
467
|
</section>
|
|
462
468
|
|
|
@@ -12,8 +12,8 @@ const { data: apiEntriesData, refresh: refreshEntries } = useLazyFetch<{ items:
|
|
|
12
12
|
const { data: judgesData, refresh: refreshJudges } = useLazyFetch<ContestJudgeItem[]>(`/api/contests/${slug}/judges`);
|
|
13
13
|
|
|
14
14
|
useSeoMeta({
|
|
15
|
-
title: () => `${contest.value?.title || 'Contest'}
|
|
16
|
-
ogTitle: () => `${contest.value?.title || 'Contest'}
|
|
15
|
+
title: () => `${contest.value?.title || 'Contest'}, ${useSiteName()}`,
|
|
16
|
+
ogTitle: () => `${contest.value?.title || 'Contest'}, ${useSiteName()}`,
|
|
17
17
|
ogImage: () => contest.value?.bannerUrl || '/og-default.png',
|
|
18
18
|
});
|
|
19
19
|
|
|
@@ -43,8 +43,8 @@ const participants = computed<Participant[]>(() => {
|
|
|
43
43
|
// Visibility banner shown to those who can see a non-public contest.
|
|
44
44
|
const visibilityNote = computed(() => {
|
|
45
45
|
if (!c.value || c.value.visibility === 'public') return null;
|
|
46
|
-
if (c.value.visibility === 'unlisted') return { icon: 'fa-link', text: 'Unlisted
|
|
47
|
-
return { icon: 'fa-lock', text: 'Private
|
|
46
|
+
if (c.value.visibility === 'unlisted') return { icon: 'fa-link', text: 'Unlisted, visible by direct link only, hidden from listings.' };
|
|
47
|
+
return { icon: 'fa-lock', text: 'Private, visible only to you, reviewers, judges, and allowed roles.' };
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
// Tabs ----------------------------------------------------------------------
|
|
@@ -199,7 +199,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
199
199
|
</div>
|
|
200
200
|
<div class="cpub-submit-body">
|
|
201
201
|
<p class="cpub-submit-hint">
|
|
202
|
-
Pick one of your published projects to enter
|
|
202
|
+
Pick one of your published projects to enter, or start a new one.
|
|
203
203
|
<template v-if="eligibleTypes.length"> This contest accepts: {{ eligibleTypes.join(', ') }}.</template>
|
|
204
204
|
</p>
|
|
205
205
|
<div class="cpub-submit-gallery" role="radiogroup" aria-label="Select a project to submit">
|
|
@@ -230,7 +230,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
230
230
|
</button>
|
|
231
231
|
</div>
|
|
232
232
|
<p v-if="submittableContent.length === 0" class="cpub-submit-hint" style="margin-top: 10px; margin-bottom: 0;">
|
|
233
|
-
No eligible published content yet
|
|
233
|
+
No eligible published content yet, use “Create a new {{ newProjectType }}” above to start one.
|
|
234
234
|
</p>
|
|
235
235
|
</div>
|
|
236
236
|
<div class="cpub-submit-footer">
|
|
@@ -311,7 +311,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
311
311
|
<div v-if="c?.status === 'active'" class="cpub-entries-cta">
|
|
312
312
|
<div class="cpub-entries-cta-text">
|
|
313
313
|
<p class="cpub-entries-cta-title"><i class="fa-solid fa-trophy"></i> Enter this contest</p>
|
|
314
|
-
<p class="cpub-entries-cta-sub">Submit one of your published projects
|
|
314
|
+
<p class="cpub-entries-cta-sub">Submit one of your published projects, or start a new one.</p>
|
|
315
315
|
</div>
|
|
316
316
|
<button v-if="isAuthenticated" class="cpub-btn cpub-btn-primary cpub-btn-lg" @click="showSubmitDialog = true">
|
|
317
317
|
<i class="fa-solid fa-upload"></i> Submit Entry
|
|
@@ -25,6 +25,18 @@ const currentReviewStage = computed(() => {
|
|
|
25
25
|
return st && st.kind === 'review' ? st : null;
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
// The current review round's id — mirrors the server's per-round tagging exactly
|
|
29
|
+
// (normalizeStages-aware, so classic contests resolve to the synthesized core-review).
|
|
30
|
+
// Used to pre-fill ONLY this round's score, so a judge entering round 2 doesn't see
|
|
31
|
+
// their round-1 score.
|
|
32
|
+
const currentRoundId = computed<string | null>(() => {
|
|
33
|
+
const c = contest.value;
|
|
34
|
+
if (!c) return null;
|
|
35
|
+
const cid = currentStageId(c);
|
|
36
|
+
const st = normalizeStages(c).find((s) => s.id === cid);
|
|
37
|
+
return st && st.kind === 'review' ? st.id : null;
|
|
38
|
+
});
|
|
39
|
+
|
|
28
40
|
// Judging rubric: per-round criteria if the current review stage defines them,
|
|
29
41
|
// else the contest-level rubric. Judges score each criterion (0..max); the overall
|
|
30
42
|
// is the normalized weighted sum (computed server-side).
|
|
@@ -38,7 +50,7 @@ function critMax(i: number): number {
|
|
|
38
50
|
return typeof w === 'number' && w > 0 ? w : 100;
|
|
39
51
|
}
|
|
40
52
|
|
|
41
|
-
useSeoMeta({ title: () => `Judge: ${contest.value?.title || 'Contest'}
|
|
53
|
+
useSeoMeta({ title: () => `Judge: ${contest.value?.title || 'Contest'}, ${useSiteName()}` });
|
|
42
54
|
|
|
43
55
|
// Judge authorization derives from the contest_judges table.
|
|
44
56
|
const myJudge = computed(() => (judgesData.value ?? []).find((j) => j.userId === user.value?.id) ?? null);
|
|
@@ -66,7 +78,7 @@ const entryList = computed(() => {
|
|
|
66
78
|
// surviving cohort (eliminated entries drop out of later rounds).
|
|
67
79
|
const items = (entriesData.value?.items ?? []).filter((e) => !e.eliminated);
|
|
68
80
|
return items.map((entry) => {
|
|
69
|
-
const myScore = entry.judgeScores?.find((s) => s.judgeId === user.value?.id);
|
|
81
|
+
const myScore = entry.judgeScores?.find((s) => s.judgeId === user.value?.id && (s.roundId ?? null) === currentRoundId.value);
|
|
70
82
|
return {
|
|
71
83
|
id: entry.id,
|
|
72
84
|
contentId: entry.contentId,
|
|
@@ -158,7 +170,7 @@ async function submitScore(entryId: string): Promise<void> {
|
|
|
158
170
|
await $fetch(`/api/contests/${slug}/judge`, { method: 'POST', body });
|
|
159
171
|
success.value = 'Score submitted for entry.';
|
|
160
172
|
await refreshEntries().catch(() => {
|
|
161
|
-
success.value = 'Score saved
|
|
173
|
+
success.value = 'Score saved, refresh to see the updated totals.';
|
|
162
174
|
});
|
|
163
175
|
} catch (err: unknown) {
|
|
164
176
|
error.value = (err as { data?: { message?: string } })?.data?.message || 'Failed to submit score.';
|
|
@@ -16,7 +16,7 @@ const shownEntries = computed(() => rankedEntries.value.length);
|
|
|
16
16
|
const { data: votesData } = useLazyFetch<ContestEntryVoteInfo[]>(`/api/contests/${slug}/votes`);
|
|
17
17
|
|
|
18
18
|
useSeoMeta({
|
|
19
|
-
title: () => `Results: ${contest.value?.title || 'Contest'}
|
|
19
|
+
title: () => `Results: ${contest.value?.title || 'Contest'}, ${useSiteName()}`,
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
// Community-vote tallies (only when the contest enabled community voting).
|
|
@@ -78,7 +78,7 @@ function medalColor(rank: number): string {
|
|
|
78
78
|
</NuxtLink>
|
|
79
79
|
<h1 class="cpub-results-title">
|
|
80
80
|
<i class="fa-solid fa-ranking-star" style="color: var(--yellow);"></i>
|
|
81
|
-
{{ contest?.title || 'Contest' }}
|
|
81
|
+
{{ contest?.title || 'Contest' }}, Results
|
|
82
82
|
</h1>
|
|
83
83
|
</header>
|
|
84
84
|
|
|
@@ -115,7 +115,7 @@ function medalColor(rank: number): string {
|
|
|
115
115
|
</div>
|
|
116
116
|
<NuxtLink :to="`/u/${entry.authorUsername}/${entry.contentType}/${entry.contentSlug}`" class="cpub-podium-title">{{ entry.contentTitle }}</NuxtLink>
|
|
117
117
|
<NuxtLink :to="`/u/${entry.authorUsername}`" class="cpub-podium-author">{{ entry.authorName }}</NuxtLink>
|
|
118
|
-
<div class="cpub-podium-score">Score: {{ entry.score ?? '
|
|
118
|
+
<div class="cpub-podium-score">Score: {{ entry.score ?? '-' }}</div>
|
|
119
119
|
<template v-if="entry.rank && prizeForRank(entry.rank)">
|
|
120
120
|
<div class="cpub-podium-prize">
|
|
121
121
|
<i class="fa-solid fa-gift"></i> {{ prizeForRank(entry.rank)?.title }}
|
|
@@ -151,7 +151,7 @@ function medalColor(rank: number): string {
|
|
|
151
151
|
<span v-if="entry.rank && entry.rank <= 3" :style="{ color: medalColor(entry.rank) }">
|
|
152
152
|
<i class="fa-solid" :class="medalIcon(entry.rank)"></i>
|
|
153
153
|
</span>
|
|
154
|
-
{{ entry.rank ?? '
|
|
154
|
+
{{ entry.rank ?? '-' }}
|
|
155
155
|
</td>
|
|
156
156
|
<td>
|
|
157
157
|
<NuxtLink :to="`/u/${entry.authorUsername}/${entry.contentType}/${entry.contentSlug}`" class="cpub-lb-entry-link">{{ entry.contentTitle }}</NuxtLink>
|
|
@@ -159,7 +159,7 @@ function medalColor(rank: number): string {
|
|
|
159
159
|
<td>
|
|
160
160
|
<NuxtLink :to="`/u/${entry.authorUsername}`" class="cpub-lb-author-link">{{ entry.authorName }}</NuxtLink>
|
|
161
161
|
</td>
|
|
162
|
-
<td class="cpub-lb-score">{{ entry.score ?? '
|
|
162
|
+
<td class="cpub-lb-score">{{ entry.score ?? '-' }}</td>
|
|
163
163
|
<td v-if="votingEnabled" class="cpub-lb-votes"><i class="fa-solid fa-heart"></i> {{ voteCount(entry.id) }}</td>
|
|
164
164
|
</tr>
|
|
165
165
|
</tbody>
|
|
@@ -3,7 +3,7 @@ import type { ContestStage } from '@commonpub/schema';
|
|
|
3
3
|
|
|
4
4
|
definePageMeta({ middleware: 'auth' });
|
|
5
5
|
|
|
6
|
-
useSeoMeta({ title: `Create Contest
|
|
6
|
+
useSeoMeta({ title: `Create Contest, ${useSiteName()}` });
|
|
7
7
|
|
|
8
8
|
const toast = useToast();
|
|
9
9
|
const { extract: extractError } = useApiError();
|
|
@@ -187,12 +187,12 @@ function prizeLabel(prize: Prize): string {
|
|
|
187
187
|
</div>
|
|
188
188
|
<div class="cpub-form-field">
|
|
189
189
|
<label for="contest-desc" class="cpub-form-label">Description</label>
|
|
190
|
-
<textarea id="contest-desc" v-model="description" class="cpub-form-textarea" rows="4" placeholder="Describe your contest. Supports Markdown
|
|
190
|
+
<textarea id="contest-desc" v-model="description" class="cpub-form-textarea" rows="4" placeholder="Describe your contest. Supports Markdown, # headings, - lists, **bold**, [links](url)…" />
|
|
191
191
|
<p class="cpub-form-hint">Supports Markdown (headings, lists, bold, links) and inline HTML. Shown formatted on the contest page.</p>
|
|
192
192
|
</div>
|
|
193
193
|
<div class="cpub-form-field">
|
|
194
194
|
<label for="contest-rules" class="cpub-form-label">Rules</label>
|
|
195
|
-
<textarea id="contest-rules" v-model="rules" class="cpub-form-textarea" rows="6" placeholder="Contest rules and requirements. Supports Markdown
|
|
195
|
+
<textarea id="contest-rules" v-model="rules" class="cpub-form-textarea" rows="6" placeholder="Contest rules and requirements. Supports Markdown, one rule per line, or full Markdown." />
|
|
196
196
|
<p class="cpub-form-hint">Supports Markdown. Plain one-rule-per-line text is rendered as a numbered list.</p>
|
|
197
197
|
</div>
|
|
198
198
|
<div class="cpub-form-field">
|
|
@@ -225,8 +225,8 @@ function prizeLabel(prize: Prize): string {
|
|
|
225
225
|
|
|
226
226
|
<!-- Stages -->
|
|
227
227
|
<section class="cpub-form-section">
|
|
228
|
-
<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);"
|
|
229
|
-
<p class="cpub-form-hint">The standard flow (Submissions → Judging → Results) is derived from the schedule above. Add custom stages for multi-round contests
|
|
228
|
+
<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>
|
|
229
|
+
<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>
|
|
230
230
|
<ContestStagesEditor
|
|
231
231
|
v-model="stages"
|
|
232
232
|
v-model:current-stage-id="currentStageIdRef"
|
|
@@ -242,9 +242,9 @@ function prizeLabel(prize: Prize): string {
|
|
|
242
242
|
<div class="cpub-form-field">
|
|
243
243
|
<label for="visibility" class="cpub-form-label">Who can see this contest</label>
|
|
244
244
|
<select id="visibility" v-model="visibility" class="cpub-form-input">
|
|
245
|
-
<option value="public">Public
|
|
246
|
-
<option value="unlisted">Unlisted
|
|
247
|
-
<option value="private">Private
|
|
245
|
+
<option value="public">Public, listed and visible to everyone</option>
|
|
246
|
+
<option value="unlisted">Unlisted, visible by direct link, hidden from listings</option>
|
|
247
|
+
<option value="private">Private, restricted (you can publish it later)</option>
|
|
248
248
|
</select>
|
|
249
249
|
</div>
|
|
250
250
|
<div v-if="visibility === 'private'" class="cpub-form-field">
|
|
@@ -285,9 +285,9 @@ function prizeLabel(prize: Prize): string {
|
|
|
285
285
|
<div class="cpub-form-field">
|
|
286
286
|
<label for="judging-visibility" class="cpub-form-label">Score Visibility</label>
|
|
287
287
|
<select id="judging-visibility" v-model="judgingVisibility" class="cpub-form-input">
|
|
288
|
-
<option value="judges-only">Judges only
|
|
289
|
-
<option value="public">Public
|
|
290
|
-
<option value="private">Private
|
|
288
|
+
<option value="judges-only">Judges only, scores hidden until results</option>
|
|
289
|
+
<option value="public">Public, show scores during judging</option>
|
|
290
|
+
<option value="private">Private, scores stay with organizers</option>
|
|
291
291
|
</select>
|
|
292
292
|
</div>
|
|
293
293
|
<label class="cpub-form-check">
|
|
@@ -299,7 +299,7 @@ function prizeLabel(prize: Prize): string {
|
|
|
299
299
|
<h3 class="cpub-form-subtitle">Judging Criteria <span v-if="criteriaTotal" class="cpub-form-hint-inline">{{ criteriaTotal }} pts</span></h3>
|
|
300
300
|
<button type="button" class="cpub-btn cpub-btn-sm" @click="addCriterion"><i class="fa-solid fa-plus"></i> Add Criterion</button>
|
|
301
301
|
</div>
|
|
302
|
-
<p v-if="!criteria.length" class="cpub-form-hint">Optional rubric shown to entrants and judges (e.g. Documentation
|
|
302
|
+
<p v-if="!criteria.length" class="cpub-form-hint">Optional rubric shown to entrants and judges (e.g. Documentation, 20 pts).</p>
|
|
303
303
|
<div v-for="(crit, ci) in criteria" :key="ci" class="cpub-criterion-row">
|
|
304
304
|
<div class="cpub-form-row">
|
|
305
305
|
<div class="cpub-form-field" style="flex: 3">
|
|
@@ -321,7 +321,7 @@ function prizeLabel(prize: Prize): string {
|
|
|
321
321
|
<!-- Prizes -->
|
|
322
322
|
<section class="cpub-form-section">
|
|
323
323
|
<div class="cpub-form-section-header">
|
|
324
|
-
<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);"
|
|
324
|
+
<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>
|
|
325
325
|
<button type="button" class="cpub-btn cpub-btn-sm" @click="addPrize">
|
|
326
326
|
<i class="fa-solid fa-plus"></i> Add Prize
|
|
327
327
|
</button>
|
|
@@ -331,9 +331,9 @@ function prizeLabel(prize: Prize): string {
|
|
|
331
331
|
<input v-model="showPrizes" type="checkbox" />
|
|
332
332
|
<span>Show the Prizes tab on the contest page</span>
|
|
333
333
|
</label>
|
|
334
|
-
<p v-if="!showPrizes" class="cpub-form-hint">The Prizes tab is hidden
|
|
334
|
+
<p v-if="!showPrizes" class="cpub-form-hint">The Prizes tab is hidden, any prizes below are saved but not shown to visitors.</p>
|
|
335
335
|
|
|
336
|
-
<p class="cpub-form-hint">Contests don't need prizes
|
|
336
|
+
<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>
|
|
337
337
|
<div class="cpub-form-field">
|
|
338
338
|
<label for="prizes-desc" class="cpub-form-label">Prizes overview (optional)</label>
|
|
339
339
|
<textarea id="prizes-desc" v-model="prizesDescription" class="cpub-form-textarea" rows="3" placeholder="Intro shown above the prize cards. Supports Markdown." />
|
package/pages/contests/index.vue
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
useSeoMeta({ title: `Contests
|
|
2
|
+
useSeoMeta({ title: `Contests, ${useSiteName()}` });
|
|
3
3
|
|
|
4
4
|
const { data: contests } = await useFetch('/api/contests');
|
|
5
5
|
const { isAuthenticated, isAdmin, user } = useAuth();
|
|
@@ -23,7 +23,7 @@ function coverFor(url: string | null | undefined): string | null {
|
|
|
23
23
|
if (siteDomain && !url.includes(siteDomain)) {
|
|
24
24
|
return `/api/image-proxy?url=${encodeURIComponent(url)}&w=600`;
|
|
25
25
|
}
|
|
26
|
-
} catch { /* invalid URL
|
|
26
|
+
} catch { /* invalid URL, use as-is */ }
|
|
27
27
|
return url;
|
|
28
28
|
}
|
|
29
29
|
|
package/pages/cookies.vue
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
const runtimeConfig = useRuntimeConfig();
|
|
3
3
|
const siteName = computed(() => (runtimeConfig.public.siteName as string) || 'CommonPub');
|
|
4
4
|
|
|
5
|
-
useSeoMeta({ title: `Cookie Policy
|
|
5
|
+
useSeoMeta({ title: `Cookie Policy, ${siteName.value}` });
|
|
6
6
|
|
|
7
7
|
const { cookies, consentLevel, acceptAll, acceptEssential, resetConsent, hasConsented } = useCookieConsent();
|
|
8
8
|
|
package/pages/create.vue
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { ContentType } from '@commonpub/server';
|
|
3
3
|
|
|
4
|
-
useSeoMeta({ title: `Create
|
|
4
|
+
useSeoMeta({ title: `Create, ${useSiteName()}` });
|
|
5
5
|
definePageMeta({ middleware: 'auth' });
|
|
6
6
|
|
|
7
7
|
const { isTypeEnabled } = useContentTypes();
|
|
@@ -27,7 +27,7 @@ const allTypes = [
|
|
|
27
27
|
bg: 'var(--pink-bg)',
|
|
28
28
|
border: 'var(--pink-border)',
|
|
29
29
|
name: 'Blog',
|
|
30
|
-
desc: 'Write long-form content
|
|
30
|
+
desc: 'Write long-form content, articles, tutorials, deep dives, opinion pieces, or personal updates with rich formatting.',
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
type: 'explainer',
|
package/pages/dashboard.vue
CHANGED
|
@@ -208,7 +208,7 @@ watch(selectedVersion, () => {
|
|
|
208
208
|
const sidebarOpen = ref(false);
|
|
209
209
|
|
|
210
210
|
useSeoMeta({
|
|
211
|
-
title: () => renderedPage.value ? `${renderedPage.value.title}
|
|
211
|
+
title: () => renderedPage.value ? `${renderedPage.value.title}, ${site.value?.name ?? 'Docs'}` : `Docs, ${useSiteName()}`,
|
|
212
212
|
description: () => renderedPage.value?.frontmatter?.description ?? '',
|
|
213
213
|
});
|
|
214
214
|
</script>
|
|
@@ -48,7 +48,7 @@ watch(selectedVersion, () => {
|
|
|
48
48
|
refreshPages();
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
useSeoMeta({ title: () => `Edit ${site.value?.name ?? 'Docs'}
|
|
51
|
+
useSeoMeta({ title: () => `Edit ${site.value?.name ?? 'Docs'}, ${useSiteName()}` });
|
|
52
52
|
|
|
53
53
|
interface DocsPage {
|
|
54
54
|
id: string;
|
|
@@ -98,7 +98,7 @@ const firstPageSlug = computed(() => {
|
|
|
98
98
|
const sidebarOpen = ref(false);
|
|
99
99
|
|
|
100
100
|
useSeoMeta({
|
|
101
|
-
title: () => site.value ? `${site.value.name}
|
|
101
|
+
title: () => site.value ? `${site.value.name}, Docs, ${useSiteName()}` : `Docs, ${useSiteName()}`,
|
|
102
102
|
description: () => site.value?.description || '',
|
|
103
103
|
});
|
|
104
104
|
</script>
|
package/pages/docs/create.vue
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
definePageMeta({ middleware: 'auth' });
|
|
3
3
|
|
|
4
|
-
useSeoMeta({ title: `Create Docs Site
|
|
4
|
+
useSeoMeta({ title: `Create Docs Site, ${useSiteName()}` });
|
|
5
5
|
|
|
6
6
|
const toast = useToast();
|
|
7
7
|
const { extract: extractError } = useApiError();
|
package/pages/docs/index.vue
CHANGED
|
@@ -21,7 +21,7 @@ if (!isOwner.value && !isAdmin.value) {
|
|
|
21
21
|
throw createError({ statusCode: 403, statusMessage: 'Unauthorized' });
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
useSeoMeta({ title: `Edit ${event.value.title}
|
|
24
|
+
useSeoMeta({ title: `Edit ${event.value.title}, Events, ${useSiteName()}` });
|
|
25
25
|
|
|
26
26
|
const saving = ref(false);
|
|
27
27
|
const form = reactive({
|
|
@@ -9,7 +9,7 @@ const { isAuthenticated, user } = useAuth();
|
|
|
9
9
|
const { data: event, refresh } = await useFetch<EventDetail>(`/api/events/${slug}`);
|
|
10
10
|
const { data: attendees } = await useFetch<{ items: AttendeeItem[]; total: number }>(`/api/events/${slug}/attendees`);
|
|
11
11
|
|
|
12
|
-
useSeoMeta({ title: event.value ? `${event.value.title}
|
|
12
|
+
useSeoMeta({ title: event.value ? `${event.value.title}, Events, ${useSiteName()}` : `Event, ${useSiteName()}` });
|
|
13
13
|
|
|
14
14
|
const rsvpLoading = ref(false);
|
|
15
15
|
|
|
@@ -107,7 +107,7 @@ const typeIcon = computed(() => {
|
|
|
107
107
|
<i class="fa-solid fa-calendar"></i>
|
|
108
108
|
<div>
|
|
109
109
|
<div>{{ formatDate(event.startDate) }}</div>
|
|
110
|
-
<div class="cpub-event-info-sub">{{ formatTime(event.startDate) }}
|
|
110
|
+
<div class="cpub-event-info-sub">{{ formatTime(event.startDate) }}, {{ formatTime(event.endDate) }}</div>
|
|
111
111
|
</div>
|
|
112
112
|
</div>
|
|
113
113
|
<div v-if="event.location" class="cpub-event-info-item">
|
package/pages/events/create.vue
CHANGED
package/pages/events/index.vue
CHANGED