@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
@@ -0,0 +1,131 @@
1
+ /**
2
+ * useEditorAutosave — debounced save engine for block-editor pages.
3
+ *
4
+ * Extracted from docs/[siteSlug]/edit.vue (session 205), which had grown
5
+ * its own inline autosave: a debounce timer, a status state machine, the
6
+ * Cmd+S shortcut, and a beforeunload guard all hand-wired in setup(). This
7
+ * pulls that engine into one tested unit so the page only has to supply a
8
+ * `persist()` callback and call `markDirty()` from its watchers.
9
+ *
10
+ * Why not reuse useLayoutAutoSave? That composable is a *leading*-debounce
11
+ * watcher (it fires `debounceMs` after the dirty flag first flips true and
12
+ * does NOT re-arm on continued edits) and deliberately owns nothing else —
13
+ * the layout editor keeps its status/shortcuts elsewhere. The docs editor
14
+ * needs a *trailing* debounce (save once the user pauses), plus the status
15
+ * machine + Cmd+S + unsaved-changes guard. Different semantics, different
16
+ * surface; consolidating the three autosave call-sites is its own follow-up.
17
+ *
18
+ * Router-coupled navigation guards (onBeforeRouteLeave) stay in the page —
19
+ * this composable is intentionally router-free so it unit-tests without a
20
+ * router instance.
21
+ */
22
+ import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue';
23
+
24
+ export type AutosaveStatus = 'idle' | 'saving' | 'saved' | 'error';
25
+
26
+ export interface UseEditorAutosaveOptions {
27
+ /** Performs the actual persistence (the PUT + any refresh). Must throw on failure. */
28
+ persist: () => Promise<void>;
29
+ /** Gate — autosave and manual save only run when this returns true (e.g. a page is selected). Default: always. */
30
+ canSave?: () => boolean;
31
+ /** Trailing-debounce window in ms. Default 5000 (docs editor saves once the user pauses for 5s). */
32
+ debounceMs?: number;
33
+ /** Called with the thrown error when a save fails — the page wires this to a toast. */
34
+ onError?: (err: unknown) => void;
35
+ }
36
+
37
+ export interface UseEditorAutosave {
38
+ /** True when there are unsaved changes. */
39
+ isDirty: Ref<boolean>;
40
+ /** When true, markDirty() is a no-op — set during content load so programmatic edits do not mark dirty. */
41
+ isLoading: Ref<boolean>;
42
+ /** Save-lifecycle status for the UI (status dot + label). */
43
+ status: Ref<AutosaveStatus>;
44
+ /** True while a save is in flight. */
45
+ saving: Ref<boolean>;
46
+ /** Mark dirty and (re)arm the trailing-debounce timer. No-op while isLoading. */
47
+ markDirty: () => void;
48
+ /** Cancel any pending timer and persist immediately. Used by Cmd+S, the Save button, and save-before-switch. */
49
+ saveNow: () => Promise<void>;
50
+ /** Clear dirty + reset status to idle — call after loading a freshly-selected page. */
51
+ reset: () => void;
52
+ /** Cancel a pending autosave without saving. */
53
+ cancel: () => void;
54
+ }
55
+
56
+ export function useEditorAutosave(opts: UseEditorAutosaveOptions): UseEditorAutosave {
57
+ const debounceMs = opts.debounceMs ?? 5000;
58
+ const canSave = opts.canSave ?? ((): boolean => true);
59
+
60
+ const isDirty = ref(false);
61
+ const isLoading = ref(false);
62
+ const status = ref<AutosaveStatus>('idle');
63
+ const saving = ref(false);
64
+ let timer: ReturnType<typeof setTimeout> | null = null;
65
+
66
+ function cancel(): void {
67
+ if (timer !== null) {
68
+ clearTimeout(timer);
69
+ timer = null;
70
+ }
71
+ }
72
+
73
+ async function saveNow(): Promise<void> {
74
+ if (!canSave()) return;
75
+ if (saving.value) return; // re-entrancy guard — never fire two PUTs for one change
76
+ cancel();
77
+ saving.value = true;
78
+ status.value = 'saving';
79
+ try {
80
+ await opts.persist();
81
+ isDirty.value = false;
82
+ status.value = 'saved';
83
+ } catch (err: unknown) {
84
+ // Leave isDirty true — the change is still unsaved and should retry/save again.
85
+ status.value = 'error';
86
+ opts.onError?.(err);
87
+ } finally {
88
+ saving.value = false;
89
+ }
90
+ }
91
+
92
+ function markDirty(): void {
93
+ if (isLoading.value) return;
94
+ isDirty.value = true;
95
+ cancel();
96
+ timer = setTimeout(() => {
97
+ timer = null;
98
+ if (isDirty.value && canSave()) void saveNow();
99
+ }, debounceMs);
100
+ }
101
+
102
+ function reset(): void {
103
+ cancel();
104
+ isDirty.value = false;
105
+ status.value = 'idle';
106
+ }
107
+
108
+ function onKeydown(e: KeyboardEvent): void {
109
+ if ((e.metaKey || e.ctrlKey) && e.key === 's') {
110
+ e.preventDefault();
111
+ void saveNow();
112
+ }
113
+ }
114
+
115
+ function onBeforeUnload(e: BeforeUnloadEvent): void {
116
+ if (isDirty.value) e.preventDefault();
117
+ }
118
+
119
+ onMounted(() => {
120
+ if (typeof document !== 'undefined') document.addEventListener('keydown', onKeydown);
121
+ if (typeof window !== 'undefined') window.addEventListener('beforeunload', onBeforeUnload);
122
+ });
123
+
124
+ onBeforeUnmount(() => {
125
+ cancel();
126
+ if (typeof document !== 'undefined') document.removeEventListener('keydown', onKeydown);
127
+ if (typeof window !== 'undefined') window.removeEventListener('beforeunload', onBeforeUnload);
128
+ });
129
+
130
+ return { isDirty, isLoading, status, saving, markDirty, saveNow, reset, cancel };
131
+ }
@@ -150,16 +150,23 @@ export function useEngagement(opts: EngagementOptions) {
150
150
 
151
151
  async function share(): Promise<void> {
152
152
  if (!contentId.value) return;
153
+ const url = window.location.href;
153
154
  if (navigator.share) {
154
155
  try {
155
- await navigator.share({
156
- url: window.location.href,
157
- });
156
+ await navigator.share({ url });
158
157
  } catch {
159
- // User cancelled or not supported
158
+ // User cancelled or share unavailable — nothing to report.
160
159
  }
161
- } else {
162
- await navigator.clipboard.writeText(window.location.href);
160
+ return;
161
+ }
162
+ // No Web Share API (desktop / non-secure context): copy + confirm so the
163
+ // button isn't a silent no-op.
164
+ const toast = useToast();
165
+ try {
166
+ await navigator.clipboard.writeText(url);
167
+ toast.success('Link copied to clipboard');
168
+ } catch {
169
+ toast.error('Could not copy the link');
163
170
  }
164
171
  }
165
172
 
@@ -20,6 +20,11 @@ export interface FeatureFlags {
20
20
  /** Per-stage submission artifacts for multi-round contests. Default ON;
21
21
  * inert until a stage defines a submissionTemplate. */
22
22
  contestStageSubmissions: boolean;
23
+ /** Form-first proposal submissions + draft placeholder project (Phase 4). Default OFF. */
24
+ contestProposals: boolean;
25
+ /** Offer PII field types in the submission-form builder (Phase 4). Default OFF.
26
+ * PII access is always gated server-side by `contest.pii.read`. */
27
+ contestPii: boolean;
23
28
  events: boolean;
24
29
  learning: boolean;
25
30
  explainers: boolean;
@@ -68,7 +73,8 @@ let hydrated = false;
68
73
  // `flags.value.X` access would crash at runtime.
69
74
  export const DEFAULT_FLAGS: FeatureFlags = {
70
75
  content: true, social: true, hubs: true, docs: true, video: true,
71
- contests: false, contestStageSubmissions: true, events: false, learning: true, explainers: true,
76
+ contests: false, contestStageSubmissions: true, contestProposals: false, contestPii: false,
77
+ events: false, learning: true, explainers: true,
72
78
  editorial: true, federation: false, admin: false, themeStudio: true, emailNotifications: false,
73
79
  publicApi: false, contentImport: true,
74
80
  layoutEngine: false,
@@ -169,6 +175,8 @@ export function useFeatures() {
169
175
  video: computed(() => flags.value.video),
170
176
  contests: computed(() => flags.value.contests),
171
177
  contestStageSubmissions: computed(() => flags.value.contestStageSubmissions),
178
+ contestProposals: computed(() => flags.value.contestProposals),
179
+ contestPii: computed(() => flags.value.contestPii),
172
180
  events: computed(() => flags.value.events),
173
181
  learning: computed(() => flags.value.learning),
174
182
  explainers: computed(() => flags.value.explainers),
@@ -0,0 +1,60 @@
1
+ /**
2
+ * useFileUpload — single source of truth for the `/api/files/upload` POST.
3
+ *
4
+ * Eight call sites used to copy-paste the same `FormData` + `$fetch` dance
5
+ * (build FormData, append `file`, optionally append `purpose`, POST as
6
+ * multipart). They diverged only in the response shape they read back
7
+ * (`{ url }`, plus optional `originalName`/`size`/`sizeBytes`/`mimeType`/
8
+ * `width`/`height`) and in how they handle errors. This composable owns the
9
+ * request; callers keep their own success/error handling and pick the response
10
+ * shape via the `T` type parameter.
11
+ *
12
+ * Behaviour is identical to the previous inline code: same endpoint, same
13
+ * field names (`file`, optional `purpose`), same multipart body, errors
14
+ * propagate to the caller (no swallowing here).
15
+ */
16
+
17
+ /** Every upload response includes at least the stored URL. */
18
+ export interface FileUploadResult {
19
+ url: string;
20
+ }
21
+
22
+ export interface UseFileUpload {
23
+ /**
24
+ * POST a file to `/api/files/upload` as multipart FormData.
25
+ *
26
+ * @param file the File/Blob to upload (appended as the `file` field).
27
+ * @param purpose optional upload purpose (e.g. 'avatar', 'banner', 'cover',
28
+ * 'content'); appended as the `purpose` field when provided.
29
+ * Omit it to match the legacy hub-post upload, which sent none.
30
+ * @returns the parsed JSON response, typed by the caller via `T`.
31
+ */
32
+ uploadFile: <T extends FileUploadResult = FileUploadResult>(
33
+ file: Blob,
34
+ purpose?: string,
35
+ ) => Promise<T>;
36
+ }
37
+
38
+ export function useFileUpload(): UseFileUpload {
39
+ async function uploadFile<T extends FileUploadResult = FileUploadResult>(
40
+ file: Blob,
41
+ purpose?: string,
42
+ ): Promise<T> {
43
+ const formData = new FormData();
44
+ formData.append('file', file);
45
+ if (purpose !== undefined) {
46
+ formData.append('purpose', purpose);
47
+ }
48
+ // Keep the `<T>` generic (it bounds the request type so Nuxt's route-scoring
49
+ // typegen doesn't recurse over the whole route union) but cast the result: the
50
+ // typed overload otherwise mis-resolves to the GET response shape in some apps'
51
+ // route typegen (a Nuxt fragility any new GET route can perturb). The upload
52
+ // endpoint returns a FileUploadResult, so the cast is sound.
53
+ return (await $fetch<T>('/api/files/upload', {
54
+ method: 'POST',
55
+ body: formData,
56
+ })) as unknown as T;
57
+ }
58
+
59
+ return { uploadFile };
60
+ }
@@ -0,0 +1,84 @@
1
+ import type { Serialized, ContentListItem } from '@commonpub/server';
2
+
3
+ /**
4
+ * Per-tab keyset content loader for a user profile.
5
+ *
6
+ * The profile page used to fetch one page of all the user's published content
7
+ * and split it into tabs client-side, so a tab with many items was silently
8
+ * truncated and an owner never saw their drafts. This loads the active tab's
9
+ * slice from `GET /api/users/[username]/content` with keyset pagination (the
10
+ * server endpoint orders by publishedAt DESC NULLS LAST, id DESC) and pages by
11
+ * the opaque `nextCursor` — the same dup-safe strategy as useContentFeed, but
12
+ * pointed at the per-user endpoint (kept separate so the homepage feed loader
13
+ * stays untouched).
14
+ *
15
+ * The caller passes a reactive query ({ type?, drafts?, limit }). When the query
16
+ * identity changes (tab switch) the feed re-fetches from the first page and the
17
+ * accumulator resets. Draft visibility is enforced server-side from the
18
+ * authenticated viewer; `drafts: 'true'` is only a request.
19
+ */
20
+ export type ProfileFeedItem = Serialized<ContentListItem>;
21
+
22
+ type ProfileQuery = Record<string, unknown> & { limit?: number };
23
+
24
+ // Shallow wire type. Items are Array<Record<string, unknown>> (not the deep
25
+ // Serialized<…> mapped type) to avoid TS2589 "excessively deep" through
26
+ // useFetch's own generic machinery under the consumer apps' stricter typecheck;
27
+ // we cast once to ProfileFeedItem[] at the `items` boundary callers read.
28
+ interface ProfileFeedResponse { items: Array<Record<string, unknown>>; nextCursor?: string | null }
29
+
30
+ export function useProfileContent(username: string, query: Ref<ProfileQuery> | ComputedRef<ProfileQuery>) {
31
+ const toast = useToast();
32
+
33
+ const endpoint = `/api/users/${username}/content`;
34
+ // @ts-ignore TS2589: parameterising useFetch with the deep Serialized type blows
35
+ // the instantiation depth under the consumer apps' stricter typecheck. Runtime is
36
+ // unaffected; `data` is read through the typed `page` computed below.
37
+ const { data, pending } = useFetch(endpoint, { query, watch: [query] });
38
+ const page = computed<ProfileFeedResponse | null>(() => (data.value as ProfileFeedResponse | null) ?? null);
39
+
40
+ // Accumulator for load-more pages, kept separate from the useFetch payload so a
41
+ // tab switch cleanly replaces the list.
42
+ const extra = ref<Array<Record<string, unknown>>>([]);
43
+ const cursor = ref<string | null>(null);
44
+ const loadingMore = ref(false);
45
+ const exhausted = ref(false);
46
+
47
+ const items = computed<ProfileFeedItem[]>(
48
+ () => [...(page.value?.items ?? []), ...extra.value] as ProfileFeedItem[],
49
+ );
50
+
51
+ // Reset pagination whenever the tab/filter changes.
52
+ watch(query, () => {
53
+ extra.value = [];
54
+ cursor.value = null;
55
+ exhausted.value = false;
56
+ }, { deep: true });
57
+
58
+ const canLoadMore = computed(() => {
59
+ if (exhausted.value) return false;
60
+ // The next cursor is whatever the last load-more returned, else the first page's.
61
+ return (cursor.value ?? page.value?.nextCursor ?? null) != null;
62
+ });
63
+
64
+ async function loadMore(): Promise<void> {
65
+ if (loadingMore.value || !canLoadMore.value) return;
66
+ const next = cursor.value ?? page.value?.nextCursor ?? null;
67
+ if (!next) return;
68
+ loadingMore.value = true;
69
+ try {
70
+ const res = await $fetch<ProfileFeedResponse>(endpoint, {
71
+ query: { ...query.value, cursor: next },
72
+ });
73
+ extra.value.push(...res.items);
74
+ cursor.value = res.nextCursor ?? null;
75
+ if (!res.nextCursor) exhausted.value = true;
76
+ } catch {
77
+ toast.error('Failed to load more');
78
+ } finally {
79
+ loadingMore.value = false;
80
+ }
81
+ }
82
+
83
+ return { items, pending, loadMore, canLoadMore, loadingMore };
84
+ }
@@ -174,15 +174,49 @@ const RICH_URL_ATTRS = new Set(['href', 'src', 'xlink:href']);
174
174
  // CSS constructs that can fetch, exfiltrate, or execute — stripped per-declaration.
175
175
  const STYLE_DECL_BLOCKLIST = /expression\s*\(|javascript:|vbscript:|behavior\s*:|-moz-binding|@import|url\s*\(/i;
176
176
 
177
- function sanitizeStyleAttr(value: string): string {
177
+ // Color neutralization (opt-in, for dark-mode-safe author HTML). A declaration of
178
+ // one of these properties whose value is a HARDCODED color literal (hex / rgb()/
179
+ // hsl() / common named) is dropped so the themed `.cpub-md-html` baseline shows
180
+ // through — but theme-adaptive values (var(), currentColor, inherit, transparent)
181
+ // are KEPT, so an author who writes `color: var(--text)` is respected.
182
+ const COLOR_PROPS = new Set(['color', 'background', 'background-color', 'border-color', 'outline-color']);
183
+ const LITERAL_COLOR = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?)\s*\(|\b(?:white|black|red|green|blue|yellow|orange|purple|pink|gray|grey|silver|gold|maroon|navy|teal|olive|lime|aqua|cyan|magenta|brown|beige|ivory|crimson|coral|salmon|khaki|indigo|violet)\b/i;
184
+ const ADAPTIVE_COLOR = /var\(|currentcolor|inherit|transparent|initial|unset/i;
185
+
186
+ function isClashingColorDecl(decl: string): boolean {
187
+ const ci = decl.indexOf(':');
188
+ if (ci <= 0) return false;
189
+ const prop = decl.slice(0, ci).trim().toLowerCase();
190
+ if (!COLOR_PROPS.has(prop)) return false;
191
+ const val = decl.slice(ci + 1);
192
+ if (ADAPTIVE_COLOR.test(val)) return false;
193
+ return LITERAL_COLOR.test(val);
194
+ }
195
+
196
+ function sanitizeStyleAttr(value: string, neutralizeColors = false): string {
178
197
  return value
179
198
  .split(';')
180
- .filter((decl) => decl.trim() && !STYLE_DECL_BLOCKLIST.test(decl))
199
+ .filter((decl) => {
200
+ if (!decl.trim() || STYLE_DECL_BLOCKLIST.test(decl)) return false;
201
+ if (neutralizeColors && isClashingColorDecl(decl)) return false;
202
+ return true;
203
+ })
181
204
  .join(';');
182
205
  }
183
206
 
207
+ export interface SanitizeRichOptions {
208
+ /**
209
+ * Drop inline hardcoded color/background literals so the themed render baseline
210
+ * (dark-safe) shows through. Theme-adaptive values (var(), currentColor) are
211
+ * kept. Off by default (general-purpose rendering preserves author colors);
212
+ * on for the contest "Full HTML" fields so a pasted light-mode document stays
213
+ * readable in dark mode.
214
+ */
215
+ neutralizeColors?: boolean;
216
+ }
217
+
184
218
  /** Sanitize author HTML for "full HTML" rendering — permissive but script-free. */
185
- export function sanitizeRichHtml(html: string): string {
219
+ export function sanitizeRichHtml(html: string, opts: SanitizeRichOptions = {}): string {
186
220
  if (!html || typeof html !== 'string') return '';
187
221
 
188
222
  let result = html.replace(/<!--[\s\S]*?-->/g, '');
@@ -219,7 +253,7 @@ export function sanitizeRichHtml(html: string): string {
219
253
  if (RICH_URL_ATTRS.has(name) && !isSafeUrl(value)) continue;
220
254
 
221
255
  if (name === 'style') {
222
- const cleaned = sanitizeStyleAttr(value);
256
+ const cleaned = sanitizeStyleAttr(value, opts.neutralizeColors);
223
257
  if (cleaned) safeAttrs.push(`style="${escapeAttrValue(cleaned)}"`);
224
258
  continue;
225
259
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * useScrollSpy — TOC scroll-spy (active-heading highlight) + smooth scroll.
3
+ *
4
+ * Consolidates two divergent, each-buggy copies of the same IntersectionObserver
5
+ * pattern: ProjectView.vue (which never re-observed, so the highlight went stale
6
+ * when content changed) and the docs viewer (which re-observed but leaked an
7
+ * observer every time because its instance was function-local and never
8
+ * disconnected). This single source:
9
+ * - re-observes whenever `source` changes (fixes the stale highlight), and
10
+ * - disconnects the previous observer before each re-observe and on unmount
11
+ * (fixes the leak).
12
+ *
13
+ * Element discovery and `rootMargin` stay per-surface (one finds elements by TOC
14
+ * id, the other by CSS selector; the active band differs), so callers pass those
15
+ * in. `scrollTo` honours prefers-reduced-motion (the docs viewer uses native
16
+ * anchor links instead and simply ignores it).
17
+ */
18
+ import { onMounted, onUnmounted, ref, watch, nextTick, type Ref, type WatchSource } from 'vue';
19
+
20
+ export interface UseScrollSpyOptions {
21
+ /** Resolve the heading elements to observe. Called on mount + on each `source` change. */
22
+ getHeadingElements: () => HTMLElement[];
23
+ /** Reactive trigger; when it changes, the observer is rebuilt (e.g. the TOC array or rendered page). */
24
+ source: WatchSource;
25
+ /** IntersectionObserver rootMargin — the active band, tuned per surface. */
26
+ rootMargin?: string;
27
+ }
28
+
29
+ export interface UseScrollSpy {
30
+ /** Id of the heading currently in the active band (or set by scrollTo). */
31
+ activeId: Ref<string>;
32
+ /** Smooth-scroll to a heading by id and mark it active (honours reduced-motion). */
33
+ scrollTo: (id: string) => void;
34
+ }
35
+
36
+ const DEFAULT_ROOT_MARGIN = '-80px 0px -70% 0px';
37
+
38
+ export function useScrollSpy(options: UseScrollSpyOptions): UseScrollSpy {
39
+ const activeId = ref('');
40
+ let observer: IntersectionObserver | null = null;
41
+
42
+ function observe(): void {
43
+ if (typeof IntersectionObserver === 'undefined') return; // SSR / unsupported
44
+ observer?.disconnect(); // never leak the previous observer
45
+ const els = options.getHeadingElements();
46
+ if (!els.length) {
47
+ observer = null;
48
+ return;
49
+ }
50
+ observer = new IntersectionObserver(
51
+ (entries) => {
52
+ // Topmost heading in the band wins (entries arrive in document order).
53
+ for (const entry of entries) {
54
+ if (entry.isIntersecting) {
55
+ activeId.value = entry.target.id;
56
+ break;
57
+ }
58
+ }
59
+ },
60
+ { rootMargin: options.rootMargin ?? DEFAULT_ROOT_MARGIN, threshold: 0 },
61
+ );
62
+ for (const el of els) observer.observe(el);
63
+ }
64
+
65
+ function scrollTo(id: string): void {
66
+ const el = document.getElementById(id);
67
+ if (!el) return;
68
+ // CSS scroll-behavior is reduced-motion-gated, but the JS `smooth` option
69
+ // ignores that, so honour the preference explicitly.
70
+ const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
71
+ el.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' });
72
+ activeId.value = id;
73
+ }
74
+
75
+ let stopWatch: (() => void) | null = null;
76
+ onMounted(() => {
77
+ // immediate → initial observe after first paint; subsequent → re-observe on change.
78
+ stopWatch = watch(options.source, () => nextTick(observe), { immediate: true });
79
+ });
80
+ onUnmounted(() => {
81
+ stopWatch?.();
82
+ observer?.disconnect();
83
+ observer = null;
84
+ });
85
+
86
+ return { activeId, scrollTo };
87
+ }
package/layouts/admin.vue CHANGED
@@ -1,9 +1,27 @@
1
1
  <script setup lang="ts">
2
2
  const { isAdmin } = useAuth();
3
- const { admin: adminEnabled, layoutEngine } = useFeatures();
3
+ const { admin: adminEnabled, layoutEngine, publicApi } = useFeatures();
4
4
  const runtimeConfig = useRuntimeConfig();
5
5
  const siteName = computed(() => (runtimeConfig.public.siteName as string) || 'CommonPub');
6
6
 
7
+ // Each nav link is gated on the permission key its target route enforces via
8
+ // requirePermission (verified against the server routes). With the RBAC flag
9
+ // OFF, useCan returns true for admins (admin floor) and false otherwise, so the
10
+ // visible chrome is unchanged today; flipping the flag later drives the UI.
11
+ const canDashboard = useCan('audit.read'); // /admin → /api/admin/stats
12
+ const canUsers = useCan('users.read'); // /admin/users
13
+ const canRoles = useCan('roles.manage'); // /admin/roles
14
+ const canContent = useCan('content.moderate'); // /admin/content
15
+ const canCategories = useCan('categories.manage'); // /admin/categories + video-categories
16
+ const canReports = useCan('reports.review'); // /admin/reports
17
+ const canAudit = useCan('audit.read'); // /admin/audit
18
+ const canTheme = useCan('theme.manage'); // /admin/theme
19
+ const canLayout = useCan('layout.manage'); // /admin/homepage + /admin/layouts
20
+ const canNavigation = useCan('navigation.manage'); // /admin/navigation
21
+ const canSettings = useCan('settings.manage'); // /admin/features + /admin/settings
22
+ const canFederation = useCan('federation.manage'); // /admin/federation
23
+ const canApiKeys = useCan('apikeys.manage'); // /admin/api-keys
24
+
7
25
  // Sidebar state (desktop collapse + mobile drawer) — see useAdminSidebar.ts.
8
26
  // Editor routes (/admin/layouts/[id], /admin/theme/edit/[id]) auto-collapse
9
27
  // so the editor canvas gets more horizontal room; user can override per visit.
@@ -57,52 +75,56 @@ const { desktopCollapsed, mobileOpen, toggleDesktop, toggleMobile, closeMobile }
57
75
  "Dashboard, link", the icon alone has no accessible name.
58
76
  `title` attr only set when collapsed → visual tooltip on hover.
59
77
  -->
60
- <NuxtLink to="/admin" class="admin-nav-link" :title="desktopCollapsed ? 'Dashboard' : undefined" @click="closeMobile">
78
+ <NuxtLink v-if="canDashboard" to="/admin" class="admin-nav-link" :title="desktopCollapsed ? 'Dashboard' : undefined" @click="closeMobile">
61
79
  <i class="fa-solid fa-gauge"></i><span class="admin-nav-label">Dashboard</span>
62
80
  </NuxtLink>
63
- <NuxtLink to="/admin/users" class="admin-nav-link" :title="desktopCollapsed ? 'Users' : undefined" @click="closeMobile">
81
+ <NuxtLink v-if="canUsers" to="/admin/users" class="admin-nav-link" :title="desktopCollapsed ? 'Users' : undefined" @click="closeMobile">
64
82
  <i class="fa-solid fa-users"></i><span class="admin-nav-label">Users</span>
65
83
  </NuxtLink>
66
- <NuxtLink to="/admin/roles" class="admin-nav-link" :title="desktopCollapsed ? 'Roles' : undefined" @click="closeMobile">
84
+ <NuxtLink v-if="canRoles" to="/admin/roles" class="admin-nav-link" :title="desktopCollapsed ? 'Roles' : undefined" @click="closeMobile">
67
85
  <i class="fa-solid fa-user-shield"></i><span class="admin-nav-label">Roles</span>
68
86
  </NuxtLink>
69
- <NuxtLink to="/admin/content" class="admin-nav-link" :title="desktopCollapsed ? 'Content' : undefined" @click="closeMobile">
87
+ <NuxtLink v-if="canContent" to="/admin/content" class="admin-nav-link" :title="desktopCollapsed ? 'Content' : undefined" @click="closeMobile">
70
88
  <i class="fa-solid fa-newspaper"></i><span class="admin-nav-label">Content</span>
71
89
  </NuxtLink>
72
- <NuxtLink to="/admin/categories" class="admin-nav-link" :title="desktopCollapsed ? 'Categories' : undefined" @click="closeMobile">
90
+ <NuxtLink v-if="canCategories" to="/admin/categories" class="admin-nav-link" :title="desktopCollapsed ? 'Categories' : undefined" @click="closeMobile">
73
91
  <i class="fa-solid fa-tags"></i><span class="admin-nav-label">Categories</span>
74
92
  </NuxtLink>
75
- <NuxtLink to="/admin/reports" class="admin-nav-link" :title="desktopCollapsed ? 'Reports' : undefined" @click="closeMobile">
93
+ <NuxtLink v-if="canCategories" to="/admin/video-categories" class="admin-nav-link" :title="desktopCollapsed ? 'Video Categories' : undefined" @click="closeMobile">
94
+ <i class="fa-solid fa-film"></i><span class="admin-nav-label">Video Categories</span>
95
+ </NuxtLink>
96
+ <NuxtLink v-if="canReports" to="/admin/reports" class="admin-nav-link" :title="desktopCollapsed ? 'Reports' : undefined" @click="closeMobile">
76
97
  <i class="fa-solid fa-flag"></i><span class="admin-nav-label">Reports</span>
77
98
  </NuxtLink>
78
- <NuxtLink to="/admin/audit" class="admin-nav-link" :title="desktopCollapsed ? 'Audit Log' : undefined" @click="closeMobile">
99
+ <NuxtLink v-if="canAudit" to="/admin/audit" class="admin-nav-link" :title="desktopCollapsed ? 'Audit Log' : undefined" @click="closeMobile">
79
100
  <i class="fa-solid fa-clipboard-list"></i><span class="admin-nav-label">Audit Log</span>
80
101
  </NuxtLink>
81
- <NuxtLink to="/admin/theme" class="admin-nav-link" :title="desktopCollapsed ? 'Theme' : undefined" @click="closeMobile">
102
+ <NuxtLink v-if="canTheme" to="/admin/theme" class="admin-nav-link" :title="desktopCollapsed ? 'Theme' : undefined" @click="closeMobile">
82
103
  <i class="fa-solid fa-palette"></i><span class="admin-nav-label">Theme</span>
83
104
  </NuxtLink>
84
- <NuxtLink to="/admin/homepage" class="admin-nav-link" :title="desktopCollapsed ? 'Homepage' : undefined" @click="closeMobile">
105
+ <NuxtLink v-if="canLayout" to="/admin/homepage" class="admin-nav-link" :title="desktopCollapsed ? 'Homepage' : undefined" @click="closeMobile">
85
106
  <i class="fa-solid fa-house"></i><span class="admin-nav-label">Homepage</span>
86
107
  </NuxtLink>
87
- <!-- Layouts editor — gated on layoutEngine feature flag (CLAUDE.md rule #2).
88
- Stays invisible until the operator flips the flag, then appears between
89
- the legacy /admin/homepage editor and Navigation. Phase 3a, session 160 audit. -->
90
- <NuxtLink v-if="layoutEngine" to="/admin/layouts" class="admin-nav-link" :title="desktopCollapsed ? 'Layouts' : undefined" @click="closeMobile">
108
+ <!-- Layouts editor — gated on layoutEngine feature flag (CLAUDE.md rule #2)
109
+ AND layout.manage. Stays invisible until the operator flips the flag,
110
+ then appears between the legacy /admin/homepage editor and Navigation.
111
+ Phase 3a, session 160 audit. -->
112
+ <NuxtLink v-if="layoutEngine && canLayout" to="/admin/layouts" class="admin-nav-link" :title="desktopCollapsed ? 'Layouts' : undefined" @click="closeMobile">
91
113
  <i class="fa-solid fa-table-cells-large"></i><span class="admin-nav-label">Layouts</span>
92
114
  </NuxtLink>
93
- <NuxtLink to="/admin/navigation" class="admin-nav-link" :title="desktopCollapsed ? 'Navigation' : undefined" @click="closeMobile">
115
+ <NuxtLink v-if="canNavigation" to="/admin/navigation" class="admin-nav-link" :title="desktopCollapsed ? 'Navigation' : undefined" @click="closeMobile">
94
116
  <i class="fa-solid fa-bars"></i><span class="admin-nav-label">Navigation</span>
95
117
  </NuxtLink>
96
- <NuxtLink to="/admin/features" class="admin-nav-link" :title="desktopCollapsed ? 'Features' : undefined" @click="closeMobile">
118
+ <NuxtLink v-if="canSettings" to="/admin/features" class="admin-nav-link" :title="desktopCollapsed ? 'Features' : undefined" @click="closeMobile">
97
119
  <i class="fa-solid fa-toggle-on"></i><span class="admin-nav-label">Features</span>
98
120
  </NuxtLink>
99
- <NuxtLink to="/admin/federation" class="admin-nav-link" :title="desktopCollapsed ? 'Federation' : undefined" @click="closeMobile">
121
+ <NuxtLink v-if="canFederation" to="/admin/federation" class="admin-nav-link" :title="desktopCollapsed ? 'Federation' : undefined" @click="closeMobile">
100
122
  <i class="fa-solid fa-globe"></i><span class="admin-nav-label">Federation</span>
101
123
  </NuxtLink>
102
- <NuxtLink to="/admin/api-keys" class="admin-nav-link" :title="desktopCollapsed ? 'API Keys' : undefined" @click="closeMobile">
124
+ <NuxtLink v-if="publicApi && canApiKeys" to="/admin/api-keys" class="admin-nav-link" :title="desktopCollapsed ? 'API Keys' : undefined" @click="closeMobile">
103
125
  <i class="fa-solid fa-key"></i><span class="admin-nav-label">API Keys</span>
104
126
  </NuxtLink>
105
- <NuxtLink to="/admin/settings" class="admin-nav-link" :title="desktopCollapsed ? 'Settings' : undefined" @click="closeMobile">
127
+ <NuxtLink v-if="canSettings" to="/admin/settings" class="admin-nav-link" :title="desktopCollapsed ? 'Settings' : undefined" @click="closeMobile">
106
128
  <i class="fa-solid fa-gear"></i><span class="admin-nav-label">Settings</span>
107
129
  </NuxtLink>
108
130
  </nav>