@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
@@ -11,11 +11,9 @@ const siteSlug = computed(() => route.params.siteSlug as string);
11
11
  const { show: toast } = useToast();
12
12
 
13
13
  // Provide upload handler to block components (ImageBlock, GalleryBlock)
14
+ const { uploadFile } = useFileUpload();
14
15
  provide(UPLOAD_HANDLER_KEY, async (file: File) => {
15
- const formData = new FormData();
16
- formData.append('file', file);
17
- formData.append('purpose', 'content');
18
- const res = await $fetch<{ url: string; width?: number | null; height?: number | null }>('/api/files/upload', { method: 'POST', body: formData });
16
+ const res = await uploadFile<{ url: string; width?: number | null; height?: number | null }>(file, 'content');
19
17
  return { url: res.url, width: res.width ?? null, height: res.height ?? null };
20
18
  });
21
19
 
@@ -111,18 +109,48 @@ const pageSlug = ref('');
111
109
  const pageSidebarLabel = ref('');
112
110
  const pageDescription = ref('');
113
111
  const pageStatus = ref<'draft' | 'published'>('draft');
114
- const savingPage = ref(false);
115
- const autoSaveTimer = ref<ReturnType<typeof setTimeout> | null>(null);
116
- const autoSaveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle');
117
- const isDirty = ref(false);
118
- const isLoadingPage = ref(false); // Guard: suppresses dirty-marking during page load
119
112
  const markdownNotice = ref<string | null>(null); // Shows notice when markdown page is converted
120
113
 
114
+ // ═══ AUTOSAVE ENGINE ═══
115
+ // persistPage() does the actual PUT + tree refresh; useEditorAutosave owns the
116
+ // dirty flag, save-status machine, 5s trailing debounce, Cmd+S, and the
117
+ // unsaved-changes (beforeunload) guard. The destructured refs keep their
118
+ // historical names so the template + the rest of setup read unchanged.
119
+ async function persistPage(): Promise<void> {
120
+ await $fetch(`/api/docs/${siteSlug.value}/pages/${selectedPageId.value}`, {
121
+ method: 'PUT',
122
+ body: {
123
+ title: selectedPage.value?.title,
124
+ slug: pageSlug.value,
125
+ sidebarLabel: pageSidebarLabel.value || null,
126
+ description: pageDescription.value || null,
127
+ content: blockEditor.toBlockTuples(),
128
+ },
129
+ });
130
+ // Keep the page tree in sync with the saved slug/label.
131
+ await refreshPages();
132
+ }
133
+
134
+ const {
135
+ isDirty,
136
+ isLoading: isLoadingPage, // Guard: suppresses dirty-marking during page load
137
+ status: autoSaveStatus,
138
+ saving: savingPage,
139
+ markDirty,
140
+ saveNow,
141
+ reset: resetAutosave,
142
+ } = useEditorAutosave({
143
+ persist: persistPage,
144
+ canSave: () => !!selectedPageId.value,
145
+ debounceMs: 5000,
146
+ onError: (err: unknown) => toast(err instanceof Error ? err.message : 'Failed to save page', 'error'),
147
+ });
148
+
121
149
  // Load page content when selecting
