@commonpub/layer 0.57.0 → 0.59.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.
Files changed (132) hide show
  1. package/components/LayoutRow.vue +8 -8
  2. package/components/LayoutSection.vue +8 -8
  3. package/components/LayoutSlot.vue +3 -3
  4. package/components/MirrorDetailModal.vue +3 -3
  5. package/components/MirrorRequestApproveModal.vue +3 -3
  6. package/components/PollDisplay.vue +1 -1
  7. package/components/RegistryDirectory.vue +2 -2
  8. package/components/admin/layouts/AdminLayoutsAutoForm.vue +1 -1
  9. package/components/admin/layouts/AdminLayoutsCanvas.vue +2 -2
  10. package/components/admin/layouts/AdminLayoutsConflictModal.vue +1 -1
  11. package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +1 -1
  12. package/components/admin/layouts/AdminLayoutsInspectorPage.vue +1 -1
  13. package/components/admin/layouts/AdminLayoutsToolbar.vue +5 -5
  14. package/components/admin/theme/AdminThemeSceneGallery.vue +3 -3
  15. package/components/admin/theme/AdminThemeSceneProse.vue +3 -3
  16. package/components/admin/theme/AdminThemeTokenInput.vue +1 -1
  17. package/components/blocks/BlockCodeView.vue +2 -2
  18. package/components/blocks/BlockDividerView.vue +1 -1
  19. package/components/blocks/BlockPartsListView.vue +1 -1
  20. package/components/blocks/BlockQuizView.vue +1 -1
  21. package/components/blocks/BlockQuoteView.vue +1 -1
  22. package/components/contest/ContestHero.vue +2 -2
  23. package/components/contest/ContestStagesEditor.vue +4 -4
  24. package/components/editors/ArticleEditor.vue +1 -1
  25. package/components/editors/ExplainerEditor.vue +1 -1
  26. package/components/sections/SectionLearning.vue +1 -1
  27. package/components/views/ArticleView.vue +2 -2
  28. package/components/views/ProjectView.vue +3 -3
  29. package/composables/useAdminSidebar.ts +3 -3
  30. package/composables/useLayoutEditor.ts +1 -1
  31. package/composables/useLayoutHotkeys.ts +1 -1
  32. package/composables/useLayoutResize.ts +1 -1
  33. package/composables/usePublishValidation.ts +1 -1
  34. package/composables/useThemeAdmin.ts +2 -2
  35. package/error.vue +1 -1
  36. package/layouts/admin.vue +2 -2
  37. package/layouts/default.vue +2 -2
  38. package/package.json +6 -6
  39. package/pages/[type]/index.vue +1 -1
  40. package/pages/about.vue +3 -3
  41. package/pages/admin/api-keys.vue +5 -5
  42. package/pages/admin/audit.vue +2 -2
  43. package/pages/admin/categories.vue +1 -1
  44. package/pages/admin/content.vue +2 -2
  45. package/pages/admin/features.vue +1 -1
  46. package/pages/admin/federation.vue +9 -9
  47. package/pages/admin/homepage.vue +4 -4
  48. package/pages/admin/index.vue +1 -1
  49. package/pages/admin/layouts/[id].vue +18 -18
  50. package/pages/admin/layouts/index.vue +4 -4
  51. package/pages/admin/navigation.vue +1 -1
  52. package/pages/admin/reports.vue +1 -1
  53. package/pages/admin/settings.vue +2 -2
  54. package/pages/admin/theme/edit/[id].vue +2 -2
  55. package/pages/admin/theme/index.vue +5 -5
  56. package/pages/admin/users.vue +1 -1
  57. package/pages/auth/forgot-password.vue +1 -1
  58. package/pages/auth/login.vue +3 -3
  59. package/pages/auth/register.vue +1 -1
  60. package/pages/auth/reset-password.vue +1 -1
  61. package/pages/auth/verify-email.vue +1 -1
  62. package/pages/cert/[code].vue +1 -1
  63. package/pages/contests/[slug]/edit.vue +78 -19
  64. package/pages/contests/[slug]/index.vue +7 -7
  65. package/pages/contests/[slug]/judge.vue +15 -3
  66. package/pages/contests/[slug]/results.vue +5 -5
  67. package/pages/contests/create.vue +15 -15
  68. package/pages/contests/index.vue +2 -2
  69. package/pages/cookies.vue +1 -1
  70. package/pages/create.vue +2 -2
  71. package/pages/dashboard.vue +1 -1
  72. package/pages/docs/[siteSlug]/[...pagePath].vue +1 -1
  73. package/pages/docs/[siteSlug]/edit.vue +1 -1
  74. package/pages/docs/[siteSlug]/index.vue +1 -1
  75. package/pages/docs/create.vue +1 -1
  76. package/pages/docs/index.vue +1 -1
  77. package/pages/events/[slug]/edit.vue +1 -1
  78. package/pages/events/[slug]/index.vue +2 -2
  79. package/pages/events/create.vue +1 -1
  80. package/pages/events/index.vue +1 -1
  81. package/pages/explore.vue +1 -1
  82. package/pages/federated-hubs/[id]/index.vue +3 -3
  83. package/pages/federated-hubs/[id]/posts/[postId].vue +1 -1
  84. package/pages/federation/search.vue +1 -1
  85. package/pages/feed.vue +1 -1
  86. package/pages/hubs/[slug]/members.vue +1 -1
  87. package/pages/hubs/[slug]/posts/[postId].vue +1 -1
  88. package/pages/hubs/[slug]/settings.vue +5 -5
  89. package/pages/hubs/create.vue +6 -6
  90. package/pages/hubs/index.vue +1 -1
  91. package/pages/index.vue +2 -2
  92. package/pages/learn/[slug]/[lessonSlug]/edit.vue +1 -1
  93. package/pages/learn/[slug]/[lessonSlug]/index.vue +4 -4
  94. package/pages/learn/[slug]/edit.vue +1 -1
  95. package/pages/learn/[slug]/index.vue +1 -1
  96. package/pages/learn/create.vue +1 -1
  97. package/pages/learn/index.vue +2 -2
  98. package/pages/messages/[conversationId].vue +1 -1
  99. package/pages/messages/index.vue +1 -1
  100. package/pages/notifications.vue +1 -1
  101. package/pages/privacy.vue +5 -5
  102. package/pages/products/[slug].vue +1 -1
  103. package/pages/products/index.vue +1 -1
  104. package/pages/search.vue +1 -1
  105. package/pages/settings/profile.vue +1 -1
  106. package/pages/settings.vue +1 -1
  107. package/pages/tags/[slug].vue +1 -1
  108. package/pages/tags/index.vue +1 -1
  109. package/pages/terms.vue +1 -1
  110. package/pages/u/[username]/[type]/[slug]/edit.vue +3 -3
  111. package/pages/u/[username]/[type]/[slug]/index.vue +1 -1
  112. package/pages/u/[username]/followers.vue +1 -1
  113. package/pages/u/[username]/following.vue +1 -1
  114. package/pages/u/[username]/index.vue +3 -3
  115. package/pages/videos/[id].vue +1 -1
  116. package/pages/videos/index.vue +1 -1
  117. package/pages/videos/submit.vue +2 -2
  118. package/sections/builtin/hero.ts +1 -1
  119. package/sections/builtin/markdown.ts +1 -1
  120. package/server/api/admin/homepage/sections.put.ts +1 -1
  121. package/server/api/admin/layouts/[id].put.ts +1 -1
  122. package/server/api/contests/[slug]/entries.post.ts +3 -3
  123. package/server/api/hubs/[slug]/feed.xml.get.ts +1 -1
  124. package/server/api/users/[username]/feed.xml.get.ts +1 -1
  125. package/server/middleware/content-redirect.ts +1 -1
  126. package/server/plugins/federation-delivery.ts +1 -1
  127. package/server/plugins/federation-hub-sync.ts +1 -1
  128. package/server/plugins/registry-heartbeat.ts +3 -3
  129. package/server/plugins/search-index.ts +1 -1
  130. package/server/utils/email.ts +3 -3
  131. package/server/utils/instanceTheme.ts +1 -1
  132. 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 Admin ${useSiteName()}` });
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 based on ${themeId}`,
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 default reset to Classic' : 'Theme deleted', 'success');
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()} ${detected.count} tokens.`,
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 probably from
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>
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  definePageMeta({ layout: 'admin', middleware: 'auth' });
3
- useSeoMeta({ title: `Users Admin ${useSiteName()}` });
3
+ useSeoMeta({ title: `Users, Admin, ${useSiteName()}` });
4
4
 
5
5
  const search = ref('');
6
6
  const toast = useToast();
@@ -2,7 +2,7 @@
2
2
  definePageMeta({ layout: 'auth' });
3
3
 
4
4
  useSeoMeta({
5
- title: `Forgot Password ${useSiteName()}`,
5
+ title: `Forgot Password, ${useSiteName()}`,
6
6
  description: 'Reset your CommonPub account password.',
7
7
  });
8
8
 
@@ -2,7 +2,7 @@
2
2
  definePageMeta({ layout: 'auth' });
3
3
 
4
4
  useSeoMeta({
5
- title: `Log in ${useSiteName()}`,
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) gated by features.identity.signInWithRemote.
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">— or Pleroma, GoToSocial, Akkoma, Firefish</span>
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
@@ -2,7 +2,7 @@
2
2
  definePageMeta({ layout: 'auth' });
3
3
 
4
4
  useSeoMeta({
5
- title: `Register ${useSiteName()}`,
5
+ title: `Register, ${useSiteName()}`,
6
6
  description: 'Create your CommonPub account.',
7
7
  });
8
8
 
@@ -2,7 +2,7 @@
2
2
  definePageMeta({ layout: 'auth' });
3
3
 
4
4
  useSeoMeta({
5
- title: `Reset Password ${useSiteName()}`,
5
+ title: `Reset Password, ${useSiteName()}`,
6
6
  description: 'Set a new password for your CommonPub account.',
7
7
  });
8
8
 
@@ -2,7 +2,7 @@
2
2
  definePageMeta({ layout: 'auth' });
3
3
 
4
4
  useSeoMeta({
5
- title: `Verify Email ${useSiteName()}`,
5
+ title: `Verify Email, ${useSiteName()}`,
6
6
  description: 'Verify your CommonPub email address.',
7
7
  });
8
8
 
@@ -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 ${certData.value.path.title} ${useSiteName()}` : `Certificate ${useSiteName()}`,
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>
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { ContestStage } from '@commonpub/schema';
3
+ import type { Serialized, ContestEntryItem } from '@commonpub/server';
3
4
 
