@commonpub/layer 0.82.0 → 0.83.1

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 (195) hide show
  1. package/components/AppToast.vue +1 -1
  2. package/components/ContentAvatar.vue +98 -0
  3. package/components/CpubCriteriaBar.vue +88 -0
  4. package/components/CpubDateTimeField.vue +73 -0
  5. package/components/CpubMarkdown.vue +3 -1
  6. package/components/FormatToggle.vue +2 -2
  7. package/components/ImageUpload.vue +5 -8
  8. package/components/MirrorDetailModal.vue +3 -1
  9. package/components/MirrorRequestApproveModal.vue +3 -1
  10. package/components/ProductEditModal.vue +184 -0
  11. package/components/RemoteFollowDialog.vue +2 -2
  12. package/components/SearchSidebar.vue +14 -21
  13. package/components/ShareToHubModal.vue +3 -1
  14. package/components/admin/layouts/AdminLayoutsPalette.vue +5 -1
  15. package/components/admin/layouts/AdminLayoutsPaletteTile.vue +7 -1
  16. package/components/admin/layouts/AdminLayoutsToolbar.vue +1 -1
  17. package/components/blocks/BlockCompareColumnsView.vue +92 -0
  18. package/components/blocks/BlockContentRenderer.vue +17 -0
  19. package/components/blocks/BlockCriteriaBarView.vue +25 -0
  20. package/components/blocks/BlockGalleryView.vue +5 -0
  21. package/components/blocks/BlockHtmlView.vue +26 -0
  22. package/components/blocks/BlockImageView.vue +4 -0
  23. package/components/blocks/BlockJudgesShowcaseView.vue +52 -0
  24. package/components/blocks/BlockRoadmapView.vue +84 -0
  25. package/components/blocks/BlockSponsorsView.vue +89 -0
  26. package/components/blocks/BlockTableView.vue +49 -0
  27. package/components/blocks/BlockTabsView.vue +121 -0
  28. package/components/contest/ContestBodyCanvas.vue +155 -0
  29. package/components/contest/ContestCriteriaEditor.vue +79 -0
  30. package/components/contest/ContestEditor.vue +948 -0
  31. package/components/contest/ContestEntries.vue +1 -1
  32. package/components/contest/ContestEntryPrivateData.vue +126 -0
  33. package/components/contest/ContestHero.vue +114 -186
  34. package/components/contest/ContestJudgeManager.vue +6 -4
  35. package/components/contest/ContestJudgingCriteria.vue +5 -21
  36. package/components/contest/ContestPrizes.vue +8 -1
  37. package/components/contest/ContestProposalForm.vue +88 -0
  38. package/components/contest/ContestRules.vue +8 -1
  39. package/components/contest/ContestSidebar.vue +8 -2
  40. package/components/contest/ContestStageSubmission.vue +10 -36
  41. package/components/contest/ContestStagesEditor.vue +141 -65
  42. package/components/contest/ContestStakeholderManager.vue +3 -2
  43. package/components/contest/ContestSubmissionField.vue +141 -0
  44. package/components/contest/blocks/CompareColumnsBlock.vue +127 -0
  45. package/components/contest/blocks/ContestTabPanel.vue +27 -0
  46. package/components/contest/blocks/CriteriaBarBlock.vue +118 -0
  47. package/components/contest/blocks/HtmlBlock.vue +61 -0
  48. package/components/contest/blocks/JudgesShowcaseBlock.vue +96 -0
  49. package/components/contest/blocks/RoadmapBlock.vue +127 -0
  50. package/components/contest/blocks/SponsorsBlock.vue +127 -0
  51. package/components/contest/blocks/TableBlock.vue +101 -0
  52. package/components/contest/blocks/TabsBlock.vue +168 -0
  53. package/components/editors/ArticleEditor.vue +9 -16
  54. package/components/editors/ExplainerEditor.vue +8 -5
  55. package/components/editors/ProjectEditor.vue +13 -10
  56. package/components/homepage/CustomHtmlSection.vue +11 -2
  57. package/components/hub/HubProducts.vue +4 -2
  58. package/components/nav/NavDropdown.vue +1 -5
  59. package/components/nav/NavLink.vue +2 -0
  60. package/components/views/ArticleView.vue +3 -56
  61. package/components/views/ExplainerView.vue +4 -0
  62. package/components/views/ProjectView.vue +83 -245
  63. package/composables/useContestEditor.ts +388 -0
  64. package/composables/useDocsPageTree.ts +154 -0
  65. package/composables/useDocsSiteSettings.ts +107 -0
  66. package/composables/useEditorAutosave.ts +131 -0
  67. package/composables/useEngagement.ts +13 -6
  68. package/composables/useFeatures.ts +9 -1
  69. package/composables/useFileUpload.ts +60 -0
  70. package/composables/useProfileContent.ts +84 -0
  71. package/composables/useSanitize.ts +38 -4
  72. package/composables/useScrollSpy.ts +87 -0
  73. package/layouts/admin.vue +41 -19
  74. package/layouts/default.vue +18 -9
  75. package/nuxt.config.ts +13 -0
  76. package/package.json +9 -9
  77. package/pages/[type]/index.vue +6 -1
  78. package/pages/admin/api-keys.vue +13 -3
  79. package/pages/admin/features.vue +2 -0
  80. package/pages/admin/federation.vue +1 -1
  81. package/pages/admin/layouts/[id].vue +30 -2
  82. package/pages/admin/settings.vue +2 -1
  83. package/pages/admin/users.vue +1 -1
  84. package/pages/admin/video-categories.vue +203 -0
  85. package/pages/cert/[code].vue +6 -2
  86. package/pages/contests/[slug]/edit.vue +4 -769
  87. package/pages/contests/[slug]/entries/[entryId].vue +34 -1
  88. package/pages/contests/[slug]/index.vue +93 -7
  89. package/pages/contests/[slug]/judge.vue +49 -26
  90. package/pages/contests/create.vue +5 -466
  91. package/pages/contests/index.vue +7 -2
  92. package/pages/cookies.vue +1 -1
  93. package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
  94. package/pages/docs/[siteSlug]/edit.vue +93 -231
  95. package/pages/events/[slug]/edit.vue +20 -20
  96. package/pages/events/create.vue +18 -18
  97. package/pages/events/index.vue +7 -2
  98. package/pages/hubs/[slug]/index.vue +34 -9
  99. package/pages/hubs/[slug]/invites.vue +312 -0
  100. package/pages/hubs/[slug]/members.vue +128 -0
  101. package/pages/hubs/[slug]/posts/[postId].vue +2 -2
  102. package/pages/hubs/index.vue +6 -1
  103. package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
  104. package/pages/learn/index.vue +8 -1
  105. package/pages/messages/index.vue +1 -1
  106. package/pages/mirror/[id].vue +1 -1
  107. package/pages/products/[slug].vue +55 -2
  108. package/pages/products/index.vue +6 -1
  109. package/pages/settings/account.vue +8 -8
  110. package/pages/settings/profile.vue +23 -14
  111. package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
  112. package/pages/u/[username]/followers.vue +11 -3
  113. package/pages/u/[username]/following.vue +10 -8
  114. package/pages/u/[username]/index.vue +73 -7
  115. package/pages/videos/index.vue +13 -10
  116. package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
  117. package/server/api/admin/api-keys/[id].delete.ts +2 -2
  118. package/server/api/admin/api-keys/index.get.ts +1 -0
  119. package/server/api/admin/api-keys/index.post.ts +1 -0
  120. package/server/api/admin/federation/refederate.post.ts +18 -1
  121. package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
  122. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
  123. package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
  124. package/server/api/admin/layouts/[id].delete.ts +1 -4
  125. package/server/api/admin/layouts/[id].get.ts +1 -4
  126. package/server/api/admin/layouts/[id].put.ts +1 -4
  127. package/server/api/auth/federated/login.post.ts +12 -5
  128. package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
  129. package/server/api/content/[id]/build.get.ts +11 -0
  130. package/server/api/content/[id]/report.post.ts +2 -0
  131. package/server/api/content/[id]/versions.get.ts +15 -0
  132. package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
  133. package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
  134. package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
  135. package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
  136. package/server/api/contests/[slug]/export.get.ts +43 -0
  137. package/server/api/contests/[slug]/judge.post.ts +8 -2
  138. package/server/api/contests/[slug]/proposal.post.ts +36 -0
  139. package/server/api/contests/[slug]/user-search.get.ts +30 -0
  140. package/server/api/contests/index.post.ts +1 -1
  141. package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
  142. package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
  143. package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
  144. package/server/api/docs/[siteSlug]/search.get.ts +7 -1
  145. package/server/api/events/[slug]/attendees.get.ts +10 -0
  146. package/server/api/events/[slug].get.ts +9 -0
  147. package/server/api/events/index.get.ts +8 -1
  148. package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
  149. package/server/api/federation/content/[id]/build.get.ts +10 -0
  150. package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
  151. package/server/api/hubs/[slug]/invites.get.ts +5 -3
  152. package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
  153. package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
  154. package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
  155. package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
  156. package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
  157. package/server/api/hubs/[slug]/requests.get.ts +20 -0
  158. package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
  159. package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
  160. package/server/api/products/[id].delete.ts +22 -2
  161. package/server/api/registry/ping.post.ts +17 -3
  162. package/server/api/search/index.get.ts +5 -3
  163. package/server/api/social/bookmark.get.ts +1 -0
  164. package/server/api/social/bookmark.post.ts +1 -0
  165. package/server/api/social/bookmarks.get.ts +1 -0
  166. package/server/api/social/comments/[id].delete.ts +1 -0
  167. package/server/api/social/comments.get.ts +1 -0
  168. package/server/api/social/comments.post.ts +1 -0
  169. package/server/api/social/like.get.ts +1 -0
  170. package/server/api/social/like.post.ts +1 -0
  171. package/server/api/users/[username]/content.get.ts +15 -3
  172. package/server/api/users/[username]/follow.delete.ts +1 -0
  173. package/server/api/users/[username]/follow.post.ts +1 -0
  174. package/server/api/users/[username]/followers.get.ts +2 -1
  175. package/server/api/users/[username]/following.get.ts +2 -1
  176. package/server/middleware/content-ap.ts +8 -3
  177. package/server/middleware/csrf.ts +93 -0
  178. package/server/plugins/federation-hub-sync.ts +48 -17
  179. package/server/plugins/notification-email.ts +22 -3
  180. package/server/routes/hubs/[slug]/inbox.ts +13 -1
  181. package/server/routes/inbox.ts +14 -1
  182. package/server/routes/users/[username]/inbox.ts +13 -1
  183. package/server/utils/inbox.ts +7 -2
  184. package/server/utils/validate.ts +22 -0
  185. package/theme/base.css +5 -0
  186. package/theme/prose.css +20 -0
  187. package/theme/stoa-dark.css +4 -0
  188. package/types/contestBlocks.ts +122 -0
  189. package/utils/contestBlocks.ts +107 -0
  190. package/utils/contestBody.ts +25 -0
  191. package/utils/contestStages.ts +62 -0
  192. package/utils/contestSubmission.ts +97 -0
  193. package/utils/datetime.ts +45 -0
  194. package/utils/projectBlocks.ts +162 -0
  195. package/components/editors/BlogEditor.vue +0 -648