122
150
  async function selectPage(pageId: string): Promise<void> {
123
151
  // Save current page first if dirty
124
152
  if (isDirty.value && selectedPageId.value) {
125
- await saveCurrentPage();
153
+ await saveNow();
126
154
  }
127
155
 
128
156
  selectedPageId.value = pageId;
@@ -151,44 +179,14 @@ async function selectPage(pageId: string): Promise<void> {
151
179
  pageSidebarLabel.value = (page as unknown as Record<string, unknown>).sidebarLabel as string ?? '';
152
180
  pageDescription.value = (page as unknown as Record<string, unknown>).description as string ?? '';
153
181
  pageStatus.value = ((page as unknown as Record<string, unknown>).status as 'draft' | 'published') || 'draft';
154
- isDirty.value = false;
155
- autoSaveStatus.value = 'idle';
182
+ resetAutosave();
156
183
 
157
184
  // Release guard after watchers have flushed
158
185
  await nextTick();
159
186
  isLoadingPage.value = false;
160
187
  }
161
188
 
162
- // ══�� SAVING ═══
163
- async function saveCurrentPage(): Promise<void> {
164
- if (!selectedPageId.value) return;
165
- savingPage.value = true;
166
- autoSaveStatus.value = 'saving';
167
-
168
- try {
169
- await $fetch(`/api/docs/${siteSlug.value}/pages/${selectedPageId.value}`, {
170
- method: 'PUT',
171
- body: {
172
- title: selectedPage.value?.title,
173
- slug: pageSlug.value,
174
- sidebarLabel: pageSidebarLabel.value || null,
175
- description: pageDescription.value || null,
176
- content: blockEditor.toBlockTuples(),
177
- },
178
- });
179
- isDirty.value = false;
180
- autoSaveStatus.value = 'saved';
181
- // Refresh pages list to keep tree in sync
182
- await refreshPages();
183
- } catch (err: unknown) {
184
- autoSaveStatus.value = 'error';
185
- toast(err instanceof Error ? err.message : 'Failed to save page', 'error');
186
- } finally {
187
- savingPage.value = false;
188
- }
189
- }
190
-
191
- // Autosave: debounce 5 seconds for docs (shorter than article 30s)
189
+ // ═══ PUBLISH / UNPUBLISH ═══
192
190
  async function publishPage(): Promise<void> {
193
191
  if (!selectedPageId.value) return;
194
192
  try {
@@ -219,47 +217,17 @@ async function unpublishPage(): Promise<void> {
219
217
  }
220
218
  }
221
219
 
222
- function scheduleAutoSave(): void {
223
- if (autoSaveTimer.value) clearTimeout(autoSaveTimer.value);
224
- autoSaveTimer.value = setTimeout(() => {
225
- if (isDirty.value && selectedPageId.value) {
226
- saveCurrentPage();
227
- }
228
- }, 5000);
229
- }
230
-
231
- // Watch for changes — skip during page load to avoid false dirty
220
+ // Watch for changes — markDirty() no-ops during page load (isLoadingPage guard)
221
+ // and arms the autosave engine's trailing debounce.
232
222
  watch(() => blockEditor.blocks.value, () => {
233
- if (isLoadingPage.value) return;
234
- isDirty.value = true;
235
- scheduleAutoSave();
223
+ markDirty();
236
224
  }, { deep: true });
237
225
 
238
226
  watch([pageSlug, pageSidebarLabel, pageDescription], () => {
239
- if (isLoadingPage.value) return;
240
- isDirty.value = true;
241
- scheduleAutoSave();
227
+ markDirty();
242
228
  });
243
229
 
244
- // Keyboard shortcut: Cmd+S to save
245
- function handleKeydown(e: KeyboardEvent): void {
246
- if ((e.metaKey || e.ctrlKey) && e.key === 's') {
247
- e.preventDefault();
248
- saveCurrentPage();
249
- }
250
- }
251
-
252
- // Warn about unsaved changes on navigation
253
- function handleBeforeUnload(e: BeforeUnloadEvent): void {
254
- if (isDirty.value) {
255
- e.preventDefault();
256
- }
257
- }
258
-
259
230
  onMounted(() => {
260
- document.addEventListener('keydown', handleKeydown);
261
- window.addEventListener('beforeunload', handleBeforeUnload);
262
-
263
231
  // Auto-select page from ?page= query or first page
264
232
  const requestedPage = route.query.page as string | undefined;
265
233
  if (requestedPage && pages.value.length > 0) {
@@ -275,112 +243,41 @@ onMounted(() => {
275
243
  }
276
244
  });
277
245
 
278
- onUnmounted(() => {
279
- document.removeEventListener('keydown', handleKeydown);
280
- window.removeEventListener('beforeunload', handleBeforeUnload);
281
- if (autoSaveTimer.value) clearTimeout(autoSaveTimer.value);
246
+ // Warn before in-app navigation (beforeunload only covers full-page unload)
247
+ onBeforeRouteLeave((_to, _from, next) => {
248
+ if (isDirty.value && !window.confirm('You have unsaved changes. Leave anyway?')) {
249
+ next(false);
250
+ } else {
251
+ next();
252
+ }
282
253
  });
283
254
 
284
255
  // ═══ PAGE TREE ACTIONS ═══
285
- const pendingReparent = ref(false);
286
- async function handleCreatePage(parentId: string | null, title: string): Promise<void> {
287
- try {
288
- const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
289
- const result = await $fetch(`/api/docs/${siteSlug.value}/pages`, {
290
- method: 'POST',
291
- body: {
292
- title,
293
- slug,
294
- content: [['paragraph', { html: '' }]],
295
- parentId: parentId ?? undefined,
296
- sortOrder: (pages.value?.length ?? 0) + 1,
297
- versionId: selectedVersionId.value,
298
- },
299
- });
300
- await refreshPages();
301
- if (result && typeof result === 'object' && 'id' in result) {
302
- selectPage((result as { id: string }).id);
303
- }
304
- toast('Page created', 'success');
305
- } catch (err: unknown) {
306
- toast(err instanceof Error ? err.message : 'Failed to create page', 'error');
307
- }
308
- }
309
-
310
- async function handleRenamePage(pageId: string, newTitle: string): Promise<void> {
311
- try {
312
- await $fetch(`/api/docs/${siteSlug.value}/pages/${pageId}`, {
313
- method: 'PUT',
314
- body: { title: newTitle },
315
- });
316
- await refreshPages();
317
- toast('Page renamed', 'success');
318
- } catch (err: unknown) {
319
- toast(err instanceof Error ? err.message : 'Failed to rename', 'error');
320
- }
321
- }
322
-
323
- async function handleDuplicatePage(pageId: string): Promise<void> {
324
- try {
325
- const result = await $fetch(`/api/docs/${siteSlug.value}/pages/${pageId}/duplicate`, {
326
- method: 'POST',
327
- });
328
- await refreshPages();
329
- if (result && typeof result === 'object' && 'id' in result) {
330
- selectPage((result as { id: string }).id);
331
- }
332
- toast('Page duplicated', 'success');
333
- } catch (err: unknown) {
334
- toast(err instanceof Error ? err.message : 'Failed to duplicate page', 'error');
335
- }
336
- }
337
-
338
- async function handleDeletePage(pageId: string): Promise<void> {
339
- try {
340
- await $fetch(`/api/docs/${siteSlug.value}/pages/${pageId}`, { method: 'DELETE' });
256
+ // CRUD orchestration (incl. the reparent/reorder deferred-refresh coordination)
257
+ // lives in useDocsPageTree; this page just supplies its context. Handler names
258
+ // are preserved for the template bindings + the inline title edit below.
259
+ const {
260
+ createPage: handleCreatePage,
261
+ renamePage: handleRenamePage,
262
+ duplicatePage: handleDuplicatePage,
263
+ deletePage: handleDeletePage,
264
+ reorder: handleReorder,
265
+ reparent: handleReparent,
266
+ } = useDocsPageTree({
267
+ siteSlug: () => siteSlug.value,
268
+ versionId: () => selectedVersionId.value,
269
+ version: () => selectedVersion.value,
270
+ pageCount: () => pages.value.length,
271
+ refreshPages,
272
+ selectPage,
273
+ onDeleted: (pageId) => {
341
274
  if (selectedPageId.value === pageId) {
342
275
  selectedPageId.value = null;
343
276
  blockEditor.clearBlocks();
344
277
  }
345
- await refreshPages();
346
- toast('Page deleted', 'success');
347
- } catch (err: unknown) {
348
- toast(err instanceof Error ? err.message : 'Failed to delete', 'error');
349
- }
350
- }
351
-
352
- async function handleReorder(pageIds: string[]): Promise<void> {
353
- pendingReparent.value = false; // Cancel reparent's deferred refresh
354
- try {
355
- await $fetch(`/api/docs/${siteSlug.value}/pages/reorder`, {
356
- method: 'POST',
357
- body: { pageIds, version: selectedVersion.value || undefined },
358
- });
359
- await refreshPages();
360
- } catch {
361
- toast('Failed to reorder', 'error');
362
- }
363
- }
364
-
365
- async function handleReparent(pageId: string, newParentId: string | null): Promise<void> {
366
- try {
367
- await $fetch(`/api/docs/${siteSlug.value}/pages/${pageId}`, {
368
- method: 'PUT',
369
- body: { parentId: newParentId ?? null },
370
- });
371
- // Don't refresh here — if reorder follows immediately, let reorder refresh
372
- // If reparent is standalone (drag inside), refresh
373
- pendingReparent.value = true;
374
- setTimeout(async () => {
375
- if (pendingReparent.value) {
376
- pendingReparent.value = false;
377
- await refreshPages();
378
- }
379
- }, 100);
380
- } catch {
381
- toast('Failed to move page', 'error');
382
- }
383
- }
278
+ },
279
+ toast,
280
+ });
384
281
 
385
282
 
386
283
  // ═══ PAGE TITLE EDITING ═══
@@ -424,18 +321,29 @@ function handleMarkdownImport(md: string, mode: 'append' | 'replace'): void {
424
321
  const { importMarkdown } = useMarkdownImport(blockEditor);
425
322
  importMarkdown(md, mode);
426
323
  showImportDialog.value = false;
427
- isDirty.value = true;
428
- scheduleAutoSave();
324
+ markDirty();
429
325
  }
430
326
 
431
327
  // ═══ SITE SETTINGS ═══
432
- const showSettings = ref(false);
433
- const settingsName = ref('');
434
- const settingsDesc = ref('');
435
- const savingSettings = ref(false);
436
- const newVersion = ref('');
437
- const newVersionDefault = ref(false);
438
- const savingVersion = ref(false);
328
+ // Settings/versions actions live in useDocsSiteSettings (composable extraction).
329
+ // The page keeps the watch(site) seeding below, since it owns the site fetch.
330
+ const {
331
+ showSettings,
332
+ settingsName,
333
+ settingsDesc,
334
+ savingSettings,
335
+ newVersion,
336
+ newVersionDefault,
337
+ savingVersion,
338
+ saveSiteSettings,
339
+ deleteSite,
340
+ createVersion,
341
+ } = useDocsSiteSettings({
342
+ siteSlug: () => siteSlug.value,
343
+ refreshSite,
344
+ onSiteDeleted: async () => { await navigateTo('/docs'); },
345
+ toast,
346
+ });
439
347
 
440
348
  interface DocsSiteVersion {
441
349
  id: string;
@@ -448,52 +356,6 @@ watch(site, (s) => {
448
356
  settingsName.value = (s as Record<string, unknown>).name as string ?? '';
449
357
  settingsDesc.value = (s as Record<string, unknown>).description as string ?? '';
450
358
  }, { immediate: true });
451
-
452
- async function saveSiteSettings(): Promise<void> {
453
- savingSettings.value = true;
454
- try {
455
- await $fetch(`/api/docs/${siteSlug.value}`, {
456
- method: 'PUT',
457
- body: { name: settingsName.value, description: settingsDesc.value },
458
- });
459
- toast('Site settings updated', 'success');
460
- await refreshSite();
461
- } catch (err: unknown) {
462
- toast(err instanceof Error ? err.message : 'Failed to update settings', 'error');
463
- } finally {
464
- savingSettings.value = false;
465
- }
466
- }
467
-
468
- async function deleteSite(): Promise<void> {
469
- if (!confirm('Delete this entire docs site? All pages and versions will be permanently deleted.')) return;
470
- try {
471
- await $fetch(`/api/docs/${siteSlug.value}`, { method: 'DELETE' });
472
- toast('Docs site deleted', 'success');
473
- await navigateTo('/docs');
474
- } catch {
475
- toast('Failed to delete docs site', 'error');
476
- }
477
- }
478
-
479
- async function createVersion(): Promise<void> {
480
- if (!newVersion.value.trim()) return;
481
- savingVersion.value = true;
482
- try {
483
- await $fetch(`/api/docs/${siteSlug.value}/versions`, {
484
- method: 'POST',
485
- body: { version: newVersion.value, isDefault: newVersionDefault.value },
486
- });
487
- toast('Version created', 'success');
488
- newVersion.value = '';
489
- newVersionDefault.value = false;
490
- await refreshSite();
491
- } catch (err: unknown) {
492
- toast(err instanceof Error ? err.message : 'Failed to create version', 'error');
493
- } finally {
494
- savingVersion.value = false;
495
- }
496
- }
497
359
  </script>
498
360
 
499
361
  <template>
@@ -536,7 +398,7 @@ async function createVersion(): Promise<void> {
536
398
  class="cpub-docs-toolbar-btn"
537
399
  :class="{ 'cpub-docs-toolbar-btn-saving': savingPage }"
538
400
  :disabled="!isDirty || savingPage"
539
- @click="saveCurrentPage"
401
+ @click="saveNow"
540
402
  >
541
403
  <i class="fa-solid" :class="savingPage ? 'fa-spinner fa-spin' : 'fa-floppy-disk'" />
542
404
  <span>{{ savingPage ? 'Saving' : isDirty ? 'Save' : 'Saved' }}</span>
@@ -85,29 +85,29 @@ async function submit(): Promise<void> {
85
85
 
86
86
  <form class="cpub-form" @submit.prevent="submit">
87
87
  <div class="cpub-form-field">
88
- <label class="cpub-form-label">Title *</label>
89
- <input v-model="form.title" class="cpub-form-input" required />
88
+ <label for="ev-title" class="cpub-form-label">Title *</label>
89
+ <input id="ev-title" v-model="form.title" class="cpub-form-input" required />
90
90
  </div>
91
91
 
92
92
  <div class="cpub-form-field">
93
- <label class="cpub-form-label">Description</label>
94
- <textarea v-model="form.description" class="cpub-form-textarea" rows="4" />
93
+ <label for="ev-description" class="cpub-form-label">Description</label>
94
+ <textarea id="ev-description" v-model="form.description" class="cpub-form-textarea" rows="4" />
95
95
  </div>
96
96
 
97
97
  <ImageUpload v-model="form.coverImage" purpose="cover" label="Cover Image" hint="Recommended: 16:9 aspect ratio" />
98
98
 
99
99
  <div class="cpub-form-row">
100
100
  <div class="cpub-form-field">
101
- <label class="cpub-form-label">Type</label>
102
- <select v-model="form.eventType" class="cpub-form-input">
101
+ <label for="ev-type" class="cpub-form-label">Type</label>
102
+ <select id="ev-type" v-model="form.eventType" class="cpub-form-input">
103
103
  <option value="in-person">In-Person</option>
104
104
  <option value="online">Online</option>
105
105
  <option value="hybrid">Hybrid</option>
106
106
  </select>
107
107
  </div>
108
108
  <div class="cpub-form-field">
109
- <label class="cpub-form-label">Status</label>
110
- <select v-model="form.status" class="cpub-form-input">
109
+ <label for="ev-status" class="cpub-form-label">Status</label>
110
+ <select id="ev-status" v-model="form.status" class="cpub-form-input">
111
111
  <option value="draft">Draft</option>
112
112
  <option value="published">Published</option>
113
113
  <option value="active">Active</option>
@@ -119,19 +119,19 @@ async function submit(): Promise<void> {
119
119
 
120
120
  <div class="cpub-form-row">
121
121
  <div class="cpub-form-field">
122
- <label class="cpub-form-label">Start Date *</label>
123
- <input v-model="form.startDate" type="datetime-local" class="cpub-form-input" required />
122
+ <label for="ev-start" class="cpub-form-label">Start Date *</label>
123
+ <input id="ev-start" v-model="form.startDate" type="datetime-local" class="cpub-form-input" required />
124
124
  </div>
125
125
  <div class="cpub-form-field">
126
- <label class="cpub-form-label">End Date *</label>
127
- <input v-model="form.endDate" type="datetime-local" class="cpub-form-input" required />
126
+ <label for="ev-end" class="cpub-form-label">End Date *</label>
127
+ <input id="ev-end" v-model="form.endDate" type="datetime-local" class="cpub-form-input" required />
128
128
  </div>
129
129
  </div>
130
130
 
131
131
  <div class="cpub-form-row">
132
132
  <div class="cpub-form-field">
133
- <label class="cpub-form-label">Capacity</label>
134
- <input v-model.number="form.capacity" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" />
133
+ <label for="ev-capacity" class="cpub-form-label">Capacity</label>
134
+ <input id="ev-capacity" v-model.number="form.capacity" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" />
135
135
  </div>
136
136
  <div class="cpub-form-field">
137
137
  <label class="cpub-form-label cpub-form-checkbox-label">
@@ -142,18 +142,18 @@ async function submit(): Promise<void> {
142
142
 
143
143
  <div v-if="form.eventType !== 'online'" class="cpub-form-row">
144
144
  <div class="cpub-form-field">
145
- <label class="cpub-form-label">Location</label>
146
- <input v-model="form.location" class="cpub-form-input" placeholder="Venue name / address" />
145
+ <label for="ev-location" class="cpub-form-label">Location</label>
146
+ <input id="ev-location" v-model="form.location" class="cpub-form-input" placeholder="Venue name / address" />
147
147
  </div>
148
148
  <div class="cpub-form-field">
149
- <label class="cpub-form-label">Location URL</label>
150
- <input v-model="form.locationUrl" type="url" class="cpub-form-input" placeholder="Map link" />
149
+ <label for="ev-location-url" class="cpub-form-label">Location URL</label>
150
+ <input id="ev-location-url" v-model="form.locationUrl" type="url" class="cpub-form-input" placeholder="Map link" />
151
151
  </div>
152
152
  </div>
153
153
 
154
154
  <div v-if="form.eventType !== 'in-person'" class="cpub-form-field">
155
- <label class="cpub-form-label">Online URL</label>
156
- <input v-model="form.onlineUrl" type="url" class="cpub-form-input" placeholder="Meeting link" />
155
+ <label for="ev-online-url" class="cpub-form-label">Online URL</label>
156
+ <input id="ev-online-url" v-model="form.onlineUrl" type="url" class="cpub-form-input" placeholder="Meeting link" />
157
157
  </div>
158
158
 
159
159
  <div class="cpub-form-actions">
@@ -66,57 +66,57 @@ async function submit(): Promise<void> {
66
66
 
67
67
  <form class="cpub-form" @submit.prevent="submit">
68
68
  <div class="cpub-form-field">
69
- <label class="cpub-form-label">Title *</label>
70
- <input v-model="form.title" class="cpub-form-input" placeholder="Event title" required />
69
+ <label for="ev-title" class="cpub-form-label">Title *</label>
70
+ <input id="ev-title" v-model="form.title" class="cpub-form-input" placeholder="Event title" required />
71
71
  </div>
72
72
 
73
73
  <div class="cpub-form-field">
74
- <label class="cpub-form-label">Description</label>
75
- <textarea v-model="form.description" class="cpub-form-textarea" rows="4" placeholder="Describe the event..." />
74
+ <label for="ev-description" class="cpub-form-label">Description</label>
75
+ <textarea id="ev-description" v-model="form.description" class="cpub-form-textarea" rows="4" placeholder="Describe the event..." />
76
76
  </div>
77
77
 
78
78
  <ImageUpload v-model="form.coverImage" purpose="cover" label="Cover Image" hint="Recommended: 16:9 aspect ratio" />
79
79
 
80
80
  <div class="cpub-form-row">
81
81
  <div class="cpub-form-field">
82
- <label class="cpub-form-label">Type</label>
83
- <select v-model="form.eventType" class="cpub-form-input">
82
+ <label for="ev-type" class="cpub-form-label">Type</label>
83
+ <select id="ev-type" v-model="form.eventType" class="cpub-form-input">
84
84
  <option value="in-person">In-Person</option>
85
85
  <option value="online">Online</option>
86
86
  <option value="hybrid">Hybrid</option>
87
87
  </select>
88
88
  </div>
89
89
  <div class="cpub-form-field">
90
- <label class="cpub-form-label">Capacity</label>
91
- <input v-model.number="form.capacity" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" />
90
+ <label for="ev-capacity" class="cpub-form-label">Capacity</label>
91
+ <input id="ev-capacity" v-model.number="form.capacity" type="number" min="1" class="cpub-form-input" placeholder="Unlimited" />
92
92
  </div>
93
93
  </div>
94
94
 
95
95
  <div class="cpub-form-row">
96
96
  <div class="cpub-form-field">
97
- <label class="cpub-form-label">Start Date *</label>
98
- <input v-model="form.startDate" type="datetime-local" class="cpub-form-input" required />
97
+ <label for="ev-start" class="cpub-form-label">Start Date *</label>
98
+ <input id="ev-start" v-model="form.startDate" type="datetime-local" class="cpub-form-input" required />
99
99
  </div>
100
100
  <div class="cpub-form-field">
101
- <label class="cpub-form-label">End Date *</label>
102
- <input v-model="form.endDate" type="datetime-local" class="cpub-form-input" required />
101
+ <label for="ev-end" class="cpub-form-label">End Date *</label>
102
+ <input id="ev-end" v-model="form.endDate" type="datetime-local" class="cpub-form-input" required />
103
103
  </div>
104
104
  </div>
105
105
 
106
106
  <div v-if="form.eventType !== 'online'" class="cpub-form-row">
107
107
  <div class="cpub-form-field">
108
- <label class="cpub-form-label">Location</label>
109
- <input v-model="form.location" class="cpub-form-input" placeholder="Venue name / address" />
108
+ <label for="ev-location" class="cpub-form-label">Location</label>
109
+ <input id="ev-location" v-model="form.location" class="cpub-form-input" placeholder="Venue name / address" />
110
110
  </div>
111
111
  <div class="cpub-form-field">
112
- <label class="cpub-form-label">Location URL</label>
113
- <input v-model="form.locationUrl" type="url" class="cpub-form-input" placeholder="Map link" />
112
+ <label for="ev-location-url" class="cpub-form-label">Location URL</label>
113
+ <input id="ev-location-url" v-model="form.locationUrl" type="url" class="cpub-form-input" placeholder="Map link" />
114
114
  </div>
115
115
  </div>
116
116
 
117
117
  <div v-if="form.eventType !== 'in-person'" class="cpub-form-field">
118
- <label class="cpub-form-label">Online URL</label>
119
- <input v-model="form.onlineUrl" type="url" class="cpub-form-input" placeholder="Meeting link" />
118
+ <label for="ev-online-url" class="cpub-form-label">Online URL</label>
119
+ <input id="ev-online-url" v-model="form.onlineUrl" type="url" class="cpub-form-input" placeholder="Meeting link" />
120
120
  </div>
121
121
 
122
122
  <div class="cpub-form-actions">
@@ -30,7 +30,7 @@ const queryParams = computed(() => {
30
30
  return q;
31
31
  });
32
32
 
33
- const { data, refresh } = await useFetch<{ items: EventListItem[]; total: number }>('/api/events', {
33
+ const { data, refresh, error } = await useFetch<{ items: EventListItem[]; total: number }>('/api/events', {
34
34
  query: queryParams,
35
35
  });
36
36
 
@@ -109,7 +109,12 @@ function setView(mode: 'grid' | 'calendar'): void {
109
109
  <EventCalendar :events="data?.items ?? []" />
110
110
  </template>
111
111
  <template v-else>
112
- <div v-if="data?.items?.length" class="cpub-events-grid">
112
+ <div v-if="error" class="cpub-fetch-error">
113
+ <div class="cpub-fetch-error-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
114
+ <div class="cpub-fetch-error-msg">Failed to load events.</div>
115
+ <button class="cpub-btn cpub-btn-sm" @click="refresh()">Retry</button>
116
+ </div>
117
+ <div v-else-if="data?.items?.length" class="cpub-events-grid">
113
118
  <EventCard v-for="event in data.items" :key="event.id" :event="event" />
114
119
  </div>
115
120
  <div v-else class="cpub-empty-state">