@commonpub/layer 0.81.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) 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 +11 -3
  40. package/components/contest/ContestStageSubmission.vue +10 -36
  41. package/components/contest/ContestStagesEditor.vue +141 -65
  42. package/components/contest/ContestStakeholderManager.vue +54 -20
  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/useAuth.ts +13 -0
  64. package/composables/useCan.ts +23 -0
  65. package/composables/useContestEditor.ts +388 -0
  66. package/composables/useDocsPageTree.ts +154 -0
  67. package/composables/useDocsSiteSettings.ts +107 -0
  68. package/composables/useEditorAutosave.ts +131 -0
  69. package/composables/useEngagement.ts +13 -6
  70. package/composables/useFeatures.ts +9 -1
  71. package/composables/useFileUpload.ts +60 -0
  72. package/composables/useProfileContent.ts +84 -0
  73. package/composables/useSanitize.ts +38 -4
  74. package/composables/useScrollSpy.ts +87 -0
  75. package/layouts/admin.vue +43 -18
  76. package/layouts/default.vue +18 -9
  77. package/nuxt.config.ts +13 -0
  78. package/package.json +8 -8
  79. package/pages/[type]/index.vue +6 -1
  80. package/pages/admin/api-keys.vue +13 -3
  81. package/pages/admin/features.vue +2 -0
  82. package/pages/admin/federation.vue +1 -1
  83. package/pages/admin/layouts/[id].vue +30 -2
  84. package/pages/admin/roles.vue +286 -0
  85. package/pages/admin/settings.vue +2 -1
  86. package/pages/admin/users.vue +81 -1
  87. package/pages/admin/video-categories.vue +203 -0
  88. package/pages/cert/[code].vue +6 -2
  89. package/pages/contests/[slug]/edit.vue +4 -764
  90. package/pages/contests/[slug]/entries/[entryId].vue +34 -1
  91. package/pages/contests/[slug]/index.vue +97 -8
  92. package/pages/contests/[slug]/judge.vue +49 -26
  93. package/pages/contests/create.vue +5 -466
  94. package/pages/contests/index.vue +7 -2
  95. package/pages/cookies.vue +1 -1
  96. package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
  97. package/pages/docs/[siteSlug]/edit.vue +93 -231
  98. package/pages/events/[slug]/edit.vue +20 -20
  99. package/pages/events/create.vue +18 -18
  100. package/pages/events/index.vue +7 -2
  101. package/pages/hubs/[slug]/index.vue +34 -9
  102. package/pages/hubs/[slug]/invites.vue +312 -0
  103. package/pages/hubs/[slug]/members.vue +128 -0
  104. package/pages/hubs/[slug]/posts/[postId].vue +2 -2
  105. package/pages/hubs/index.vue +6 -1
  106. package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
  107. package/pages/learn/index.vue +8 -1
  108. package/pages/messages/index.vue +1 -1
  109. package/pages/mirror/[id].vue +1 -1
  110. package/pages/products/[slug].vue +55 -2
  111. package/pages/products/index.vue +6 -1
  112. package/pages/settings/account.vue +8 -8
  113. package/pages/settings/profile.vue +23 -14
  114. package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
  115. package/pages/u/[username]/followers.vue +11 -3
  116. package/pages/u/[username]/following.vue +10 -8
  117. package/pages/u/[username]/index.vue +73 -7
  118. package/pages/videos/index.vue +13 -10
  119. package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
  120. package/server/api/admin/api-keys/[id].delete.ts +2 -2
  121. package/server/api/admin/api-keys/index.get.ts +1 -0
  122. package/server/api/admin/api-keys/index.post.ts +1 -0
  123. package/server/api/admin/federation/refederate.post.ts +18 -1
  124. package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
  125. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
  126. package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
  127. package/server/api/admin/layouts/[id].delete.ts +1 -4
  128. package/server/api/admin/layouts/[id].get.ts +1 -4
  129. package/server/api/admin/layouts/[id].put.ts +1 -4
  130. package/server/api/admin/permissions.get.ts +14 -0
  131. package/server/api/admin/roles/[id]/index.delete.ts +25 -0
  132. package/server/api/admin/roles/[id]/index.put.ts +24 -0
  133. package/server/api/admin/roles/index.get.ts +10 -0
  134. package/server/api/admin/roles/index.post.ts +27 -0
  135. package/server/api/admin/users/[id]/role.put.ts +20 -1
  136. package/server/api/admin/users/[id]/roles.get.ts +10 -0
  137. package/server/api/admin/users/[id]/roles.put.ts +17 -0
  138. package/server/api/auth/federated/login.post.ts +12 -5
  139. package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
  140. package/server/api/content/[id]/build.get.ts +11 -0
  141. package/server/api/content/[id]/report.post.ts +2 -0
  142. package/server/api/content/[id]/versions.get.ts +15 -0
  143. package/server/api/contests/[slug]/advance.post.ts +10 -5
  144. package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
  145. package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
  146. package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
  147. package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
  148. package/server/api/contests/[slug]/export.get.ts +43 -0
  149. package/server/api/contests/[slug]/index.get.ts +10 -2
  150. package/server/api/contests/[slug]/index.put.ts +11 -2
  151. package/server/api/contests/[slug]/judge.post.ts +8 -2
  152. package/server/api/contests/[slug]/proposal.post.ts +36 -0
  153. package/server/api/contests/[slug]/stakeholders/index.post.ts +12 -3
  154. package/server/api/contests/[slug]/transition.post.ts +8 -3
  155. package/server/api/contests/[slug]/user-search.get.ts +30 -0
  156. package/server/api/contests/index.post.ts +1 -1
  157. package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
  158. package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
  159. package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
  160. package/server/api/docs/[siteSlug]/search.get.ts +7 -1
  161. package/server/api/events/[slug]/attendees.get.ts +10 -0
  162. package/server/api/events/[slug].get.ts +9 -0
  163. package/server/api/events/index.get.ts +8 -1
  164. package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
  165. package/server/api/federation/content/[id]/build.get.ts +10 -0
  166. package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
  167. package/server/api/hubs/[slug]/invites.get.ts +5 -3
  168. package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
  169. package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
  170. package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
  171. package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
  172. package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
  173. package/server/api/hubs/[slug]/requests.get.ts +20 -0
  174. package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
  175. package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
  176. package/server/api/me.get.ts +7 -0
  177. package/server/api/products/[id].delete.ts +22 -2
  178. package/server/api/registry/ping.post.ts +17 -3
  179. package/server/api/search/index.get.ts +5 -3
  180. package/server/api/social/bookmark.get.ts +1 -0
  181. package/server/api/social/bookmark.post.ts +1 -0
  182. package/server/api/social/bookmarks.get.ts +1 -0
  183. package/server/api/social/comments/[id].delete.ts +1 -0
  184. package/server/api/social/comments.get.ts +1 -0
  185. package/server/api/social/comments.post.ts +1 -0
  186. package/server/api/social/like.get.ts +1 -0
  187. package/server/api/social/like.post.ts +1 -0
  188. package/server/api/users/[username]/content.get.ts +15 -3
  189. package/server/api/users/[username]/follow.delete.ts +1 -0
  190. package/server/api/users/[username]/follow.post.ts +1 -0
  191. package/server/api/users/[username]/followers.get.ts +2 -1
  192. package/server/api/users/[username]/following.get.ts +2 -1
  193. package/server/middleware/content-ap.ts +8 -3
  194. package/server/middleware/csrf.ts +93 -0
  195. package/server/plugins/federation-hub-sync.ts +48 -17
  196. package/server/plugins/notification-email.ts +22 -3
  197. package/server/routes/hubs/[slug]/inbox.ts +13 -1
  198. package/server/routes/inbox.ts +14 -1
  199. package/server/routes/users/[username]/inbox.ts +13 -1
  200. package/server/utils/inbox.ts +7 -2
  201. package/server/utils/validate.ts +22 -0
  202. package/theme/base.css +5 -0
  203. package/theme/prose.css +20 -0
  204. package/theme/stoa-dark.css +4 -0
  205. package/types/contestBlocks.ts +122 -0
  206. package/utils/contestBlocks.ts +107 -0
  207. package/utils/contestBody.ts +25 -0
  208. package/utils/contestStages.ts +62 -0
  209. package/utils/contestSubmission.ts +97 -0
  210. package/utils/datetime.ts +45 -0
  211. package/utils/projectBlocks.ts +162 -0
  212. package/components/editors/BlogEditor.vue +0 -648