4
5
  definePageMeta({ middleware: 'auth' });
5
6
 
@@ -11,7 +12,7 @@ const { user, isAdmin } = useAuth();
11
12
 
12
13
  const { data: contest, refresh } = useLazyFetch(`/api/contests/${slug}`);
13
14
  const isOwner = computed(() => isAdmin.value || !!(user.value?.id && contest.value?.createdById === user.value.id));
14
- useSeoMeta({ title: () => `Edit: ${contest.value?.title ?? 'Contest'} ${useSiteName()}` });
15
+ useSeoMeta({ title: () => `Edit: ${contest.value?.title ?? 'Contest'}, ${useSiteName()}` });
15
16
 
16
17
  const saving = ref(false);
17
18
  const title = ref('');
@@ -226,6 +227,35 @@ const statusAction = contestStatusAction;
226
227
  // Phase B2 — advancement cuts. Operates on the PERSISTED stages (contest.value),
227
228
  // not the editable `stages` ref, since it acts on real entries.
228
229
  const reviewStages = computed(() => (contest.value?.stages ?? []).filter((s) => s.kind === 'review'));
230
+
231
+ // Entries (the cohort) — for the manual advancement picker. The eligible set is
232
+ // everyone not already eliminated by a prior round's cut.
233
+ const { data: entriesData, refresh: refreshEntries } = useLazyFetch<{ items: Serialized<ContestEntryItem>[] }>(`/api/contests/${slug}/entries`);
234
+ const eligibleEntries = computed(() => (entriesData.value?.items ?? []).filter((e) => !e.eliminated));
235
+ const advanceMode = ref<Record<string, 'topN' | 'manual'>>({});
236
+ const manualPick = ref<Record<string, string[]>>({});
237
+ function toggleManual(stageId: string, entryId: string): void {
238
+ const cur = manualPick.value[stageId] ?? [];
239
+ manualPick.value[stageId] = cur.includes(entryId) ? cur.filter((x) => x !== entryId) : [...cur, entryId];
240
+ }
241
+ async function advanceStageManual(stageId: string): Promise<void> {
242
+ const ids = manualPick.value[stageId] ?? [];
243
+ if (!ids.length) { toast.error('Select at least one entry to advance.'); return; }
244
+ if (!confirm(`Advance the ${ids.length} selected ${ids.length === 1 ? 'entry' : 'entries'}? The rest of the cohort is marked "not advanced" and drops out of later judging + final results.`)) return;
245
+ advancing.value = stageId;
246
+ try {
247
+ const r = await $fetch<{ advancedCount: number; eliminatedCount: number }>(`/api/contests/${slug}/advance`, {
248
+ method: 'POST',
249
+ body: { reviewStageId: stageId, mode: 'manual', advancedEntryIds: ids },
250
+ });
251
+ toast.success(`${r.advancedCount} advanced, ${r.eliminatedCount} not advanced.`);
252
+ await Promise.all([refresh(), refreshEntries()]);
253
+ } catch (err: unknown) {
254
+ toast.error(extractError(err));
255
+ } finally {
256
+ advancing.value = null;
257
+ }
258
+ }
229
259
  async function advanceStage(stageId: string): Promise<void> {
230
260
  const topN = advanceN.value[stageId];
231
261
  if (!topN || topN < 1) { toast.error('Enter how many entries advance.'); return; }
@@ -237,7 +267,7 @@ async function advanceStage(stageId: string): Promise<void> {
237
267
  body: { reviewStageId: stageId, mode: 'topN', topN },
238
268
  });