@@ -4,6 +4,8 @@ defineProps<{
4
4
  prizes: Prize[];
5
5
  /** Optional intro shown above the prize cards (section-level, not per-prize). */
6
6
  description?: string | null;
7
+ /** Block-editor intro (BlockTuple[]); rendered instead of `description` when present. */
8
+ blocks?: unknown[] | null;
7
9
  format?: 'markdown' | 'html' | null;
8
10
  }>();
9
11
 
@@ -38,7 +40,12 @@ function prizeIcon(prize: Prize): string {
38
40
  <div class="cpub-sec-head">
39
41
  <h2><i class="fa fa-trophy" style="color: var(--yellow);"></i> Prizes</h2>
40
42
  </div>
41
- <CpubMarkdown v-if="description" :source="description" :format="format" class="cpub-prizes-intro" />
43
+ <BlocksBlockContentRenderer
44
+ v-if="blocks?.length"
45
+ :blocks="(blocks as [string, Record<string, unknown>][])"
46
+ class="cpub-prose cpub-md cpub-prizes-intro"
47
+ />
48
+ <CpubMarkdown v-else-if="description" :source="description" :format="format" class="cpub-prizes-intro" />
42
49
  <div v-if="prizes.length" class="cpub-prize-grid">
43
50
  <div
44
51
  v-for="(prize, i) in prizes"
@@ -0,0 +1,88 @@
1
+ <script setup lang="ts">
2
+ import type { ContestStage } from '@commonpub/schema';
3
+ import { blockingFields, buildSubmissionPayload } from '../../utils/contestSubmission';
4
+
5
+ // Form-first proposal entry (Phase 4). For a current, proposal-mode submission
6
+ // stage: the entrant fills the stage form and the server creates a DRAFT
7
+ // placeholder project linked as their entry, then routes them into the editor.
8
+ // Gated by features.contestProposals at the route; shown by the parent page only
9
+ // when the current stage is proposal-mode.
10
+
11
+ const props = defineProps<{
12
+ contestSlug: string;
13
+ /** The current proposal-mode submission stage (carries the template). */
14
+ stage: ContestStage;
15
+ }>();
16
+
17
+ const emit = defineEmits<{ (e: 'submitted', projectSlug: string, contentType: string): void }>();
18
+
19
+ const toast = useToast();
20
+ const { extract: extractError } = useApiError();
21
+
22
+ const template = computed(() => props.stage.submissionTemplate ?? []);
23
+ const values = ref<Record<string, string>>({});
24
+ watch(template, (t) => {
25
+ const next: Record<string, string> = {};
26
+ for (const f of t) next[f.key] = '';
27
+ values.value = next;
28
+ }, { immediate: true });
29
+
30
+ const missingRequired = computed(() => blockingFields(template.value, values.value));
31
+ const submitting = ref(false);
32
+
33
+ async function submit(): Promise<void> {
34
+ if (submitting.value) return;
35
+ if (missingRequired.value.length) {
36
+ toast.error(`Please complete: ${missingRequired.value.join(', ')}`);
37
+ return;
38
+ }
39
+ submitting.value = true;
40
+ try {
41
+ const fields = buildSubmissionPayload(template.value, values.value);
42
+ const res = await $fetch<{ entryId: string; projectSlug: string; contentType: string }>(
43
+ `/api/contests/${props.contestSlug}/proposal`,
44
+ { method: 'POST', body: { stageId: props.stage.id, fields } },
45
+ );
46
+ toast.success('Proposal submitted. Continue building your project for the next round.');
47
+ emit('submitted', res.projectSlug, res.contentType);
48
+ } catch (err: unknown) {
49
+ toast.error(extractError(err));
50
+ } finally {
51
+ submitting.value = false;
52
+ }
53
+ }
54
+ </script>
55
+
56
+ <template>
57
+ <section v-if="template.length" class="cpub-proposal" :aria-label="`${stage.name} proposal form`">
58
+ <div class="cpub-proposal-head">
59
+ <h3 class="cpub-proposal-title"><i class="fa-solid fa-clipboard-list"></i> {{ stage.name }}: submit a proposal</h3>
60
+ </div>
61
+ <p v-if="stage.description" class="cpub-proposal-desc">{{ stage.description }}</p>
62
+ <p class="cpub-proposal-desc">Submitting creates a draft project you can develop for later rounds. You can edit it any time.</p>
63
+
64
+ <ContestSubmissionField
65
+ v-for="f in template"
66
+ :key="f.key"
67
+ :field="f"
68
+ v-model="values[f.key]"
69
+ id-prefix="cpub-proposal"
70
+ />
71
+
72
+ <div class="cpub-proposal-actions">
73
+ <button type="button" class="cpub-btn cpub-btn-primary" :disabled="submitting" @click="submit">
74
+ <i class="fa-solid fa-paper-plane"></i>
75
+ {{ submitting ? 'Submitting...' : 'Submit proposal' }}
76
+ </button>
77
+ </div>
78
+ </section>
79
+ </template>
80
+
81
+ <style scoped>
82
+ .cpub-proposal { border: var(--border-width-default) solid var(--accent-border); background: var(--accent-bg); padding: 16px 20px; margin-bottom: 18px; }
83
+ .cpub-proposal-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
84
+ .cpub-proposal-title { font-size: 14px; font-weight: 700; display: flex; align-items: center; gap: 8px; margin: 0; }
85
+ .cpub-proposal-title i { color: var(--accent); }
86
+ .cpub-proposal-desc { font-size: 12px; color: var(--text-dim); margin: 0 0 12px; line-height: 1.6; }
87
+ .cpub-proposal-actions { display: flex; align-items: center; gap: 10px; }
88
+ </style>
@@ -8,6 +8,8 @@
8
8
  */
9
9
  defineProps<{
10
10
  rules: string;
11
+ /** Block-editor body (BlockTuple[]); rendered instead of `rules` when present. */
12
+ blocks?: unknown[] | null;
11
13
  format?: 'markdown' | 'html' | null;
12
14
  }>();
13
15
  </script>
@@ -18,7 +20,12 @@ defineProps<{
18
20
  <h2><i class="fa fa-file-lines" style="color: var(--purple);"></i> Rules</h2>
19
21
  </div>
20
22
  <div class="cpub-rules-card">
21
- <CpubMarkdown :source="rules" :format="format" />
23
+ <BlocksBlockContentRenderer
24
+ v-if="blocks?.length"
25
+ :blocks="(blocks as [string, Record<string, unknown>][])"
26
+ class="cpub-prose cpub-md"
27
+ />
28
+ <CpubMarkdown v-else :source="rules" :format="format" />
22
29
  </div>
23
30
  </div>
24
31
  </template>
@@ -17,9 +17,15 @@ const emit = defineEmits<{
17
17
  type StepState = 'done' | 'current' | 'upcoming';
18
18
  interface TimelineStep { label: string; date: string | null; state: StepState; icon: string }
19
19
 
20
+ // toLocaleDateString is timezone-dependent, so it would mismatch between the
21
+ // server's TZ and the viewer's on hydration. Gate it on a client `mounted` flag so
22
+ // the timeline dates only render (in the viewer's local TZ) after mount.
23
+ const mounted = ref(false);
24
+ onMounted(() => { mounted.value = true; });
25
+
20
26
  function fmt(d: string | null | undefined): string | null {
21
- if (!d) return null;
22
- return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
27
+ if (!d || !mounted.value) return null;
28
+ return formatLocalDate(d);
23
29
  }
24
30
 
25
31
  // Phase B1 — the timeline renders the contest's stages (its explicit `stages`, or
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { ContestStage, ContestStageSubmission } from '@commonpub/schema';
3
+ import { blockingFields, buildSubmissionPayload } from '../../utils/contestSubmission';
3
4
 
4
5
  // Per-stage artifact form: an entrant with an entry fills the CURRENT
5
6
  // submission stage's template fields (a proposal, a prototype's links, ...).
@@ -51,9 +52,7 @@ watch([existing, template], () => {
51
52
  const dirty = computed(() =>
52
53
  template.value.some((f) => (values.value[f.key] ?? '') !== (existing.value?.fields[f.key] ?? '')),
53
54
  );
54
- const missingRequired = computed(() =>
55
- template.value.filter((f) => f.required && !(values.value[f.key] ?? '').trim()).map((f) => f.label),
56
- );
55
+ const missingRequired = computed(() => blockingFields(template.value, values.value));
57
56
 
58
57
  const saving = ref(false);
59
58
  async function save(): Promise<void> {
@@ -64,11 +63,7 @@ async function save(): Promise<void> {
64
63
  }
65
64
  saving.value = true;
66
65
  try {
67
- const fields: Record<string, string> = {};
68
- for (const f of template.value) {
69
- const v = (values.value[f.key] ?? '').trim();
70
- if (v) fields[f.key] = v;
71
- }
66
+ const fields = buildSubmissionPayload(template.value, values.value);
72
67
  await $fetch(`/api/contests/${props.contestSlug}/entries/${selectedEntryId.value}/submission`, {
73
68
  method: 'PUT',
74
69
  body: { stageId: props.stage.id, fields },
@@ -107,34 +102,13 @@ function submittedAtLabel(iso: string): string {
107
102
  </select>
108
103
  </div>
109
104
 
110
- <div v-for="f in template" :key="f.key" class="cpub-stagesub-field">
111
- <label class="cpub-stagesub-label" :for="`cpub-stagesub-${f.key}`">
112
- {{ f.label }} <span v-if="f.required" class="cpub-stagesub-req" aria-hidden="true">*</span>
113
- <span v-if="f.required" class="cpub-sr-only">(required)</span>
114
- </label>
115
- <textarea
116
- v-if="f.type === 'textarea'"
117
- :id="`cpub-stagesub-${f.key}`"
118
- v-model="values[f.key]"
119
- class="cpub-stagesub-input cpub-stagesub-textarea"
120
- rows="4"
121
- maxlength="4000"
122
- :required="f.required"
123
- :aria-describedby="f.help ? `cpub-stagesub-${f.key}-help` : undefined"
124
- ></textarea>
125
- <input
126
- v-else
127
- :id="`cpub-stagesub-${f.key}`"
128
- v-model="values[f.key]"
129
- :type="f.type === 'url' ? 'url' : 'text'"
130
- class="cpub-stagesub-input"
131
- maxlength="4000"
132
- :placeholder="f.type === 'url' ? 'https://' : undefined"
133
- :required="f.required"
134
- :aria-describedby="f.help ? `cpub-stagesub-${f.key}-help` : undefined"
135
- />
136
- <p v-if="f.help" :id="`cpub-stagesub-${f.key}-help`" class="cpub-stagesub-help">{{ f.help }}</p>
137
- </div>
105
+ <ContestSubmissionField
106
+ v-for="f in template"
107
+ :key="f.key"
108
+ :field="f"
109
+ v-model="values[f.key]"
110
+ id-prefix="cpub-stagesub"
111
+ />
138
112
 
139
113
  <div class="cpub-stagesub-actions">
140
114
  <button
@@ -20,17 +20,7 @@ const props = defineProps<{
20
20
 
21
21
  const KINDS: ContestStage['kind'][] = ['submission', 'review', 'interim', 'results', 'event', 'custom'];
22
22
 
23
- // datetime-local <-> ISO (mirrors the rest of the contest forms' convention).
24
- function toLocal(iso?: string): string {
25
- if (!iso) return '';
26
- try { return new Date(iso).toISOString().slice(0, 16); } catch { return ''; }
27
- }
28
- function toIso(local: string): string | undefined {
29
- if (!local) return undefined;
30
- const d = new Date(local);
31
- return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
32
- }
33
-
23
+ // datetime-local <-> ISO via the shared, offset-correct helpers (utils/datetime).
34
24
  function commit(next: ContestStage[]): void {
35
25
  stages.value = next;
36
26
  }
@@ -40,29 +30,8 @@ function setField(i: number, patch: Partial<ContestStage>): void {
40
30
  commit(next);
41
31
  }
42
32
 
43
- function onDate(i: number, field: 'startsAt' | 'endsAt', e: Event): void {
44
- const v = toIso((e.target as HTMLInputElement).value);
45
- setField(i, { [field]: v } as Partial<ContestStage>);
46
- }
47
-
48
- // Per-round rubric (review stages). Immutable updates via setField.
49
- type StageCriterion = { label: string; weight?: number; description?: string };
50
- function addCriterion(i: number): void {
51
- const cur = (stages.value[i]?.criteria ?? []) as StageCriterion[];
52
- setField(i, { criteria: [...cur, { label: '' }] });
53
- }
54
- function setCriterion(i: number, ci: number, patch: Partial<StageCriterion>): void {
55
- const cur = (stages.value[i]?.criteria ?? []).map((c, idx) => (idx === ci ? { ...c, ...patch } : c));
56
- setField(i, { criteria: cur });
57
- }
58
- function removeCriterion(i: number, ci: number): void {
59
- const cur = (stages.value[i]?.criteria ?? []).filter((_, idx) => idx !== ci);
60
- setField(i, { criteria: cur.length ? cur : undefined });
61
- }
62
- function critWeightInput(i: number, ci: number, e: Event): void {
63
- const v = (e.target as HTMLInputElement).value;
64
- setCriterion(i, ci, { weight: v === '' ? undefined : Math.max(0, Math.min(100, Math.round(Number(v)))) });
65
- }
33
+ // Per-round rubric (review stages) is edited by the shared ContestCriteriaEditor
34
+ // (same component as the contest-level Judging tab — one rubric editor, no dup).
66
35
  function advanceCountInput(i: number, e: Event): void {
67
36
  const v = (e.target as HTMLInputElement).value;
68
37
  setField(i, { advanceCount: v === '' ? undefined : Math.max(1, Math.round(Number(v))) });
@@ -73,7 +42,15 @@ function advanceCountInput(i: number, e: Event): void {
73
42
  // functions in utils/contestStages.ts (unit-tested). Flag-gated (rule #2).
74
43
  const { features } = useFeatures();
75
44
  const templatesEnabled = computed(() => features.value.contestStageSubmissions !== false);
76
- const FIELD_TYPES: ContestSubmissionTemplateField['type'][] = ['text', 'textarea', 'url'];
45
+ // Phase 4: the PII flag offers the agreement/address types + the per-field PII
46
+ // toggle; the proposals flag offers per-stage submission mode (attach vs proposal).
47
+ const piiEnabled = computed(() => features.value.contestPii === true);
48
+ const proposalsEnabled = computed(() => features.value.contestProposals === true);
49
+ const FIELD_TYPES = computed<ContestSubmissionTemplateField['type'][]>(() => {
50
+ const base: ContestSubmissionTemplateField['type'][] = ['text', 'textarea', 'url', 'email', 'number', 'select', 'checkbox', 'date'];
51
+ if (piiEnabled.value) base.push('agreement', 'address');
52
+ return base;
53
+ });
77
54
 
78
55
  function addTemplateField(i: number): void {
79
56
  commit(withTemplateFieldAdded(stages.value, i));
@@ -81,12 +58,25 @@ function addTemplateField(i: number): void {
81
58
  function setTemplateField(i: number, fi: number, patch: Partial<ContestSubmissionTemplateField>): void {
82
59
  commit(withTemplateFieldSet(stages.value, i, fi, patch));
83
60
  }
61
+ function changeTemplateFieldType(i: number, fi: number, type: ContestSubmissionTemplateField['type']): void {
62
+ commit(withTemplateFieldTypeChanged(stages.value, i, fi, type));
63
+ }
84
64
  function templateFieldLabelInput(i: number, fi: number, e: Event): void {
85
65
  commit(withTemplateFieldLabelChanged(stages.value, i, fi, (e.target as HTMLInputElement).value));
86
66
  }
87
67
  function removeTemplateField(i: number, fi: number): void {
88
68
  commit(withTemplateFieldRemoved(stages.value, i, fi));
89
69
  }
70
+ // Select-option ops (pure helpers in utils/contestStages.ts).
71
+ function addOption(i: number, fi: number): void {
72
+ commit(withTemplateOptionAdded(stages.value, i, fi));
73
+ }
74
+ function setOption(i: number, fi: number, oi: number, patch: Partial<{ value: string; label: string }>): void {
75
+ commit(withTemplateOptionSet(stages.value, i, fi, oi, patch));
76
+ }
77
+ function removeOption(i: number, fi: number, oi: number): void {
78
+ commit(withTemplateOptionRemoved(stages.value, i, fi, oi));
79
+ }
90
80
 
91
81
  // Array operations live as pure functions in utils/contestStages.ts (unit-tested).
92
82
  function addStage(): void {
@@ -169,8 +159,9 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
169
159
 
170
160
  <div class="cpub-form-row">
171
161
  <div class="cpub-form-field" style="flex: 2;">
172
- <label class="cpub-form-label">Stage name</label>
162
+ <label :for="`stage-name-${i}`" class="cpub-form-label">Stage name</label>
173
163
  <input
164
+ :id="`stage-name-${i}`"
174
165
  :value="stage.name"
175
166
  type="text"
176
167
  class="cpub-form-input"
@@ -179,8 +170,9 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
179
170
  />
180
171
  </div>
181
172
  <div class="cpub-form-field" style="flex: 1;">
182
- <label class="cpub-form-label">Type</label>
173
+ <label :for="`stage-type-${i}`" class="cpub-form-label">Type</label>
183
174
  <select
175
+ :id="`stage-type-${i}`"
184
176
  :value="stage.kind"
185
177
  class="cpub-form-input"
186
178
  @change="setField(i, { kind: ($event.target as HTMLSelectElement).value as ContestStage['kind'] })"
@@ -193,19 +185,24 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
193
185
  <p class="cpub-stage-kind-help"><i class="fa-solid fa-circle-info"></i> {{ STAGE_KIND_HELP[stage.kind] }}</p>
194
186
 
195
187
  <div class="cpub-form-row">
196
- <div class="cpub-form-field">
197
- <label class="cpub-form-label">Starts</label>
198
- <input type="datetime-local" class="cpub-form-input" :value="toLocal(stage.startsAt)" @input="onDate(i, 'startsAt', $event)" />
199
- </div>
200
- <div class="cpub-form-field">
201
- <label class="cpub-form-label">Ends (countdown target)</label>
202
- <input type="datetime-local" class="cpub-form-input" :value="toLocal(stage.endsAt)" @input="onDate(i, 'endsAt', $event)" />
203
- </div>
188
+ <CpubDateTimeField
189
+ label="Starts"
190
+ :model-value="stage.startsAt"
191
+ :max="stage.endsAt"
192
+ @update:model-value="setField(i, { startsAt: $event })"
193
+ />
194
+ <CpubDateTimeField
195
+ label="Ends (countdown target)"
196
+ :model-value="stage.endsAt"
197
+ :min="stage.startsAt"
198
+ @update:model-value="setField(i, { endsAt: $event })"
199
+ />
204
200
  </div>
205
201
 
206
202
  <div class="cpub-form-field">
207
- <label class="cpub-form-label">Description (optional)</label>
203
+ <label :for="`stage-desc-${i}`" class="cpub-form-label">Description (optional)</label>
208
204
  <input
205
+ :id="`stage-desc-${i}`"
209
206
  :value="stage.description ?? ''"
210
207
  type="text"
211
208
  class="cpub-form-input"
@@ -217,19 +214,32 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
217
214
  <!-- Per-round config (review stages): how many advance + the rubric -->
218
215
  <div v-if="stage.kind === 'review'" class="cpub-stage-criteria">
219
216
  <div class="cpub-form-field" style="margin-bottom: 10px;">
220
- <label class="cpub-form-label">Advance the top N to the next stage</label>
221
- <input :value="stage.advanceCount ?? ''" type="number" min="1" class="cpub-form-input cpub-stage-advn" placeholder="e.g. 50, leave blank to decide at advance time" @input="advanceCountInput(i, $event)" />
222
- </div>
223
- <div class="cpub-stage-criteria-head">
224
- <span class="cpub-form-label" style="margin: 0;">Judging criteria, this round</span>
225
- <button type="button" class="cpub-btn cpub-btn-sm" @click="addCriterion(i)"><i class="fa-solid fa-plus"></i> Add</button>
217
+ <label :for="`stage-advance-${i}`" class="cpub-form-label">Advance the top N to the next stage</label>
218
+ <input :id="`stage-advance-${i}`" :value="stage.advanceCount ?? ''" type="number" min="1" class="cpub-form-input cpub-stage-advn" placeholder="e.g. 50, leave blank to decide at advance time" @input="advanceCountInput(i, $event)" />
226
219
  </div>
227
220
  <p class="cpub-form-hint" style="margin: 4px 0;">Optional, leave empty to use the contest’s default criteria. Set per-round criteria for multi-round contests (e.g. judge proposals on Feasibility, prototypes on Deployment readiness).</p>
228
- <div v-for="(crit, ci) in (stage.criteria ?? [])" :key="ci" class="cpub-stage-crit-row">
229
- <input :value="crit.label" type="text" class="cpub-form-input" placeholder="Criterion (e.g. Community impact)" @input="setCriterion(i, ci, { label: ($event.target as HTMLInputElement).value })" />
230
- <input :value="crit.weight ?? ''" type="number" min="0" max="100" class="cpub-form-input cpub-stage-crit-pts" placeholder="pts" @input="critWeightInput(i, ci, $event)" />
231
- <button type="button" class="cpub-stage-iconbtn cpub-stage-del" aria-label="Remove criterion" @click="removeCriterion(i, ci)"><i class="fa-solid fa-xmark"></i></button>
232
- </div>
221
+ <ContestCriteriaEditor
222
+ :model-value="(stage.criteria ?? [])"
223
+ label="Judging criteria, this round"
224
+ :show-total="false"
225
+ @update:model-value="setField(i, { criteria: ($event as ContestStage['criteria']) })"
226
+ />
227
+ </div>
228
+
229
+ <!-- Submission mode (Phase 4): attach an existing project, or collect a
230
+ form-first proposal that seeds a draft placeholder project. -->
231
+ <div v-if="stage.kind === 'submission' && proposalsEnabled" class="cpub-form-field">
232
+ <label :for="`stage-mode-${i}`" class="cpub-form-label">How entrants submit</label>
233
+ <select
234
+ :id="`stage-mode-${i}`"
235
+ :value="stage.submissionMode ?? 'attach'"
236
+ class="cpub-form-input"
237
+ @change="setField(i, { submissionMode: (($event.target as HTMLSelectElement).value as 'attach' | 'proposal') })"
238
+ >
239
+ <option value="attach">Attach an existing published project</option>
240
+ <option value="proposal">Proposal form (creates a draft project)</option>
241
+ </select>
242
+ <p class="cpub-form-hint" style="margin: 4px 0;">Proposal mode lets entrants apply with just this form. The server creates a draft project they develop for later rounds.</p>
233
243
  </div>
234
244
 
235
245
  <!-- Per-stage submission template (submission stages): the artifact
@@ -254,7 +264,7 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
254
264
  :value="tf.type"
255
265
  class="cpub-form-input cpub-stage-tfield-type"
256
266
  :aria-label="`Field ${fi + 1} type`"
257
- @change="setTemplateField(i, fi, { type: ($event.target as HTMLSelectElement).value as ContestSubmissionTemplateField['type'] })"
267
+ @change="changeTemplateFieldType(i, fi, ($event.target as HTMLSelectElement).value as ContestSubmissionTemplateField['type'])"
258
268
  >
259
269
  <option v-for="t in FIELD_TYPES" :key="t" :value="t">{{ TEMPLATE_FIELD_TYPE_LABEL[t] }}</option>
260
270
  </select>
@@ -277,17 +287,82 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
277
287
  :aria-label="`Field ${fi + 1} hint`"
278
288
  @input="setTemplateField(i, fi, { help: ($event.target as HTMLInputElement).value || undefined })"
279
289
  />
290
+
291
+ <!-- select: the allowed options -->
292
+ <div v-if="tf.type === 'select'" class="cpub-stage-tfield-extra">
293
+ <span class="cpub-form-hint" style="margin: 0;">Choices</span>
294
+ <div v-for="(opt, oi) in (tf.options ?? [])" :key="oi" class="cpub-stage-opt-row">
295
+ <input
296
+ :value="opt.label"
297
+ type="text"
298
+ class="cpub-form-input"
299
+ placeholder="Label (shown to entrants)"
300
+ :aria-label="`Field ${fi + 1} option ${oi + 1} label`"
301
+ @input="setOption(i, fi, oi, { label: ($event.target as HTMLInputElement).value })"
302
+ />
303
+ <input
304
+ :value="opt.value"
305
+ type="text"
306
+ class="cpub-form-input"
307
+ placeholder="Value (stored)"
308
+ :aria-label="`Field ${fi + 1} option ${oi + 1} value`"
309
+ @input="setOption(i, fi, oi, { value: ($event.target as HTMLInputElement).value })"
310
+ />
311
+ <button type="button" class="cpub-stage-iconbtn cpub-stage-del" aria-label="Remove option" @click="removeOption(i, fi, oi)"><i class="fa-solid fa-xmark"></i></button>
312
+ </div>
313
+ <button type="button" class="cpub-btn cpub-btn-sm" @click="addOption(i, fi)"><i class="fa-solid fa-plus"></i> Add choice</button>
314
+ </div>
315
+
316
+ <!-- agreement: terms the entrant must accept -->
317
+ <div v-if="tf.type === 'agreement'" class="cpub-stage-tfield-extra">
318
+ <textarea
319
+ :value="tf.terms ?? ''"
320
+ class="cpub-form-input cpub-form-textarea"
321
+ rows="3"
322
+ placeholder="Terms the entrant must accept (e.g. shipping the hardware to winners)"
323
+ :aria-label="`Field ${fi + 1} agreement terms`"
324
+ @input="setTemplateField(i, fi, { terms: ($event.target as HTMLTextAreaElement).value || undefined })"
325
+ ></textarea>
326
+ <label class="cpub-stage-tfield-req">
327
+ <input
328
+ type="checkbox"
329
+ :checked="tf.mustAccept !== false"
330
+ :aria-label="`Field ${fi + 1} must accept`"
331
+ @change="setTemplateField(i, fi, { mustAccept: ($event.target as HTMLInputElement).checked })"
332
+ />
333
+ <span>Must accept to submit</span>
334
+ </label>
335
+ </div>
336
+
337
+ <!-- address: structured + always personal data -->
338
+ <p v-if="tf.type === 'address'" class="cpub-form-hint" style="margin: 4px 0;">
339
+ Collected as a structured mailing address and stored as personal data. Visible only to staff with PII access and the entrant.
340
+ </p>
341
+
342
+ <!-- PII toggle (non-address, non-agreement scalar fields) -->
343
+ <label
344
+ v-if="piiEnabled && tf.type !== 'address' && tf.type !== 'agreement'"
345
+ class="cpub-stage-tfield-req cpub-stage-tfield-pii"
346
+ >
347
+ <input
348
+ type="checkbox"
349
+ :checked="tf.pii === true"
350
+ :aria-label="`Field ${fi + 1} is personal data`"
351
+ @change="setTemplateField(i, fi, { pii: ($event.target as HTMLInputElement).checked || undefined })"
352
+ />
353
+ <span>Personal data (store privately, hide from the public listing)</span>
354
+ </label>
280
355
  </div>
281
356
  </div>
282
357
 
283
358
  <div v-if="stage.kind === 'event'" class="cpub-form-row">
284
359
  <div class="cpub-form-field">
285
- <label class="cpub-form-label">Location</label>
286
- <input :value="stage.location ?? ''" type="text" class="cpub-form-input" placeholder="e.g. Washington, D.C." @input="setField(i, { location: ($event.target as HTMLInputElement).value || undefined })" />
360
+ <label :for="`stage-location-${i}`" class="cpub-form-label">Location</label>
361
+ <input :id="`stage-location-${i}`" :value="stage.location ?? ''" type="text" class="cpub-form-input" placeholder="e.g. Washington, D.C." @input="setField(i, { location: ($event.target as HTMLInputElement).value || undefined })" />
287
362
  </div>
288
363
  <div class="cpub-form-field">
289
- <label class="cpub-form-label">Link</label>
290
- <input :value="stage.url ?? ''" type="url" class="cpub-form-input" placeholder="https://…" @input="setField(i, { url: ($event.target as HTMLInputElement).value || undefined })" />
364
+ <label :for="`stage-url-${i}`" class="cpub-form-label">Link</label>
365
+ <input :id="`stage-url-${i}`" :value="stage.url ?? ''" type="url" class="cpub-form-input" placeholder="https://…" @input="setField(i, { url: ($event.target as HTMLInputElement).value || undefined })" />
291
366
  </div>
292
367
  </div>
293
368
  </li>
@@ -331,9 +406,6 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
331
406
  .cpub-stage-kind-help i { color: var(--accent); margin-top: 2px; flex-shrink: 0; }
332
407
  .cpub-stage-criteria { border: var(--border-width-default) dashed var(--border2); padding: 10px; margin-top: 4px; background: var(--surface); }
333
408
  .cpub-stage-criteria-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
334
- .cpub-stage-crit-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
335
- .cpub-stage-crit-row .cpub-form-input { margin: 0; }
336
- .cpub-stage-crit-pts { max-width: 70px; flex-shrink: 0; }
337
409
  .cpub-stage-advn { max-width: 320px; }
338
410
  .cpub-stage-tfield { margin-top: 8px; padding-top: 8px; border-top: var(--border-width-default) dashed var(--border2); }
339
411
  .cpub-stage-tfield:first-of-type { border-top: 0; padding-top: 0; }
@@ -343,4 +415,8 @@ const missingSubmission = computed(() => stages.value.length > 0 && !stages.valu
343
415
  .cpub-stage-tfield-req { display: inline-flex; align-items: center; gap: 5px; font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); cursor: pointer; flex-shrink: 0; }
344
416
  .cpub-stage-tfield-req input { width: 13px; height: 13px; }
345
417
  .cpub-stage-tfield-help { margin-top: 6px !important; font-size: var(--text-xs) !important; }
418
+ .cpub-stage-tfield-extra { margin-top: 6px; padding: 8px; border: var(--border-width-default) dashed var(--border2); background: var(--surface2); display: flex; flex-direction: column; gap: 6px; }
419
+ .cpub-stage-opt-row { display: flex; align-items: center; gap: 6px; }
420
+ .cpub-stage-opt-row .cpub-form-input { flex: 1; min-width: 100px; margin: 0; }
421
+ .cpub-stage-tfield-pii { margin-top: 6px; }
346
422
  </style>
@@ -25,9 +25,10 @@ function handleSearch(): void {
25
25
  searchTimeout = setTimeout(async () => {
26
26
  searching.value = true;
27
27
  try {
28
- const data = await ($fetch as Function)('/api/admin/users', { query: { search: searchQuery.value, limit: 8 } }) as { items: Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }> };
28
+ // Contest-scoped, non-admin user search (public fields only).
29
+ const data = await ($fetch as Function)(`/api/contests/${props.contestSlug}/user-search`, { query: { q: searchQuery.value, limit: 8 } }) as Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null }>;
29
30
  const existing = new Set((stakeholders.value ?? []).map((s) => s.userId));
30
- searchResults.value = data.items.filter((u) => !existing.has(u.id));
31
+ searchResults.value = data.filter((u) => !existing.has(u.id));
31
32
  } catch {
32
33
  searchResults.value = [];
33
34
  } finally {