@@ -10,7 +10,7 @@ const { toasts, dismiss } = useToast();
10
10
  :key="toast.id"
11
11
  class="cpub-toast"
12
12
  :class="`cpub-toast--${toast.type}`"
13
- role="status"
13
+ :role="toast.type === 'error' ? 'alert' : 'status'"
14
14
  >
15
15
  <i
16
16
  :class="{
@@ -0,0 +1,98 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Headless author avatar shared by the content views (ProjectView, ArticleView).
4
+ *
5
+ * Renders an <img class="cpub-av"> when `src` is present, else an initials
6
+ * <div class="cpub-av"> (first two letters of `name`, uppercased, falling back
7
+ * to 'CP'). The .cpub-av CSS lives HERE — Vue scoped styles are hashed per
8
+ * component instance, so the avatar rules cannot be left behind in the views;
9
+ * they must travel with the markup. The .cpub-av block (square-lock, border,
10
+ * object-fit, div-only flex centering) is copied verbatim from the pre-dedup
11
+ * ProjectView/ArticleView so behaviour is identical.
12
+ *
13
+ * Size is per-call: ProjectView's byline was 36px, ArticleView's byline 44px,
14
+ * ArticleView's author card 64px — so callers pass `size`/`fontSize` (the only
15
+ * value that ever diverged between the two views).
16
+ */
17
+ const props = withDefaults(
18
+ defineProps<{
19
+ src?: string | null;
20
+ name: string;
21
+ /** Avatar diameter in px. */
22
+ size?: number;
23
+ /** Initials font-size in px. */
24
+ fontSize?: number;
25
+ }>(),
26
+ { src: null, size: 28, fontSize: 10 },
27
+ );
28
+
29
+ const initials = computed(() => props.name?.slice(0, 2).toUpperCase() || 'CP');
30
+ const cssVars = computed(() => ({
31
+ '--cpub-av-size': `${props.size}px`,
32
+ '--cpub-av-font': `${props.fontSize}px`,
33
+ }));
34
+ </script>
35
+
36
+ <template>
37
+ <img
38
+ v-if="src"
39
+ :src="src"
40
+ :alt="name"
41
+ class="cpub-av"
42
+ :style="cssVars"
43
+ />
44
+ <div v-else class="cpub-av" :style="cssVars">{{ initials }}</div>
45
+ </template>
46
+
47
+ <style scoped>
48
+ /* ── AVATARS ──
49
+ * Two render modes share the .cpub-av class:
50
+ * <img class="cpub-av" ...> ← avatar photo
51
+ * <div class="cpub-av">JD</div> ← initials fallback when no avatar
52
+ *
53
+ * Sizing + border-radius is shared. But `display: flex` MUST NOT apply to
54
+ * the <img> — when a replaced element gets `display: flex` set, browsers
55
+ * (notably Chromium) treat the img content render-box inconsistently and
56
+ * the inline `object-fit: cover` is silently dropped, producing a squished
57
+ * (stretched-to-box) image instead of a center-cropped one. Visible on
58
+ * deveco.io blog pages where author avatars are vertical photos (e.g.
59
+ * 816×1456) rendered into a 44×44 square.
60
+ *
61
+ * Fix: scope display:flex centering to the div variant only.
62
+ */
63
+ .cpub-av {
64
+ --cpub-av-size: 28px;
65
+ --cpub-av-font: 10px;
66
+ width: var(--cpub-av-size);
67
+ height: var(--cpub-av-size);
68
+ /* Hard-lock to a square. Without min/max clamps, a global img reset or a
69
+ dropped dimension lets the <img> fall back to its intrinsic aspect ratio,
70
+ so a portrait photo renders as a tall oval (the deveco blog-avatar bug -
71
+ visible even on wide viewports, so it's not flex compression). min/max on
72
+ BOTH axes clamp the used size regardless of what sets width/height. */
73
+ min-width: var(--cpub-av-size);
74
+ max-width: var(--cpub-av-size);
75
+ min-height: var(--cpub-av-size);
76
+ max-height: var(--cpub-av-size);
77
+ border-radius: 50%;
78
+ background: var(--surface3);
79
+ border: var(--border-width-default) solid var(--border);
80
+ flex-shrink: 0;
81
+ }
82
+
83
+ div.cpub-av {
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ font-size: var(--cpub-av-font);
88
+ font-weight: 700;
89
+ color: var(--text-dim);
90
+ font-family: var(--font-mono);
91
+ }
92
+
93
+ /* Defensive: even when consumers forget the inline `object-fit:cover`,
94
+ img.cpub-av crops instead of stretching. */
95
+ img.cpub-av {
96
+ object-fit: cover;
97
+ }
98
+ </style>
@@ -0,0 +1,88 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * CpubCriteriaBar — the one criteria-weighting visual, shared by the `criteriaBar`
4
+ * block (view + edit preview) and the public "Judging Criteria" section. One thin,
5
+ * seamless, sharp stacked bar (no gaps, no rounding, no in-segment text) where each
6
+ * criterion is a proportional colored segment; all labels live in a legend
7
+ * (swatch · name · share, with an optional description line). The bar is decorative
8
+ * (aria-hidden); the legend + an sr-only summary carry the data. Pattern: the
9
+ * iOS-storage / GitHub-language bar — distinct categorical colors, external legend.
10
+ */
11
+ import { criteriaBar, type CriteriaBarItem } from '../utils/contestBlocks';
12
+
13
+ // NB: boolean props cast an absent value to `false` (Vue boolean casting), so the
14
+ // legend must default true explicitly — otherwise callers that omit it lose the legend.
15
+ const props = withDefaults(
16
+ defineProps<{
17
+ items?: CriteriaBarItem[];
18
+ heading?: string;
19
+ showLegend?: boolean;
20
+ }>(),
21
+ { items: () => [], heading: '', showLegend: true },
22
+ );
23
+
24
+ const data = computed(() => criteriaBar(props.items));
25
+ const segments = computed(() => data.value.rows.filter((r) => r.pct > 0));
26
+ const showLegend = computed(() => props.showLegend);
27
+ const hasDesc = computed(() => data.value.rows.some((r) => r.description));
28
+ const summary = computed(() =>
29
+ data.value.rows.map((r) => (data.value.total > 0 ? `${r.label} ${r.pct}%` : r.label)).join(', '),
30
+ );
31
+ </script>
32
+
33
+ <template>
34
+ <figure v-if="data.rows.length" class="cpub-cbar" role="group" :aria-label="heading || 'Criteria weighting'">
35
+ <figcaption v-if="heading" class="cpub-cbar-heading">{{ heading }}</figcaption>
36
+
37
+ <div v-if="segments.length" class="cpub-cbar-track" aria-hidden="true">
38
+ <span
39
+ v-for="(s, i) in segments"
40
+ :key="i"
41
+ class="cpub-cbar-seg"
42
+ :style="{ width: `${s.pct}%`, background: s.colorVar }"
43
+ :title="`${s.label} — ${s.pct}%`"
44
+ />
45
+ </div>
46
+
47
+ <ul v-if="showLegend" class="cpub-cbar-legend" :class="{ 'cpub-cbar-legend--rows': hasDesc }">
48
+ <li v-for="(r, i) in data.rows" :key="i" class="cpub-cbar-li">
49
+ <span class="cpub-cbar-dot" :style="{ background: r.colorVar }" aria-hidden="true" />
50
+ <span class="cpub-cbar-name">{{ r.label }}</span>
51
+ <span v-if="data.total > 0" class="cpub-cbar-val">{{ r.pct }}%</span>
52
+ <span v-if="r.description" class="cpub-cbar-desc">{{ r.description }}</span>
53
+ </li>
54
+ </ul>
55
+
56
+ <span class="cpub-sr-only">Criteria weighting: {{ summary }}.</span>
57
+ </figure>
58
+ </template>
59
+
60
+ <style scoped>
61
+ .cpub-cbar { margin: 0; }
62
+ .cpub-cbar-heading { font-size: 11px; font-weight: 700; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); margin: 0 0 8px; }
63
+
64
+ /* The bar: thin, seamless (segments butt edge-to-edge), sharp (no radius leak). */
65
+ .cpub-cbar-track {
66
+ display: flex; width: 100%; height: 10px; overflow: hidden;
67
+ border-radius: 0; background: var(--surface2);
68
+ border: var(--border-width-default) solid var(--border);
69
+ }
70
+ .cpub-cbar-seg { height: 100%; min-width: 2px; border-radius: 0; }
71
+
72
+ /* Legend — compact wrap by default; one row per item when descriptions exist. */
73
+ .cpub-cbar-legend { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px 18px; }
74
+ .cpub-cbar-li { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
75
+ .cpub-cbar-dot { width: 10px; height: 10px; flex-shrink: 0; border-radius: 0; border: var(--border-width-default) solid var(--border2); }
76
+ .cpub-cbar-name { font-size: 12px; color: var(--text); font-weight: 600; }
77
+ .cpub-cbar-val { font-size: 11px; color: var(--text-faint); font-family: var(--font-mono); }
78
+ .cpub-cbar-desc { display: none; }
79
+
80
+ /* Rows mode: a scannable list with the weight right-aligned + a description line. */
81
+ .cpub-cbar-legend--rows { flex-direction: column; gap: 12px; }
82
+ .cpub-cbar-legend--rows .cpub-cbar-li { display: grid; grid-template-columns: auto 1fr auto; align-items: baseline; gap: 3px 10px; width: 100%; }
83
+ .cpub-cbar-legend--rows .cpub-cbar-dot { align-self: center; }
84
+ .cpub-cbar-legend--rows .cpub-cbar-val { justify-self: end; }
85
+ .cpub-cbar-legend--rows .cpub-cbar-desc { display: block; grid-column: 2 / -1; font-size: 12px; color: var(--text-dim); line-height: 1.5; }
86
+
87
+ .cpub-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
88
+ </style>
@@ -0,0 +1,73 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Themed datetime field — one offset-correct, theme-aware replacement for raw
4
+ * `<input type="datetime-local">` across the contest editor (and reusable
5
+ * elsewhere). The model value is an ISO instant; the control speaks the viewer's
6
+ * local wall-clock via the shared utils/datetime helpers (no UTC shift, the bug
7
+ * fixed in Phase 1). `min`/`max` accept ISO and constrain the native picker (e.g.
8
+ * a stage's end can't precede its start). The native popup themes correctly via
9
+ * `color-scheme` on :root (packages/ui/theme/base.css).
10
+ */
11
+ const props = defineProps<{
12
+ modelValue?: string | null;
13
+ label?: string;
14
+ min?: string | null;
15
+ max?: string | null;
16
+ required?: boolean;
17
+ disabled?: boolean;
18
+ /** Explicit id for the input; otherwise an SSR-safe one is generated. */
19
+ id?: string;
20
+ }>();
21
+
22
+ const emit = defineEmits<{ 'update:modelValue': [value: string | undefined] }>();
23
+
24
+ const generatedId = useId();
25
+ const fieldId = computed(() => props.id ?? `cpub-dt-${generatedId}`);
26
+
27
+ // The local wall-clock is timezone-dependent, so the server (its TZ) and the
28
+ // client (the viewer's TZ) compute different value/min/max strings. Vue flags the
29
+ // hydration mismatch and, worse, does NOT rectify it in production (the viewer
30
+ // would see the SERVER's timezone). Defer the conversion to the client: render
31
+ // empty on the server and the first hydration tick, then fill once mounted, so
32
+ // SSR == hydration and the local value only appears client-side.
33
+ const mounted = ref(false);
34
+ onMounted(() => { mounted.value = true; });
35
+ const localValue = computed(() => (mounted.value ? toLocalInput(props.modelValue) : ''));
36
+ const localMin = computed(() => (mounted.value ? toLocalInput(props.min) || undefined : undefined));
37
+ const localMax = computed(() => (mounted.value ? toLocalInput(props.max) || undefined : undefined));
38
+
39
+ function onInput(e: Event): void {
40
+ emit('update:modelValue', fromLocalInput((e.target as HTMLInputElement).value));
41
+ }
42
+ </script>
43
+
44
+ <template>
45
+ <div class="cpub-datetime-field">
46
+ <label v-if="label" :for="fieldId" class="cpub-form-label">
47
+ {{ label }}<span v-if="required" class="cpub-datetime-req" aria-hidden="true"> *</span>
48
+ </label>
49
+ <input
50
+ :id="fieldId"
51
+ type="datetime-local"
52
+ class="cpub-input"
53
+ :value="localValue"
54
+ :min="localMin"
55
+ :max="localMax"
56
+ :required="required"
57
+ :disabled="disabled"
58
+ :aria-label="label ? undefined : 'Date and time'"
59
+ @input="onInput"
60
+ />
61
+ </div>
62
+ </template>
63
+
64
+ <style scoped>
65
+ .cpub-datetime-field {
66
+ display: flex;
67
+ flex-direction: column;
68
+ gap: var(--space-1);
69
+ }
70
+ .cpub-datetime-req {
71
+ color: var(--red);
72
+ }
73
+ </style>
@@ -28,7 +28,9 @@ const props = defineProps<{
28
28
  const trimmed = computed(() => (props.source ?? '').trim());
29
29
  const isHtml = computed(() => props.format === 'html');
30
30
 
31
- const richHtml = computed(() => (isHtml.value && trimmed.value ? sanitizeRichHtml(trimmed.value) : ''));
31
+ // neutralizeColors: drop hardcoded color literals so the themed `.cpub-md-html`
32
+ // baseline shows through (dark-mode-safe); author `var(--*)`/currentColor are kept.
33
+ const richHtml = computed(() => (isHtml.value && trimmed.value ? sanitizeRichHtml(trimmed.value, { neutralizeColors: true }) : ''));
32
34
 
33
35
  const blocks = computed<BlockTuple[]>(() => {
34
36
  if (isHtml.value || !trimmed.value) return [];
@@ -8,7 +8,7 @@ const model = defineModel<'markdown' | 'html'>({ default: 'markdown' });
8
8
  </script>
9
9
 
10
10
  <template>
11
- <div class="cpub-fmt-toggle" role="radiogroup" aria-label="Field format">
11
+ <div class="cpub-fmt-toggle" role="group" aria-label="Field format">
12
12
  <button
13
13
  type="button"
14
14
  class="cpub-fmt-opt"
@@ -48,7 +48,7 @@ const model = defineModel<'markdown' | 'html'>({ default: 'markdown' });
48
48
  }
49
49
  .cpub-fmt-active {
50
50
  background: var(--accent);
51
- color: var(--accent-contrast, #fff);
51
+ color: var(--color-on-accent);
52
52
  }
53
53
  .cpub-fmt-opt:focus-visible {
54
54
  outline: 2px solid var(--accent);
@@ -13,6 +13,8 @@ const emit = defineEmits<{
13
13
  'update:modelValue': [url: string];
14
14
  }>();
15
15
 
16
+ const { uploadFile } = useFileUpload();
17
+
16
18
  const uploading = ref(false);
17
19
  const error = ref('');
18
20
  const fileInput = ref<HTMLInputElement | null>(null);
@@ -59,14 +61,9 @@ async function onCropped(blob: Blob): Promise<void> {
59
61
  error.value = '';
60
62
  try {
61
63
  const ext = blob.type === 'image/png' ? 'png' : 'jpg';
62
- const formData = new FormData();
63
- formData.append('file', new File([blob], `${props.purpose}.${ext}`, { type: blob.type || 'image/jpeg' }));
64
- formData.append('purpose', props.purpose);
65
-
66
- const result = await $fetch<{ url: string }>('/api/files/upload', {
67
- method: 'POST',
68
- body: formData,
69
- });
64
+ const file = new File([blob], `${props.purpose}.${ext}`, { type: blob.type || 'image/jpeg' });
65
+
66
+ const result = await uploadFile(file, props.purpose);
70
67
 
71
68
  emit('update:modelValue', result.url);
72
69
  } catch (err: unknown) {
@@ -92,6 +92,7 @@ async function remove(): Promise<void> {
92
92
  </script>
93
93
 
94
94
  <template>
95
+ <Teleport to="body">
95
96
  <div class="cpub-modal-backdrop" @click.self="emit('close')">
96
97
  <div ref="contentRef" class="cpub-modal-content" role="dialog" aria-modal="true" aria-labelledby="cpub-mirror-modal-title">
97
98
  <div class="cpub-modal-header">
@@ -170,12 +171,13 @@ async function remove(): Promise<void> {
170
171
  </div>
171
172
  </div>
172
173
  </div>
174
+ </Teleport>
173
175
  </template>
174
176
 
175
177
  <style scoped>
176
178
  .cpub-modal-backdrop {
177
179
  position: fixed; inset: 0; background: var(--color-surface-scrim, rgba(0,0,0,0.5));
178
- z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px;
180
+ z-index: var(--z-modal-backdrop); display: flex; align-items: center; justify-content: center; padding: 16px;
179
181
  }
180
182
  .cpub-modal-content {
181
183
  background: var(--surface); border: var(--border-width-default) solid var(--border);
@@ -77,6 +77,7 @@ async function reject(): Promise<void> {
77
77
  </script>
78
78
 
79
79
  <template>
80
+ <Teleport to="body">
80
81
  <div class="cpub-modal-backdrop" @click.self="emit('close')">
81
82
  <div ref="contentRef" class="cpub-modal-content" role="dialog" aria-modal="true" aria-labelledby="cpub-mr-modal-title">
82
83
  <div class="cpub-modal-header">
@@ -123,12 +124,13 @@ async function reject(): Promise<void> {
123
124
  </div>
124
125
  </div>
125
126
  </div>
127
+ </Teleport>
126
128
  </template>
127
129
 
128
130
  <style scoped>
129
131
  .cpub-modal-backdrop {
130
132
  position: fixed; inset: 0; background: var(--color-surface-scrim, rgba(0,0,0,0.5));
131
- z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px;
133
+ z-index: var(--z-modal-backdrop); display: flex; align-items: center; justify-content: center; padding: 16px;
132
134
  }
133
135
  .cpub-modal-content {
134
136
  background: var(--surface); border: var(--border-width-default) solid var(--border);
@@ -0,0 +1,184 @@
1
+ <script setup lang="ts">
2
+ interface EditableProduct {
3
+ id: string;
4
+ name: string;
5
+ description: string | null;
6
+ category: string | null;
7
+ purchaseUrl: string | null;
8
+ datasheetUrl: string | null;
9
+ status: string;
10
+ }
11
+
12
+ const props = defineProps<{
13
+ product: EditableProduct;
14
+ }>();
15
+
16
+ const emit = defineEmits<{
17
+ close: [];
18
+ updated: [product: { slug: string }];
19
+ }>();
20
+
21
+ const toast = useToast();
22
+
23
+ const formName = ref(props.product.name);
24
+ const formDescription = ref(props.product.description ?? '');
25
+ const formCategory = ref(props.product.category ?? 'other');
26
+ const formPurchaseUrl = ref(props.product.purchaseUrl ?? '');
27
+ const formDatasheetUrl = ref(props.product.datasheetUrl ?? '');
28
+ const formStatus = ref(props.product.status ?? 'active');
29
+ const saving = ref(false);
30
+
31
+ // Parent mounts/unmounts this modal via v-if, so it's always "open" while
32
+ // mounted. A local ref flipped on mount drives useFocusTrap's watcher.
33
+ const contentRef = ref<HTMLElement | null>(null);
34
+ const visible = ref(false);
35
+ onMounted(() => { visible.value = true; });
36
+ useFocusTrap(contentRef, () => visible.value, () => emit('close'));
37
+
38
+ async function handleSave(): Promise<void> {
39
+ if (!formName.value.trim()) return;
40
+ saving.value = true;
41
+ try {
42
+ const updated = await $fetch<{ slug: string }>(`/api/products/${props.product.id}`, {
43
+ method: 'PUT',
44
+ body: {
45
+ name: formName.value,
46
+ description: formDescription.value || undefined,
47
+ category: formCategory.value,
48
+ purchaseUrl: formPurchaseUrl.value || undefined,
49
+ datasheetUrl: formDatasheetUrl.value || undefined,
50
+ status: formStatus.value,
51
+ },
52
+ });
53
+ toast.success('Product updated');
54
+ emit('updated', updated);
55
+ emit('close');
56
+ } catch {
57
+ toast.error('Failed to update product');
58
+ } finally {
59
+ saving.value = false;
60
+ }
61
+ }
62
+ </script>
63
+
64
+ <template>
65
+ <Teleport to="body">
66
+ <div class="cpub-modal-backdrop" @click.self="emit('close')">
67
+ <div ref="contentRef" class="cpub-modal-content" role="dialog" aria-modal="true" aria-labelledby="cpub-edit-product-title">
68
+ <div class="cpub-modal-header">
69
+ <h3 id="cpub-edit-product-title" class="cpub-modal-title">Edit Product</h3>
70
+ <button class="cpub-modal-close" aria-label="Close" @click="emit('close')"><i class="fa-solid fa-xmark"></i></button>
71
+ </div>
72
+
73
+ <form class="cpub-resource-form" @submit.prevent="handleSave">
74
+ <label class="cpub-field-label" for="cpub-edit-product-name">Name</label>
75
+ <input id="cpub-edit-product-name" v-model="formName" type="text" placeholder="Product name" class="cpub-input" required />
76
+
77
+ <label class="cpub-field-label" for="cpub-edit-product-desc">Description</label>
78
+ <input id="cpub-edit-product-desc" v-model="formDescription" type="text" placeholder="Short description (optional)" class="cpub-input" />
79
+
80
+ <label class="cpub-field-label" for="cpub-edit-product-category">Category</label>
81
+ <select id="cpub-edit-product-category" v-model="formCategory" class="cpub-input">
82
+ <option value="microcontroller">Microcontroller</option>
83
+ <option value="sbc">SBC</option>
84
+ <option value="sensor">Sensor</option>
85
+ <option value="actuator">Actuator</option>
86
+ <option value="display">Display</option>
87
+ <option value="communication">Communication</option>
88
+ <option value="power">Power</option>
89
+ <option value="mechanical">Mechanical</option>
90
+ <option value="software">Software</option>
91
+ <option value="tool">Tool</option>
92
+ <option value="other">Other</option>
93
+ </select>
94
+
95
+ <label class="cpub-field-label" for="cpub-edit-product-purchase">Purchase URL</label>
96
+ <input id="cpub-edit-product-purchase" v-model="formPurchaseUrl" type="url" placeholder="Purchase URL (optional)" class="cpub-input" />
97
+
98
+ <label class="cpub-field-label" for="cpub-edit-product-datasheet">Datasheet URL</label>
99
+ <input id="cpub-edit-product-datasheet" v-model="formDatasheetUrl" type="url" placeholder="Datasheet URL (optional)" class="cpub-input" />
100
+
101
+ <label class="cpub-field-label" for="cpub-edit-product-status">Status</label>
102
+ <select id="cpub-edit-product-status" v-model="formStatus" class="cpub-input">
103
+ <option value="active">Active</option>
104
+ <option value="discontinued">Discontinued</option>
105
+ <option value="preview">Preview</option>
106
+ </select>
107
+
108
+ <div class="cpub-modal-actions">
109
+ <button type="submit" class="cpub-btn cpub-btn-primary" :disabled="saving || !formName.trim()">
110
+ {{ saving ? 'Saving...' : 'Save Changes' }}
111
+ </button>
112
+ <button type="button" class="cpub-btn" @click="emit('close')">Cancel</button>
113
+ </div>
114
+ </form>
115
+ </div>
116
+ </div>
117
+ </Teleport>
118
+ </template>
119
+
120
+ <style scoped>
121
+ .cpub-modal-backdrop {
122
+ position: fixed;
123
+ inset: 0;
124
+ background: var(--color-surface-scrim, rgba(0,0,0,0.5));
125
+ z-index: var(--z-modal-backdrop);
126
+ display: flex;
127
+ align-items: center;
128
+ justify-content: center;
129
+ }
130
+
131
+ .cpub-modal-content {
132
+ background: var(--surface);
133
+ border: var(--border-width-default) solid var(--border);
134
+ box-shadow: var(--shadow-lg);
135
+ -webkit-backdrop-filter: var(--surface-backdrop, none);
136
+ backdrop-filter: var(--surface-backdrop, none);
137
+ padding: 24px;
138
+ max-width: 420px;
139
+ width: 90vw;
140
+ max-height: 90vh;
141
+ overflow-y: auto;
142
+ }
143
+
144
+ .cpub-modal-header {
145
+ display: flex;
146
+ justify-content: space-between;
147
+ align-items: center;
148
+ margin-bottom: 12px;
149
+ }
150
+
151
+ .cpub-modal-title { font-size: 16px; font-weight: 700; }
152
+
153
+ .cpub-modal-close {
154
+ background: none;
155
+ border: none;
156
+ color: var(--text-faint);
157
+ cursor: pointer;
158
+ font-size: 14px;
159
+ padding: 4px;
160
+ }
161
+ .cpub-modal-close:hover { color: var(--text); }
162
+
163
+ .cpub-resource-form {
164
+ display: flex;
165
+ flex-direction: column;
166
+ gap: 8px;
167
+ }
168
+
169
+ .cpub-field-label {
170
+ font-family: var(--font-mono);
171
+ font-size: 10px;
172
+ font-weight: 700;
173
+ text-transform: uppercase;
174
+ letter-spacing: 0.1em;
175
+ color: var(--text-faint);
176
+ margin-top: 6px;
177
+ }
178
+
179
+ .cpub-modal-actions {
180
+ display: flex;
181
+ gap: 8px;
182
+ margin-top: 12px;
183
+ }
184
+ </style>
@@ -44,9 +44,9 @@ useFocusTrap(dialogRef, () => open.value, close);
44
44
  <template>
45
45
  <Teleport to="body">
46
46
  <div v-if="open" class="cpub-rfd-overlay" @click.self="close">
47
- <div ref="dialogRef" class="cpub-rfd-dialog" role="dialog" aria-modal="true">
47
+ <div ref="dialogRef" class="cpub-rfd-dialog" role="dialog" aria-modal="true" aria-labelledby="cpub-rfd-title">
48
48
  <div class="cpub-rfd-header">
49
- <h3>Follow from your instance</h3>
49
+ <h3 id="cpub-rfd-title">Follow from your instance</h3>
50
50
  <button class="cpub-rfd-close" aria-label="Close" @click="close">
51
51
  <i class="fa-solid fa-xmark"></i>
52
52
  </button>
@@ -22,10 +22,11 @@ const { hubs: hubsEnabled } = useFeatures();
22
22
  over an empty list reads as broken on quiet/new instances). -->
23
23
  <div v-if="trendingSearches?.length" class="cpub-sb-block">
24
24
  <div class="cpub-sb-heading">Trending Searches</div>
25
- <ul class="cpub-pop-search-list">
26
- <li
25
+ <div class="cpub-pop-search-list" role="group" aria-label="Trending searches">
26
+ <button
27
27
  v-for="(item, idx) in trendingSearches?.slice(0, 8) ?? []"
28
28
  :key="idx"
29
+ type="button"
29
30
  class="cpub-pop-search-item"
30
31
  @click="emit('search', item.query)"
31
32
  >
@@ -35,23 +36,25 @@ const { hubs: hubsEnabled } = useFeatures();
35
36
  <i :class="item.trend > 0 ? 'fa-solid fa-arrow-trend-up' : 'fa-solid fa-minus'" style="font-size: 9px"></i>
36
37
  <template v-if="item.trend > 0">+{{ item.trend }}%</template>
37
38
  </span>
38
- </li>
39
- </ul>
39
+ </button>
40
+ </div>
40
41
  </div>
41
42
 
42
43
  <!-- Suggested Tags -->
43
44
  <div class="cpub-sb-block">
44
45
  <div class="cpub-sb-heading">Suggested Tags</div>
45
46
  <div class="cpub-tag-cloud">
46
- <span
47
+ <button
47
48
  v-for="tag in suggestedTags"
48
49
  :key="tag"
50
+ type="button"
49
51
  class="cpub-s-tag"
50
52
  :class="{ active: activeTags.includes(tag) }"
53
+ :aria-pressed="activeTags.includes(tag)"
51
54
  @click="emit('toggle-tag', tag)"
52
55
  >
53
56
  {{ tag }}
54
- </span>
57
+ </button>
55
58
  </div>
56
59
  </div>
57
60
 
@@ -60,10 +63,10 @@ const { hubs: hubsEnabled } = useFeatures();
60
63
  <div class="cpub-sb-heading">Browse by Category</div>
61
64
  <p class="cpub-no-results-note">Not finding what you need? Try browsing a category directly.</p>
62
65
  <div class="cpub-cat-grid">
63
- <div v-for="cat in categories" :key="cat.label" class="cpub-cat-cell" @click="emit('set-category', cat.label)">
66
+ <button v-for="cat in categories" :key="cat.label" type="button" class="cpub-cat-cell" @click="emit('set-category', cat.label)">
64
67
  <span class="cpub-cat-icon"><i :class="cat.icon"></i></span>
65
68
  <span class="cpub-cat-label">{{ cat.label }}</span>
66
- </div>
69
+ </button>
67
70
  </div>
68
71
  </div>
69
72
 
@@ -79,9 +82,6 @@ const { hubs: hubsEnabled } = useFeatures();
79
82
  <NuxtLink :to="(hub as Record<string, unknown>).source === 'federated' ? `/federated-hubs/${hub.id}` : `/hubs/${hub.slug}`" class="cpub-related-hub-name">{{ hub.name }}</NuxtLink>
80
83
  <div class="cpub-related-hub-members">{{ hub.memberCount ?? 0 }} members</div>
81
84
  </div>
82
- <button class="cpub-btn-join-sm">
83
- <i class="fa-solid fa-plus" style="font-size: 8px"></i> Join
84
- </button>
85
85
  </div>
86
86
  </div>
87
87
  </div>
@@ -104,7 +104,8 @@ const { hubs: hubsEnabled } = useFeatures();
104
104
 
105
105
  .cpub-pop-search-item {
106
106
  display: flex; align-items: center; gap: 8px; padding: 7px 0;
107
- border-bottom: var(--border-width-default) solid var(--border2); cursor: pointer;
107
+ border: 0; border-bottom: var(--border-width-default) solid var(--border2);
108
+ width: 100%; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer;
108
109
  }
109
110
 
110
111
  .cpub-pop-search-item:last-child { border-bottom: none; }
@@ -146,6 +147,7 @@ const { hubs: hubsEnabled } = useFeatures();
146
147
  .cpub-cat-cell {
147
148
  background: var(--surface2); border: var(--border-width-default) solid var(--border); padding: 10px;
148
149
  cursor: pointer; transition: border-color 0.15s, background 0.15s; text-align: center;
150
+ width: 100%; color: inherit; font: inherit; display: block;
149
151
  }
150
152
 
151
153
  .cpub-cat-cell:hover { border-color: var(--accent); background: var(--surface3); }
@@ -170,13 +172,4 @@ const { hubs: hubsEnabled } = useFeatures();
170
172
 
171
173
  .cpub-related-hub-name:hover { color: var(--accent); }
172
174
  .cpub-related-hub-members { font-size: 10px; font-family: var(--font-mono); color: var(--text-faint); }
173
-
174
- .cpub-btn-join-sm {
175
- font-size: 10px; font-family: var(--font-mono); padding: 3px 8px;
176
- border: var(--border-width-default) solid var(--border); background: var(--green-bg); color: var(--green);
177
- cursor: pointer; flex-shrink: 0; display: inline-flex; align-items: center; gap: 4px;
178
- box-shadow: var(--shadow-sm); transition: all 0.15s;
179
- }
180
-
181
- .cpub-btn-join-sm:hover { box-shadow: var(--shadow-sm); }
182
175
  </style>