239
269
  toast.success(`${r.advancedCount} advanced, ${r.eliminatedCount} not advanced.`);
240
- await refresh();
270
+ await Promise.all([refresh(), refreshEntries()]);
241
271
  } catch (err: unknown) {
242
272
  toast.error(extractError(err));
243
273
  } finally {
@@ -284,7 +314,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
284
314
  <div class="cpub-form-field">
285
315
  <label class="cpub-form-label">URL Slug</label>
286
316
  <input v-model="slugInput" type="text" class="cpub-form-input" @blur="slugInput = slugify(slugInput)" />
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>
317
+ <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>
288
318
  </div>
289
319
  <div class="cpub-form-field">
290
320
  <label class="cpub-form-label">Subheading</label>
@@ -330,7 +360,8 @@ async function transitionStatus(newStatus: string): Promise<void> {
330
360
 
331
361
  <section class="cpub-form-section">
332
362
  <h2 class="cpub-form-section-title">Stages</h2>
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>
363
+ <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>
364
+ <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>
334
365
  <ContestStagesEditor
335
366
  v-model="stages"
336
367
  v-model:current-stage-id="currentStageIdRef"
@@ -343,15 +374,36 @@ async function transitionStatus(newStatus: string): Promise<void> {
343
374
  <section v-if="reviewStages.length" class="cpub-form-section">
344
375
  <h2 class="cpub-form-section-title">Advancement</h2>
345
376
  <p class="cpub-form-hint">Multi-round contests: after judging a review stage, advance the top entries to the next stage. Entries below the cut are marked "not advanced" and excluded from later judging + final results. Re-running re-computes the cut. (Save any stage changes above first.)</p>
346
- <div v-for="rs in reviewStages" :key="rs.id" class="cpub-advance-row">
347
- <span class="cpub-advance-name"><i class="fa-solid fa-gavel"></i> {{ rs.name }}</span>
348
- <div class="cpub-advance-ctl">
377
+ <div v-for="rs in reviewStages" :key="rs.id" class="cpub-advance-block">
378
+ <div class="cpub-advance-row">
379
+ <span class="cpub-advance-name"><i class="fa-solid fa-gavel"></i> {{ rs.name }}</span>
380
+ <div class="cpub-advance-mode">
381
+ <label class="cpub-form-check"><input type="radio" :name="`mode-${rs.id}`" :checked="(advanceMode[rs.id] ?? 'topN') === 'topN'" @change="advanceMode[rs.id] = 'topN'" /> <span>Top N</span></label>
382
+ <label class="cpub-form-check"><input type="radio" :name="`mode-${rs.id}`" :checked="advanceMode[rs.id] === 'manual'" @change="advanceMode[rs.id] = 'manual'" /> <span>Pick manually</span></label>
383
+ </div>
384
+ </div>
385
+
386
+ <div v-if="(advanceMode[rs.id] ?? 'topN') === 'topN'" class="cpub-advance-ctl">
349
387
  <label class="cpub-form-label" :for="`adv-${rs.id}`">Advance top</label>
350
388
  <input :id="`adv-${rs.id}`" v-model.number="advanceN[rs.id]" type="number" min="1" class="cpub-form-input cpub-advance-n" placeholder="50" />
351
389
  <button type="button" class="cpub-btn cpub-btn-sm" :disabled="advancing === rs.id" @click="advanceStage(rs.id)">
352
390
  <i class="fa-solid fa-arrow-up-right-dots"></i> {{ advancing === rs.id ? 'Advancing…' : 'Advance' }}
353
391
  </button>
354
392
  </div>
393
+
394
+ <div v-else class="cpub-advance-manual">
395
+ <p v-if="!eligibleEntries.length" class="cpub-form-hint" style="margin: 0;">No entries in the current cohort to pick from yet.</p>
396
+ <template v-else>
397
+ <label v-for="e in eligibleEntries" :key="e.id" class="cpub-advance-pick">
398
+ <input type="checkbox" :checked="(manualPick[rs.id] ?? []).includes(e.id)" @change="toggleManual(rs.id, e.id)" />
399
+ <span class="cpub-advance-pick-title">{{ e.contentTitle }}</span>
400
+ <span v-if="e.score != null" class="cpub-advance-pick-score">{{ e.score }}</span>
401
+ </label>
402
+ <button type="button" class="cpub-btn cpub-btn-sm" :disabled="advancing === rs.id || !(manualPick[rs.id] ?? []).length" @click="advanceStageManual(rs.id)">
403
+ <i class="fa-solid fa-arrow-up-right-dots"></i> {{ advancing === rs.id ? 'Advancing…' : `Advance ${(manualPick[rs.id] ?? []).length} selected` }}
404
+ </button>
405
+ </template>
406
+ </div>
355
407
  </div>
356
408
  </section>
357
409
 
@@ -361,8 +413,8 @@ async function transitionStatus(newStatus: string): Promise<void> {
361
413
  <input v-model="showPrizes" type="checkbox" />
362
414
  <span>Show the Prizes tab on the contest page</span>
363
415
  </label>
364
- <p v-if="!showPrizes" class="cpub-form-hint">The Prizes tab is hidden any prizes below are saved but not shown to visitors.</p>
365
- <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>
416
+ <p v-if="!showPrizes" class="cpub-form-hint">The Prizes tab is hidden, any prizes below are saved but not shown to visitors.</p>
417
+ <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>
366
418
  <div class="cpub-form-field">
367
419
  <label class="cpub-form-label">Prizes overview (optional)</label>
368
420
  <textarea v-model="prizesDescription" class="cpub-form-textarea" rows="3" placeholder="Intro shown above the prize cards. Supports Markdown." />
@@ -406,9 +458,9 @@ async function transitionStatus(newStatus: string): Promise<void> {
406
458
  <div class="cpub-form-field">
407
459
  <label class="cpub-form-label">Score Visibility</label>
408
460
  <select v-model="judgingVisibility" class="cpub-form-input">
409
- <option value="judges-only">Judges only scores hidden until results</option>
410
- <option value="public">Public show scores during judging</option>
411
- <option value="private">Private scores stay with organizers</option>
461
+ <option value="judges-only">Judges only, scores hidden until results</option>
462
+ <option value="public">Public, show scores during judging</option>
463
+ <option value="private">Private, scores stay with organizers</option>
412
464
  </select>
413
465
  </div>
414
466
  <label class="cpub-form-check">
@@ -444,9 +496,9 @@ async function transitionStatus(newStatus: string): Promise<void> {
444
496
  <div class="cpub-form-field">
445
497
  <label class="cpub-form-label">Who can see this contest</label>
446
498
  <select v-model="visibility" class="cpub-form-input">
447
- <option value="public">Public listed and visible to everyone</option>
448
- <option value="unlisted">Unlisted visible by direct link, hidden from listings</option>
449
- <option value="private">Private restricted</option>
499
+ <option value="public">Public, listed and visible to everyone</option>
500
+ <option value="unlisted">Unlisted, visible by direct link, hidden from listings</option>
501
+ <option value="private">Private, restricted</option>
450
502
  </select>
451
503
  </div>
452
504
  <div v-if="visibility === 'private'" class="cpub-form-field">
@@ -461,7 +513,7 @@ async function transitionStatus(newStatus: string): Promise<void> {
461
513
  <div class="cpub-subhead">
462
514
  <h3 class="cpub-form-subtitle">Reviewers</h3>
463
515
  </div>
464
- <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>
516
+ <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>
465
517
  <ContestStakeholderManager :contest-slug="slug" />
466
518
  </section>
467
519
 
@@ -637,13 +689,20 @@ async function transitionStatus(newStatus: string): Promise<void> {
637
689
  border-top: 2px solid var(--border);
638
690
  box-shadow: var(--shadow-lg);
639
691
  }
640
- .cpub-advance-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 10px 0; border-top: var(--border-width-default) solid var(--border); }
641
- .cpub-advance-row:first-of-type { border-top: 0; }
692
+ .cpub-advance-block { padding: 12px 0; border-top: var(--border-width-default) solid var(--border); }
693
+ .cpub-advance-block:first-of-type { border-top: 0; }
694
+ .cpub-advance-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
642
695
  .cpub-advance-name { font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
643
696
  .cpub-advance-name i { color: var(--accent); font-size: 11px; }
644
- .cpub-advance-ctl { display: inline-flex; align-items: center; gap: 8px; }
697
+ .cpub-advance-mode { display: inline-flex; gap: 12px; }
698
+ .cpub-advance-ctl { display: inline-flex; align-items: center; gap: 8px; margin-top: 10px; }
645
699
  .cpub-advance-ctl .cpub-form-label { margin: 0; }
646
700
  .cpub-advance-n { width: 80px; }
701
+ .cpub-advance-manual { margin-top: 10px; display: flex; flex-direction: column; gap: 4px; }
702
+ .cpub-advance-pick { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); padding: 4px 8px; border: var(--border-width-default) solid var(--border); background: var(--surface2); cursor: pointer; }
703
+ .cpub-advance-pick-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
704
+ .cpub-advance-pick-score { font-family: var(--font-mono); font-size: 11px; color: var(--accent); flex-shrink: 0; }
705
+ .cpub-advance-manual .cpub-btn { align-self: flex-start; margin-top: 6px; }
647
706
  .cpub-edit-actionbar-status { font-size: 11px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); display: flex; align-items: center; gap: 8px; }
648
707
  .cpub-edit-actionbar-btns { display: flex; align-items: center; gap: 8px; }
649
708
 
@@ -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'} ${useSiteName()}`,
16
- ogTitle: () => `${contest.value?.title || 'Contest'} ${useSiteName()}`,
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 visible by direct link only, hidden from listings.' };
47
- return { icon: 'fa-lock', text: 'Private visible only to you, reviewers, judges, and allowed roles.' };
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 or start a new one.
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 use “Create a new {{ newProjectType }}” above to start one.
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 or start a new one.</p>
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'} ${useSiteName()}` });
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 refresh to see the updated totals.';
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'} ${useSiteName()}`,
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' }} Results
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 ?? '' }}</div>
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 ?? '' }}</td>
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 ${useSiteName()}` });
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 # headings, - lists, **bold**, [links](url)…" />
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 one rule per line, or full 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);">— 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>
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 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>
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 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>
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 20 pts).</p>
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);">— optional</span></h2>
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 any prizes below are saved but not shown to visitors.</p>
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 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>
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." />
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- useSeoMeta({ title: `Contests ${useSiteName()}` });
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 use as-is */ }
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 ${siteName.value}` });
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 ${useSiteName()}` });
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 articles, tutorials, deep dives, opinion pieces, or personal updates with rich formatting.',
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',
@@ -2,7 +2,7 @@
2
2
  definePageMeta({ middleware: 'auth' });
3
3
 
4
4
  useSeoMeta({
5
- title: `Dashboard ${useSiteName()}`,
5
+ title: `Dashboard, ${useSiteName()}`,
6
6
  description: 'Your personal CommonPub dashboard.',
7
7
  });
8
8
 
@@ -208,7 +208,7 @@ watch(selectedVersion, () => {
208
208
  const sidebarOpen = ref(false);
209
209
 
210
210
  useSeoMeta({
211
- title: () => renderedPage.value ? `${renderedPage.value.title} ${site.value?.name ?? 'Docs'}` : `Docs ${useSiteName()}`,
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>