@commonpub/layer 0.1.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 (436) hide show
  1. package/LICENSE +661 -0
  2. package/app.vue +7 -0
  3. package/components/AnnouncementBand.vue +117 -0
  4. package/components/AppToast.vue +108 -0
  5. package/components/AuthorCard.vue +119 -0
  6. package/components/AuthorRow.vue +81 -0
  7. package/components/CommentSection.vue +330 -0
  8. package/components/ContentCard.vue +340 -0
  9. package/components/ContentPicker.vue +240 -0
  10. package/components/ContentStarterForm.vue +214 -0
  11. package/components/ContentTypeBadge.vue +46 -0
  12. package/components/CountdownTimer.vue +68 -0
  13. package/components/CpubEditor.vue +87 -0
  14. package/components/DiscussionItem.vue +191 -0
  15. package/components/EditorPropertiesPanel.vue +393 -0
  16. package/components/EngagementBar.vue +131 -0
  17. package/components/FederatedContentCard.vue +291 -0
  18. package/components/FeedItem.vue +283 -0
  19. package/components/FilterChip.vue +21 -0
  20. package/components/HeatmapGrid.vue +92 -0
  21. package/components/ImageUpload.vue +219 -0
  22. package/components/MemberCard.vue +163 -0
  23. package/components/MessageThread.vue +120 -0
  24. package/components/NotificationItem.vue +103 -0
  25. package/components/ProgressTracker.vue +41 -0
  26. package/components/PublishErrorsModal.vue +116 -0
  27. package/components/RemoteActorCard.vue +206 -0
  28. package/components/RemoteUserSearch.vue +117 -0
  29. package/components/SearchFilters.vue +188 -0
  30. package/components/SearchSidebar.vue +181 -0
  31. package/components/SectionHeader.vue +17 -0
  32. package/components/ShareToHubModal.vue +189 -0
  33. package/components/SiteLogo.vue +21 -0
  34. package/components/SkillBar.vue +57 -0
  35. package/components/SortSelect.vue +30 -0
  36. package/components/StatBar.vue +14 -0
  37. package/components/TOCNav.vue +69 -0
  38. package/components/TimelineItem.vue +82 -0
  39. package/components/VideoCard.vue +106 -0
  40. package/components/blocks/BlockBuildStepView.vue +92 -0
  41. package/components/blocks/BlockCalloutView.vue +82 -0
  42. package/components/blocks/BlockCheckpointView.vue +50 -0
  43. package/components/blocks/BlockCodeView.vue +212 -0
  44. package/components/blocks/BlockContentRenderer.vue +143 -0
  45. package/components/blocks/BlockDividerView.vue +11 -0
  46. package/components/blocks/BlockDownloadsView.vue +126 -0
  47. package/components/blocks/BlockEmbedView.vue +61 -0
  48. package/components/blocks/BlockGalleryView.vue +57 -0
  49. package/components/blocks/BlockHeadingView.vue +29 -0
  50. package/components/blocks/BlockImageView.vue +34 -0
  51. package/components/blocks/BlockMarkdownView.vue +118 -0
  52. package/components/blocks/BlockMathView.vue +45 -0
  53. package/components/blocks/BlockPartsListView.vue +104 -0
  54. package/components/blocks/BlockQuizView.vue +239 -0
  55. package/components/blocks/BlockQuoteView.vue +41 -0
  56. package/components/blocks/BlockSectionHeaderView.vue +58 -0
  57. package/components/blocks/BlockSliderView.vue +236 -0
  58. package/components/blocks/BlockTextView.vue +41 -0
  59. package/components/blocks/BlockToolListView.vue +87 -0
  60. package/components/blocks/BlockVideoView.vue +89 -0
  61. package/components/editors/ArticleEditor.vue +545 -0
  62. package/components/editors/BlockCanvas.vue +487 -0
  63. package/components/editors/BlockInsertZone.vue +84 -0
  64. package/components/editors/BlockPicker.vue +285 -0
  65. package/components/editors/BlockWrapper.vue +192 -0
  66. package/components/editors/BlogEditor.vue +567 -0
  67. package/components/editors/EditorBlocks.vue +248 -0
  68. package/components/editors/EditorSection.vue +81 -0
  69. package/components/editors/EditorShell.vue +168 -0
  70. package/components/editors/EditorTagInput.vue +114 -0
  71. package/components/editors/EditorVisibility.vue +110 -0
  72. package/components/editors/ExplainerEditor.vue +503 -0
  73. package/components/editors/MarkdownImportDialog.vue +249 -0
  74. package/components/editors/ProjectEditor.vue +446 -0
  75. package/components/editors/blocks/BuildStepBlock.vue +102 -0
  76. package/components/editors/blocks/CalloutBlock.vue +122 -0
  77. package/components/editors/blocks/CheckpointBlock.vue +27 -0
  78. package/components/editors/blocks/CodeBlock.vue +177 -0
  79. package/components/editors/blocks/DividerBlock.vue +22 -0
  80. package/components/editors/blocks/DownloadsBlock.vue +41 -0
  81. package/components/editors/blocks/EmbedBlock.vue +20 -0
  82. package/components/editors/blocks/GalleryBlock.vue +236 -0
  83. package/components/editors/blocks/HeadingBlock.vue +96 -0
  84. package/components/editors/blocks/ImageBlock.vue +271 -0
  85. package/components/editors/blocks/MarkdownBlock.vue +258 -0
  86. package/components/editors/blocks/MathBlock.vue +37 -0
  87. package/components/editors/blocks/PartsListBlock.vue +358 -0
  88. package/components/editors/blocks/QuizBlock.vue +47 -0
  89. package/components/editors/blocks/QuoteBlock.vue +101 -0
  90. package/components/editors/blocks/SectionHeaderBlock.vue +130 -0
  91. package/components/editors/blocks/SliderBlock.vue +318 -0
  92. package/components/editors/blocks/TextBlock.vue +201 -0
  93. package/components/editors/blocks/ToolListBlock.vue +70 -0
  94. package/components/editors/blocks/VideoBlock.vue +22 -0
  95. package/components/hub/HubDiscussions.vue +47 -0
  96. package/components/hub/HubFeed.vue +199 -0
  97. package/components/hub/HubHero.vue +185 -0
  98. package/components/hub/HubLayout.vue +103 -0
  99. package/components/hub/HubMembers.vue +40 -0
  100. package/components/hub/HubProducts.vue +93 -0
  101. package/components/hub/HubProjects.vue +207 -0
  102. package/components/hub/HubSidebar.vue +12 -0
  103. package/components/hub/HubSidebarCard.vue +35 -0
  104. package/components/views/ArticleView.vue +771 -0
  105. package/components/views/BlogView.vue +667 -0
  106. package/components/views/ExplainerView.vue +688 -0
  107. package/components/views/ProjectView.vue +1500 -0
  108. package/composables/useApiError.ts +39 -0
  109. package/composables/useAuth.ts +87 -0
  110. package/composables/useBlockEditor.ts +187 -0
  111. package/composables/useContentSave.ts +253 -0
  112. package/composables/useContentTypes.ts +37 -0
  113. package/composables/useEngagement.ts +196 -0
  114. package/composables/useFeatures.ts +33 -0
  115. package/composables/useFederation.ts +72 -0
  116. package/composables/useJsonLd.ts +183 -0
  117. package/composables/useMarkdownImport.ts +77 -0
  118. package/composables/useMirrorContent.ts +105 -0
  119. package/composables/useNotifications.ts +73 -0
  120. package/composables/usePublishValidation.ts +65 -0
  121. package/composables/useSanitize.ts +34 -0
  122. package/composables/useSiteName.ts +4 -0
  123. package/composables/useTheme.ts +34 -0
  124. package/composables/useToast.ts +35 -0
  125. package/error.vue +129 -0
  126. package/layouts/admin.vue +213 -0
  127. package/layouts/auth.vue +63 -0
  128. package/layouts/default.vue +269 -0
  129. package/layouts/editor.vue +129 -0
  130. package/middleware/auth.ts +6 -0
  131. package/nuxt.config.ts +83 -0
  132. package/package.json +59 -0
  133. package/pages/[type]/[slug]/edit.vue +676 -0
  134. package/pages/[type]/[slug]/index.vue +313 -0
  135. package/pages/[type]/index.vue +118 -0
  136. package/pages/about.vue +100 -0
  137. package/pages/admin/audit.vue +66 -0
  138. package/pages/admin/content.vue +116 -0
  139. package/pages/admin/federation.vue +446 -0
  140. package/pages/admin/index.vue +62 -0
  141. package/pages/admin/reports.vue +88 -0
  142. package/pages/admin/settings.vue +167 -0
  143. package/pages/admin/users.vue +145 -0
  144. package/pages/auth/forgot-password.vue +103 -0
  145. package/pages/auth/login.vue +216 -0
  146. package/pages/auth/oauth/authorize.vue +178 -0
  147. package/pages/auth/register.vue +246 -0
  148. package/pages/auth/reset-password.vue +124 -0
  149. package/pages/auth/verify-email.vue +80 -0
  150. package/pages/cert/[code].vue +154 -0
  151. package/pages/contests/[slug]/edit.vue +153 -0
  152. package/pages/contests/[slug]/index.vue +556 -0
  153. package/pages/contests/[slug]/judge.vue +160 -0
  154. package/pages/contests/create.vue +192 -0
  155. package/pages/contests/index.vue +69 -0
  156. package/pages/create.vue +219 -0
  157. package/pages/dashboard.vue +521 -0
  158. package/pages/docs/[siteSlug]/[...pagePath].vue +704 -0
  159. package/pages/docs/[siteSlug]/edit.vue +480 -0
  160. package/pages/docs/[siteSlug]/index.vue +380 -0
  161. package/pages/docs/create.vue +60 -0
  162. package/pages/docs/index.vue +181 -0
  163. package/pages/explore.vue +422 -0
  164. package/pages/federated-hubs/[id]/index.vue +385 -0
  165. package/pages/federated-hubs/[id]/posts/[postId].vue +309 -0
  166. package/pages/federation/index.vue +157 -0
  167. package/pages/federation/search.vue +43 -0
  168. package/pages/federation/users/[handle].vue +221 -0
  169. package/pages/feed.vue +135 -0
  170. package/pages/hubs/[slug]/index.vue +513 -0
  171. package/pages/hubs/[slug]/members.vue +134 -0
  172. package/pages/hubs/[slug]/posts/[postId].vue +352 -0
  173. package/pages/hubs/[slug]/settings.vue +254 -0
  174. package/pages/hubs/create.vue +201 -0
  175. package/pages/hubs/index.vue +207 -0
  176. package/pages/index.vue +1005 -0
  177. package/pages/learn/[slug]/[lessonSlug]/edit.vue +413 -0
  178. package/pages/learn/[slug]/[lessonSlug]/index.vue +438 -0
  179. package/pages/learn/[slug]/edit.vue +414 -0
  180. package/pages/learn/[slug]/index.vue +341 -0
  181. package/pages/learn/create.vue +71 -0
  182. package/pages/learn/index.vue +360 -0
  183. package/pages/messages/[conversationId].vue +113 -0
  184. package/pages/messages/index.vue +303 -0
  185. package/pages/mirror/[id].vue +115 -0
  186. package/pages/notifications.vue +91 -0
  187. package/pages/products/[slug].vue +128 -0
  188. package/pages/products/index.vue +122 -0
  189. package/pages/search.vue +692 -0
  190. package/pages/settings/account.vue +170 -0
  191. package/pages/settings/appearance.vue +80 -0
  192. package/pages/settings/index.vue +81 -0
  193. package/pages/settings/notifications.vue +68 -0
  194. package/pages/settings/profile.vue +838 -0
  195. package/pages/tags/[slug].vue +111 -0
  196. package/pages/tags/index.vue +73 -0
  197. package/pages/u/[username]/followers.vue +86 -0
  198. package/pages/u/[username]/following.vue +94 -0
  199. package/pages/u/[username]/index.vue +837 -0
  200. package/pages/videos/[id].vue +212 -0
  201. package/pages/videos/index.vue +327 -0
  202. package/pages/videos/submit.vue +112 -0
  203. package/plugins/auth.ts +23 -0
  204. package/server/api/admin/audit.get.ts +17 -0
  205. package/server/api/admin/content/[id].delete.ts +15 -0
  206. package/server/api/admin/content/[id].patch.ts +37 -0
  207. package/server/api/admin/federation/activity.get.ts +31 -0
  208. package/server/api/admin/federation/clients.get.ts +9 -0
  209. package/server/api/admin/federation/clients.post.ts +16 -0
  210. package/server/api/admin/federation/hub-mirrors/index.get.ts +10 -0
  211. package/server/api/admin/federation/hub-mirrors/index.post.ts +42 -0
  212. package/server/api/admin/federation/mirrors/[id]/backfill.post.ts +39 -0
  213. package/server/api/admin/federation/mirrors/[id].delete.ts +11 -0
  214. package/server/api/admin/federation/mirrors/[id].get.ts +15 -0
  215. package/server/api/admin/federation/mirrors/[id].put.ts +22 -0
  216. package/server/api/admin/federation/mirrors/index.get.ts +9 -0
  217. package/server/api/admin/federation/mirrors/index.post.ts +30 -0
  218. package/server/api/admin/federation/pending.get.ts +22 -0
  219. package/server/api/admin/federation/refederate.post.ts +92 -0
  220. package/server/api/admin/federation/repair-types.post.ts +57 -0
  221. package/server/api/admin/federation/retry.post.ts +41 -0
  222. package/server/api/admin/federation/stats.get.ts +26 -0
  223. package/server/api/admin/reports/[id]/resolve.post.ts +12 -0
  224. package/server/api/admin/reports.get.ts +17 -0
  225. package/server/api/admin/settings.get.ts +22 -0
  226. package/server/api/admin/settings.put.ts +11 -0
  227. package/server/api/admin/stats.get.ts +9 -0
  228. package/server/api/admin/users/[id]/role.put.ts +12 -0
  229. package/server/api/admin/users/[id]/status.put.ts +12 -0
  230. package/server/api/admin/users/[id].delete.ts +10 -0
  231. package/server/api/admin/users.get.ts +18 -0
  232. package/server/api/auth/federated/callback.get.ts +67 -0
  233. package/server/api/auth/federated/login.post.ts +60 -0
  234. package/server/api/auth/oauth2/authorize.get.ts +30 -0
  235. package/server/api/auth/oauth2/authorize.post.ts +51 -0
  236. package/server/api/auth/oauth2/register.post.ts +41 -0
  237. package/server/api/auth/oauth2/token.post.ts +48 -0
  238. package/server/api/cert/[code].get.ts +13 -0
  239. package/server/api/content/[id]/build.post.ts +9 -0
  240. package/server/api/content/[id]/fork.post.ts +10 -0
  241. package/server/api/content/[id]/index.delete.ts +18 -0
  242. package/server/api/content/[id]/index.get.ts +15 -0
  243. package/server/api/content/[id]/index.put.ts +23 -0
  244. package/server/api/content/[id]/products/[productId].delete.ts +28 -0
  245. package/server/api/content/[id]/products-sync.post.ts +29 -0
  246. package/server/api/content/[id]/products.get.ts +9 -0
  247. package/server/api/content/[id]/products.post.ts +31 -0
  248. package/server/api/content/[id]/publish.post.ts +17 -0
  249. package/server/api/content/[id]/report.post.ts +17 -0
  250. package/server/api/content/[id]/versions.get.ts +9 -0
  251. package/server/api/content/[id]/view.post.ts +34 -0
  252. package/server/api/content/index.get.ts +23 -0
  253. package/server/api/content/index.post.ts +11 -0
  254. package/server/api/contests/[slug]/entries.get.ts +18 -0
  255. package/server/api/contests/[slug]/entries.post.ts +23 -0
  256. package/server/api/contests/[slug]/index.delete.ts +21 -0
  257. package/server/api/contests/[slug]/index.get.ts +11 -0
  258. package/server/api/contests/[slug]/index.put.ts +15 -0
  259. package/server/api/contests/[slug]/judge.post.ts +12 -0
  260. package/server/api/contests/[slug]/transition.post.ts +24 -0
  261. package/server/api/contests/index.get.ts +10 -0
  262. package/server/api/contests/index.post.ts +28 -0
  263. package/server/api/docs/[siteSlug]/index.delete.ts +14 -0
  264. package/server/api/docs/[siteSlug]/index.get.ts +17 -0
  265. package/server/api/docs/[siteSlug]/index.put.ts +20 -0
  266. package/server/api/docs/[siteSlug]/nav.get.ts +26 -0
  267. package/server/api/docs/[siteSlug]/pages/[pageId].delete.ts +14 -0
  268. package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +31 -0
  269. package/server/api/docs/[siteSlug]/pages/[pageId].put.ts +15 -0
  270. package/server/api/docs/[siteSlug]/pages/index.get.ts +34 -0
  271. package/server/api/docs/[siteSlug]/pages/index.post.ts +28 -0
  272. package/server/api/docs/[siteSlug]/pages/reorder.post.ts +26 -0
  273. package/server/api/docs/[siteSlug]/search.get.ts +20 -0
  274. package/server/api/docs/[siteSlug]/versions.post.ts +11 -0
  275. package/server/api/docs/index.get.ts +6 -0
  276. package/server/api/docs/index.post.ts +10 -0
  277. package/server/api/federated-hubs/[id]/posts/[postId].get.ts +16 -0
  278. package/server/api/federated-hubs/[id]/posts.get.ts +15 -0
  279. package/server/api/federated-hubs/[id].get.ts +16 -0
  280. package/server/api/federation/boost.post.ts +21 -0
  281. package/server/api/federation/content/[id].get.ts +15 -0
  282. package/server/api/federation/follow.post.ts +16 -0
  283. package/server/api/federation/health.get.ts +56 -0
  284. package/server/api/federation/hub-follow.post.ts +27 -0
  285. package/server/api/federation/hub-post-like.post.ts +115 -0
  286. package/server/api/federation/hub-post-likes.get.ts +24 -0
  287. package/server/api/federation/hub-post-reply.post.ts +42 -0
  288. package/server/api/federation/hub-post.post.ts +33 -0
  289. package/server/api/federation/like.post.ts +21 -0
  290. package/server/api/federation/remote-actor.get.ts +22 -0
  291. package/server/api/federation/reply.post.ts +22 -0
  292. package/server/api/federation/search.post.ts +17 -0
  293. package/server/api/federation/timeline.get.ts +22 -0
  294. package/server/api/federation/unfollow.post.ts +17 -0
  295. package/server/api/files/[id].delete.ts +35 -0
  296. package/server/api/files/mine.get.ts +31 -0
  297. package/server/api/files/upload-from-url.post.ts +68 -0
  298. package/server/api/files/upload.post.ts +105 -0
  299. package/server/api/health.get.ts +4 -0
  300. package/server/api/hubs/[slug]/bans/[userId].delete.ts +13 -0
  301. package/server/api/hubs/[slug]/bans.get.ts +20 -0
  302. package/server/api/hubs/[slug]/bans.post.ts +23 -0
  303. package/server/api/hubs/[slug]/feed.xml.get.ts +60 -0
  304. package/server/api/hubs/[slug]/gallery.get.ts +21 -0
  305. package/server/api/hubs/[slug]/index.delete.ts +18 -0
  306. package/server/api/hubs/[slug]/index.get.ts +14 -0
  307. package/server/api/hubs/[slug]/index.put.ts +22 -0
  308. package/server/api/hubs/[slug]/invites.get.ts +20 -0
  309. package/server/api/hubs/[slug]/invites.post.ts +27 -0
  310. package/server/api/hubs/[slug]/join.post.ts +17 -0
  311. package/server/api/hubs/[slug]/leave.post.ts +13 -0
  312. package/server/api/hubs/[slug]/members/[userId].delete.ts +13 -0
  313. package/server/api/hubs/[slug]/members/[userId].put.ts +16 -0
  314. package/server/api/hubs/[slug]/members.get.ts +20 -0
  315. package/server/api/hubs/[slug]/posts/[postId]/like.post.ts +21 -0
  316. package/server/api/hubs/[slug]/posts/[postId]/lock.post.ts +17 -0
  317. package/server/api/hubs/[slug]/posts/[postId]/pin.post.ts +17 -0
  318. package/server/api/hubs/[slug]/posts/[postId]/replies.get.ts +16 -0
  319. package/server/api/hubs/[slug]/posts/[postId]/replies.post.ts +21 -0
  320. package/server/api/hubs/[slug]/posts/[postId].delete.ts +23 -0
  321. package/server/api/hubs/[slug]/posts/[postId].get.ts +16 -0
  322. package/server/api/hubs/[slug]/posts/index.get.ts +16 -0
  323. package/server/api/hubs/[slug]/posts/index.post.ts +36 -0
  324. package/server/api/hubs/[slug]/products.get.ts +26 -0
  325. package/server/api/hubs/[slug]/products.post.ts +18 -0
  326. package/server/api/hubs/[slug]/share.post.ts +39 -0
  327. package/server/api/hubs/index.get.ts +15 -0
  328. package/server/api/hubs/index.post.ts +11 -0
  329. package/server/api/image-proxy.get.ts +91 -0
  330. package/server/api/learn/[slug]/[lessonSlug]/complete.post.ts +13 -0
  331. package/server/api/learn/[slug]/[lessonSlug]/index.get.ts +68 -0
  332. package/server/api/learn/[slug]/enroll.post.ts +12 -0
  333. package/server/api/learn/[slug]/index.delete.ts +12 -0
  334. package/server/api/learn/[slug]/index.get.ts +14 -0
  335. package/server/api/learn/[slug]/index.put.ts +19 -0
  336. package/server/api/learn/[slug]/lessons/[lessonId].delete.ts +14 -0
  337. package/server/api/learn/[slug]/lessons/[lessonId].put.ts +24 -0
  338. package/server/api/learn/[slug]/lessons.post.ts +10 -0
  339. package/server/api/learn/[slug]/modules/[moduleId].delete.ts +14 -0
  340. package/server/api/learn/[slug]/modules/[moduleId].put.ts +15 -0
  341. package/server/api/learn/[slug]/modules.post.ts +14 -0
  342. package/server/api/learn/[slug]/publish.post.ts +13 -0
  343. package/server/api/learn/[slug]/unenroll.post.ts +12 -0
  344. package/server/api/learn/certificates.get.ts +9 -0
  345. package/server/api/learn/enrollments.get.ts +9 -0
  346. package/server/api/learn/index.get.ts +17 -0
  347. package/server/api/learn/index.post.ts +11 -0
  348. package/server/api/me.get.ts +13 -0
  349. package/server/api/messages/[conversationId]/info.get.ts +43 -0
  350. package/server/api/messages/[conversationId]/stream.get.ts +73 -0
  351. package/server/api/messages/[conversationId].get.ts +13 -0
  352. package/server/api/messages/[conversationId].post.ts +12 -0
  353. package/server/api/messages/index.get.ts +39 -0
  354. package/server/api/messages/index.post.ts +58 -0
  355. package/server/api/notifications/[id].delete.ts +11 -0
  356. package/server/api/notifications/count.get.ts +10 -0
  357. package/server/api/notifications/index.get.ts +24 -0
  358. package/server/api/notifications/read.post.ts +15 -0
  359. package/server/api/notifications/stream.get.ts +59 -0
  360. package/server/api/openapi.get.ts +5 -0
  361. package/server/api/products/[id].delete.ts +15 -0
  362. package/server/api/products/[id].put.ts +18 -0
  363. package/server/api/products/[slug]/content.get.ts +21 -0
  364. package/server/api/products/[slug].get.ts +16 -0
  365. package/server/api/products/index.get.ts +28 -0
  366. package/server/api/profile.get.ts +15 -0
  367. package/server/api/profile.put.ts +24 -0
  368. package/server/api/resolve-identity.post.ts +34 -0
  369. package/server/api/search/index.get.ts +24 -0
  370. package/server/api/search/trending.get.ts +22 -0
  371. package/server/api/social/bookmark.get.ts +16 -0
  372. package/server/api/social/bookmark.post.ts +15 -0
  373. package/server/api/social/bookmarks.get.ts +16 -0
  374. package/server/api/social/comments/[id].delete.ts +13 -0
  375. package/server/api/social/comments.get.ts +18 -0
  376. package/server/api/social/comments.post.ts +11 -0
  377. package/server/api/social/like.get.ts +17 -0
  378. package/server/api/social/like.post.ts +35 -0
  379. package/server/api/stats.get.ts +8 -0
  380. package/server/api/user/hubs.get.ts +22 -0
  381. package/server/api/users/[username]/content.get.ts +21 -0
  382. package/server/api/users/[username]/feed.xml.get.ts +65 -0
  383. package/server/api/users/[username]/follow.delete.ts +15 -0
  384. package/server/api/users/[username]/follow.post.ts +15 -0
  385. package/server/api/users/[username]/followers.get.ts +22 -0
  386. package/server/api/users/[username]/following.get.ts +22 -0
  387. package/server/api/users/[username]/learning.get.ts +18 -0
  388. package/server/api/users/[username].get.ts +25 -0
  389. package/server/api/users/index.get.ts +78 -0
  390. package/server/api/videos/[id].get.ts +18 -0
  391. package/server/api/videos/categories/[id].delete.ts +15 -0
  392. package/server/api/videos/categories/[id].put.ts +17 -0
  393. package/server/api/videos/categories.get.ts +7 -0
  394. package/server/api/videos/categories.post.ts +11 -0
  395. package/server/api/videos/index.get.ts +20 -0
  396. package/server/api/videos/index.post.ts +11 -0
  397. package/server/middleware/auth.ts +180 -0
  398. package/server/middleware/features.ts +31 -0
  399. package/server/middleware/security.ts +54 -0
  400. package/server/plugins/auto-admin.ts +69 -0
  401. package/server/plugins/federation-delivery.ts +74 -0
  402. package/server/routes/.well-known/nodeinfo.ts +15 -0
  403. package/server/routes/.well-known/webfinger.ts +86 -0
  404. package/server/routes/actor/followers.ts +34 -0
  405. package/server/routes/actor/following.ts +34 -0
  406. package/server/routes/actor/outbox.ts +31 -0
  407. package/server/routes/actor.ts +30 -0
  408. package/server/routes/feed.xml.ts +59 -0
  409. package/server/routes/hubs/[slug]/followers.ts +39 -0
  410. package/server/routes/hubs/[slug]/inbox.ts +35 -0
  411. package/server/routes/hubs/[slug]/outbox.ts +43 -0
  412. package/server/routes/hubs/[slug]/posts/[postId].ts +63 -0
  413. package/server/routes/hubs/[slug].ts +29 -0
  414. package/server/routes/inbox.ts +34 -0
  415. package/server/routes/nodeinfo/2.1.ts +27 -0
  416. package/server/routes/robots.txt.ts +19 -0
  417. package/server/routes/sitemap.xml.ts +105 -0
  418. package/server/routes/users/[username]/followers.ts +33 -0
  419. package/server/routes/users/[username]/following.ts +33 -0
  420. package/server/routes/users/[username]/inbox.ts +34 -0
  421. package/server/routes/users/[username]/outbox.ts +35 -0
  422. package/server/routes/users/[username].ts +62 -0
  423. package/server/utils/auth.ts +36 -0
  424. package/server/utils/db.ts +34 -0
  425. package/server/utils/errors.ts +24 -0
  426. package/server/utils/inbox.ts +122 -0
  427. package/server/utils/validate.ts +82 -0
  428. package/theme/base.css +283 -0
  429. package/theme/components.css +322 -0
  430. package/theme/dark.css +87 -0
  431. package/theme/editor-panels.css +63 -0
  432. package/theme/forms.css +216 -0
  433. package/theme/generics.css +68 -0
  434. package/theme/layouts.css +415 -0
  435. package/theme/prose.css +342 -0
  436. package/types/hub.ts +61 -0
@@ -0,0 +1,1500 @@
1
+ <script setup lang="ts">
2
+ import type { ContentViewData } from '../../composables/useEngagement';
3
+
4
+ const { hubs: hubsEnabled } = useFeatures();
5
+
6
+ const props = defineProps<{
7
+ content: ContentViewData;
8
+ federatedId?: string;
9
+ }>();
10
+
11
+ const activeTab = ref('overview');
12
+
13
+ // Fetch linked products (BOM) for this content
14
+ const { data: bomProducts } = useFetch(() => `/api/content/${props.content?.id}/products`, {
15
+ key: `bom-${props.content?.id}`,
16
+ default: () => [],
17
+ immediate: !!props.content?.id,
18
+ });
19
+
20
+ const tabs = computed(() => {
21
+ const result = [
22
+ { value: 'overview', label: 'Overview', count: 0 },
23
+ ];
24
+ const bomCount = partsFromBlocks.value.length + (bomProducts.value?.length ?? 0);
25
+ if (bomCount > 0 || buildStepsFromBlocks.value.length > 0) {
26
+ result.push({ value: 'bom', label: 'Parts & Steps', count: bomCount });
27
+ }
28
+ if (codeBlocks.value.length > 0) {
29
+ result.push({ value: 'code', label: 'Code', count: codeBlocks.value.length });
30
+ }
31
+ if (downloadFiles.value.length > 0) {
32
+ result.push({ value: 'files', label: 'Files', count: downloadFiles.value.length });
33
+ }
34
+ result.push({ value: 'comments', label: 'Discussion', count: props.content?.commentCount ?? 0 });
35
+ return result;
36
+ });
37
+
38
+ const contentId = computed(() => props.content?.id);
39
+ const contentType = computed(() => props.content?.type ?? 'project');
40
+ const fedId = computed(() => props.federatedId);
41
+ const { liked, bookmarked, likeCount, isFederated, toggleLike, toggleBookmark, share, fetchInitialState } = useEngagement({ contentId, contentType, federatedContentId: fedId });
42
+
43
+ onMounted(() => {
44
+ fetchInitialState(props.content?.likeCount ?? 0);
45
+ });
46
+
47
+ const config = useRuntimeConfig();
48
+ useJsonLd({
49
+ type: 'howto',
50
+ title: props.content.title,
51
+ description: props.content.seoDescription ?? props.content.description ?? '',
52
+ url: `${config.public.siteUrl}/project/${props.content.slug}`,
53
+ imageUrl: props.content.coverImageUrl ?? undefined,
54
+ authorName: props.content.author?.displayName ?? props.content.author?.username ?? '',
55
+ authorUrl: `${config.public.siteUrl}/u/${props.content.author?.username}`,
56
+ difficulty: props.content.difficulty ?? undefined,
57
+ estimatedTime: props.content.buildTime ?? undefined,
58
+ estimatedCost: props.content.estimatedCost ?? undefined,
59
+ });
60
+
61
+ const difficultyLevel = computed(() => {
62
+ const d = props.content?.difficulty;
63
+ if (d === 'beginner') return 1;
64
+ if (d === 'intermediate') return 3;
65
+ if (d === 'advanced') return 5;
66
+ return 3;
67
+ });
68
+
69
+ const formattedDate = computed(() => {
70
+ const date = props.content?.publishedAt || props.content?.createdAt;
71
+ if (!date) return '';
72
+ return new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
73
+ });
74
+
75
+ // Extract parts list blocks from content for BOM tab
76
+ interface PartItem {
77
+ name: string;
78
+ quantity: number;
79
+ productId?: string;
80
+ notes?: string;
81
+ }
82
+
83
+ const partsFromBlocks = computed<PartItem[]>(() => {
84
+ const blocks = props.content?.content;
85
+ if (!Array.isArray(blocks)) return [];
86
+ const items: PartItem[] = [];
87
+ for (const block of blocks) {
88
+ const [type, data] = block as [string, Record<string, unknown>];
89
+ if (type === 'partsList' && Array.isArray(data.parts)) {
90
+ for (const part of data.parts as Array<Record<string, unknown>>) {
91
+ items.push({
92
+ name: (part.name as string) || 'Unknown',
93
+ quantity: (part.qty as number) ?? (part.quantity as number) ?? 1,
94
+ productId: part.productId as string | undefined,
95
+ notes: (part.notes as string) || '',
96
+ });
97
+ }
98
+ }
99
+ }
100
+ return items;
101
+ });
102
+
103
+ // Extract build steps from content
104
+ interface BuildStep {
105
+ number: number;
106
+ title: string;
107
+ instructions: string;
108
+ image?: string;
109
+ time?: string;
110
+ }
111
+
112
+ const buildStepsFromBlocks = computed<BuildStep[]>(() => {
113
+ const blocks = props.content?.content;
114
+ if (!Array.isArray(blocks)) return [];
115
+ const steps: BuildStep[] = [];
116
+ let stepNum = 0;
117
+ for (const block of blocks) {
118
+ const [type, data] = block as [string, Record<string, unknown>];
119
+ if (type === 'buildStep') {
120
+ stepNum++;
121
+ steps.push({
122
+ number: (data.stepNumber as number) || stepNum,
123
+ title: (data.title as string) || `Step ${stepNum}`,
124
+ instructions: (data.instructions as string) || '',
125
+ image: data.image as string | undefined,
126
+ time: data.time as string | undefined,
127
+ });
128
+ }
129
+ }
130
+ return steps;
131
+ });
132
+
133
+ // Extract code blocks for code tab
134
+ interface CodeSnippet {
135
+ language: string;
136
+ filename: string;
137
+ code: string;
138
+ }
139
+
140
+ const codeBlocks = computed<CodeSnippet[]>(() => {
141
+ const blocks = props.content?.content;
142
+ if (!Array.isArray(blocks)) return [];
143
+ const snippets: CodeSnippet[] = [];
144
+ for (const block of blocks) {
145
+ const [type, data] = block as [string, Record<string, unknown>];
146
+ if (type === 'code_block' || type === 'codeBlock') {
147
+ snippets.push({
148
+ language: (data.language as string) || '',
149
+ filename: (data.filename as string) || '',
150
+ code: (data.code as string) || '',
151
+ });
152
+ }
153
+ }
154
+ return snippets;
155
+ });
156
+
157
+ // Extract download blocks for files tab
158
+ interface FileItem {
159
+ name: string;
160
+ url: string;
161
+ size?: string;
162
+ }
163
+
164
+ const downloadFiles = computed<FileItem[]>(() => {
165
+ const blocks = props.content?.content;
166
+ if (!Array.isArray(blocks)) return [];
167
+ const files: FileItem[] = [];
168
+ for (const block of blocks) {
169
+ const [type, data] = block as [string, Record<string, unknown>];
170
+ if (type === 'downloads' && Array.isArray(data.files)) {
171
+ for (const file of data.files as Array<Record<string, unknown>>) {
172
+ files.push({
173
+ name: (file.name as string) || 'Unknown',
174
+ url: (file.url as string) || '',
175
+ size: (file.size as string) || '',
176
+ });
177
+ }
178
+ }
179
+ }
180
+ return files;
181
+ });
182
+
183
+ // Extract headings from content for table of contents
184
+ interface TocEntry { id: string; text: string; level: number }
185
+ const tocEntries = computed<TocEntry[]>(() => {
186
+ const blocks = props.content?.content;
187
+ if (!Array.isArray(blocks)) return [];
188
+ const entries: TocEntry[] = [];
189
+ for (const block of blocks) {
190
+ const [type, data] = block as [string, Record<string, unknown>];
191
+ if (type === 'heading' && data.text) {
192
+ const text = String(data.text).replace(/<[^>]+>/g, '');
193
+ if (text.trim()) {
194
+ entries.push({
195
+ id: text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''),
196
+ text: text.trim(),
197
+ level: (data.level as number) ?? 2,
198
+ });
199
+ }
200
+ }
201
+ }
202
+ return entries;
203
+ });
204
+
205
+ const tocActiveId = ref('');
206
+
207
+ function scrollToHeading(id: string): void {
208
+ const el = document.getElementById(id);
209
+ if (el) {
210
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
211
+ tocActiveId.value = id;
212
+ }
213
+ }
214
+
215
+ // Scroll-spy: highlight active TOC entry based on which heading is in view
216
+ let observer: IntersectionObserver | null = null;
217
+
218
+ onMounted(() => {
219
+ nextTick(() => {
220
+ setupScrollSpy();
221
+ });
222
+ });
223
+
224
+ onUnmounted(() => {
225
+ observer?.disconnect();
226
+ });
227
+
228
+ function setupScrollSpy(): void {
229
+ if (!tocEntries.value.length) return;
230
+ observer?.disconnect();
231
+
232
+ const headingEls = tocEntries.value
233
+ .map((e) => document.getElementById(e.id))
234
+ .filter((el): el is HTMLElement => !!el);
235
+
236
+ if (!headingEls.length) return;
237
+
238
+ observer = new IntersectionObserver(
239
+ (entries) => {
240
+ // Find the topmost visible heading
241
+ for (const entry of entries) {
242
+ if (entry.isIntersecting) {
243
+ tocActiveId.value = entry.target.id;
244
+ break;
245
+ }
246
+ }
247
+ },
248
+ { rootMargin: '-80px 0px -70% 0px', threshold: 0 },
249
+ );
250
+
251
+ for (const el of headingEls) {
252
+ observer.observe(el);
253
+ }
254
+ }
255
+
256
+ // Fork
257
+ const forking = ref(false);
258
+ async function handleFork(): Promise<void> {
259
+ forking.value = true;
260
+ try {
261
+ const result = await $fetch<{ slug: string; type: string }>(`/api/content/${props.content.id}/fork`, { method: 'POST' });
262
+ await navigateTo(`/${result.type}/${result.slug}/edit`);
263
+ } catch {
264
+ // fork failed silently
265
+ } finally {
266
+ forking.value = false;
267
+ }
268
+ }
269
+
270
+ // I Built This
271
+ const buildMarked = ref(false);
272
+ const localBuildCount = ref(props.content?.buildCount ?? 0);
273
+ const buildToggling = ref(false);
274
+ async function handleBuild(): Promise<void> {
275
+ buildToggling.value = true;
276
+ try {
277
+ const result = await $fetch<{ marked: boolean; count: number }>(`/api/content/${props.content.id}/build`, { method: 'POST' });
278
+ buildMarked.value = result.marked;
279
+ localBuildCount.value = result.count;
280
+ } catch {
281
+ // toggle failed silently
282
+ } finally {
283
+ buildToggling.value = false;
284
+ }
285
+ }
286
+ </script>
287
+
288
+ <template>
289
+ <div class="cpub-project-view">
290
+ <!-- HERO COVER -->
291
+ <div class="cpub-hero-cover" :class="{ 'cpub-hero-cover-has-image': content.coverImageUrl }">
292
+ <img v-if="content.coverImageUrl" :src="content.coverImageUrl" :alt="content.title" class="cpub-hero-cover-img" />
293
+ <template v-else>
294
+ <div class="cpub-hero-cover-grid"></div>
295
+ <div class="cpub-hero-circuit">
296
+ <div class="cpub-chip-row">
297
+ <div class="cpub-chip">{{ content.hardwarePrimary || 'MCU' }}</div>
298
+ <div class="cpub-chip-line"></div>
299
+ <div class="cpub-chip">{{ content.hardwareSecondary || 'SENSOR' }}</div>
300
+ <div class="cpub-chip-line"></div>
301
+ <div class="cpub-chip">{{ content.hardwareTertiary || 'ML MODEL' }}</div>
302
+ </div>
303
+ </div>
304
+ </template>
305
+ <div class="cpub-hero-badges">
306
+ <span v-if="content.isFeatured" class="cpub-badge cpub-badge-featured"><i class="fa-solid fa-star"></i> Featured</span>
307
+ <span class="cpub-badge cpub-badge-outline">{{ content.difficulty || 'Intermediate' }}</span>
308
+ </div>
309
+ </div>
310
+
311
+ <!-- PAGE CONTENT -->
312
+ <div class="cpub-page-outer">
313
+ <!-- BREADCRUMB -->
314
+ <div class="cpub-breadcrumb">
315
+ <NuxtLink to="/">Explore</NuxtLink>
316
+ <span class="cpub-bc-sep"><i class="fa-solid fa-chevron-right"></i></span>
317
+ <NuxtLink to="/project">Projects</NuxtLink>
318
+ <span class="cpub-bc-sep"><i class="fa-solid fa-chevron-right"></i></span>
319
+ <span class="cpub-bc-current">{{ content.title }}</span>
320
+ </div>
321
+
322
+ <!-- PROJECT META -->
323
+ <div class="cpub-project-meta">
324
+ <h1 class="cpub-project-title">{{ content.title }}</h1>
325
+ <p v-if="content.description" class="cpub-project-subtitle">{{ content.description }}</p>
326
+
327
+ <!-- Author Row -->
328
+ <div class="cpub-author-row">
329
+ <NuxtLink :to="`/u/${content.author?.username}`" class="cpub-av-link">
330
+ <img
331
+ v-if="content.author?.avatarUrl"
332
+ :src="content.author.avatarUrl"
333
+ :alt="content.author?.displayName || content.author?.username"
334
+ class="cpub-av cpub-av-lg cpub-av-img"
335
+ />
336
+ <div v-else class="cpub-av cpub-av-lg">{{ content.author?.displayName?.slice(0, 2).toUpperCase() || 'CP' }}</div>
337
+ </NuxtLink>
338
+ <div>
339
+ <NuxtLink :to="`/u/${content.author?.username}`" class="cpub-author-name cpub-author-link">
340
+ {{ content.author?.displayName || content.author?.username || 'Author' }}
341
+ </NuxtLink>
342
+ <div class="cpub-author-meta-row">
343
+ <span v-if="content.author?.org" class="cpub-author-org">{{ content.author.org }}</span>
344
+ <span class="cpub-meta-date">Published {{ formattedDate }}</span>
345
+ </div>
346
+ </div>
347
+ <span class="cpub-meta-sep">&bull;</span>
348
+ <span class="cpub-author-detail"><i class="fa-solid fa-signal"></i> {{ content.difficulty || 'Intermediate' }}</span>
349
+ <span v-if="content.buildTime" class="cpub-author-detail"><i class="fa-solid fa-clock"></i> {{ content.buildTime }}</span>
350
+ <span v-if="content.estimatedCost" class="cpub-author-detail"><i class="fa-solid fa-dollar-sign"></i> {{ content.estimatedCost }}</span>
351
+ <a v-if="content.githubUrl" :href="content.githubUrl" target="_blank" rel="noopener" class="cpub-author-detail cpub-author-detail-link"><i class="fa-brands fa-github"></i> Source</a>
352
+ <template v-if="content.tags?.length">
353
+ <span class="cpub-meta-sep">&bull;</span>
354
+ <span v-for="tag in content.tags.slice(0, 5)" :key="tag.id || tag.name || String(tag)" class="cpub-author-tag">{{ tag.name || tag }}</span>
355
+ </template>
356
+ </div>
357
+
358
+ <!-- Engagement Row -->
359
+ <div class="cpub-engagement-row">
360
+ <button class="cpub-engage-btn" :class="{ liked }" @click="toggleLike">
361
+ <i :class="liked ? 'fa-solid fa-heart' : 'fa-regular fa-heart'"></i> {{ liked ? 'Liked' : 'Like' }} <span class="cpub-count">{{ likeCount }}</span>
362
+ </button>
363
+ <button class="cpub-engage-btn" :class="{ bookmarked }" @click="toggleBookmark"><i :class="bookmarked ? 'fa-solid fa-bookmark' : 'fa-regular fa-bookmark'"></i> {{ bookmarked ? 'Saved' : 'Bookmark' }}</button>
364
+ <button class="cpub-engage-btn" @click="share"><i class="fa-solid fa-share-nodes"></i> Share</button>
365
+ <div class="cpub-engage-sep"></div>
366
+ <button class="cpub-engage-btn" :disabled="forking" @click="handleFork"><i class="fa-solid fa-code-branch"></i> {{ forking ? 'Forking...' : 'Fork' }} <span class="cpub-count">{{ content.forkCount ?? 0 }}</span></button>
367
+ <button class="cpub-engage-btn cpub-engage-btn-green" :class="{ 'cpub-engage-active': buildMarked }" :disabled="buildToggling" @click="handleBuild"><i class="fa-solid fa-hammer"></i> I Built This <span class="cpub-count">{{ localBuildCount }}</span></button>
368
+ </div>
369
+
370
+ </div>
371
+ </div>
372
+
373
+ <!-- STICKY TABS -->
374
+ <div class="cpub-tabs-sticky">
375
+ <div class="cpub-tabs-inner">
376
+ <button
377
+ v-for="tab in tabs"
378
+ :key="tab.value"
379
+ class="cpub-tab"
380
+ :class="{ active: activeTab === tab.value }"
381
+ @click="activeTab = tab.value"
382
+ >
383
+ {{ tab.label }}
384
+ <span v-if="tab.count" class="cpub-tab-badge">{{ tab.count }}</span>
385
+ </button>
386
+ </div>
387
+ </div>
388
+
389
+ <!-- MAIN CONTENT GRID -->
390
+ <div class="cpub-page-outer">
391
+ <div class="cpub-content-grid" :class="{ 'cpub-has-toc': tocEntries.length > 0 && activeTab === 'overview' }">
392
+ <!-- LEFT: TABLE OF CONTENTS -->
393
+ <nav v-if="tocEntries.length > 0 && activeTab === 'overview'" class="cpub-toc-col">
394
+ <div class="cpub-toc">
395
+ <div class="cpub-toc-title">On This Page</div>
396
+ <div class="cpub-toc-nav">
397
+ <button
398
+ v-for="entry in tocEntries"
399
+ :key="entry.id"
400
+ class="cpub-toc-item"
401
+ :class="{ active: tocActiveId === entry.id, 'cpub-toc-h3': entry.level >= 3 }"
402
+ @click="scrollToHeading(entry.id)"
403
+ >
404
+ <span class="cpub-toc-text">{{ entry.text }}</span>
405
+ </button>
406
+ </div>
407
+ </div>
408
+ </nav>
409
+
410
+ <!-- CENTER: CONTENT -->
411
+ <div class="cpub-content-col">
412
+ <!-- OVERVIEW TAB -->
413
+ <template v-if="activeTab === 'overview'">
414
+ <div class="cpub-prose">
415
+ <template v-if="content.content && Array.isArray(content.content) && (content.content as unknown[]).length > 0">
416
+ <BlocksBlockContentRenderer :blocks="(content.content as [string, Record<string, unknown>][])" />
417
+ </template>
418
+ <template v-else>
419
+ <div class="cpub-prose-section">
420
+ <div class="cpub-section-title">Introduction</div>
421
+ <p class="cpub-prose-p">No content body yet. This project doesn't have any content blocks.</p>
422
+ </div>
423
+ </template>
424
+ </div>
425
+ </template>
426
+
427
+ <!-- BOM / PARTS & STEPS TAB -->
428
+ <template v-else-if="activeTab === 'bom'">
429
+ <!-- Parts Table -->
430
+ <div v-if="partsFromBlocks.length > 0" class="cpub-bom-section">
431
+ <h2 class="cpub-tab-section-title"><i class="fa-solid fa-list-check"></i> Parts List</h2>
432
+ <div class="cpub-parts-table-wrap">
433
+ <table class="cpub-parts-table">
434
+ <thead>
435
+ <tr>
436
+ <th>Component</th>
437
+ <th>Qty</th>
438
+ <th>Notes</th>
439
+ </tr>
440
+ </thead>
441
+ <tbody>
442
+ <tr v-for="(part, idx) in partsFromBlocks" :key="idx">
443
+ <td class="cpub-part-name">{{ part.name }}</td>
444
+ <td class="cpub-part-qty">{{ part.quantity }}</td>
445
+ <td class="cpub-part-notes">{{ part.notes || '—' }}</td>
446
+ </tr>
447
+ </tbody>
448
+ </table>
449
+ </div>
450
+ </div>
451
+
452
+ <!-- Linked Products from catalog -->
453
+ <div v-if="bomProducts?.length" class="cpub-bom-section">
454
+ <h2 class="cpub-tab-section-title"><i class="fa-solid fa-cube"></i> Linked Products</h2>
455
+ <div class="cpub-linked-products">
456
+ <div v-for="bp in bomProducts" :key="bp.id" class="cpub-linked-product">
457
+ <div class="cpub-linked-product-icon"><i class="fa-solid fa-microchip"></i></div>
458
+ <div class="cpub-linked-product-info">
459
+ <NuxtLink :to="`/products/${bp.productSlug}`" class="cpub-linked-product-name">{{ bp.productName }}</NuxtLink>
460
+ <span class="cpub-linked-product-qty">× {{ bp.quantity }}</span>
461
+ </div>
462
+ </div>
463
+ </div>
464
+ </div>
465
+
466
+ <!-- Build Steps -->
467
+ <div v-if="buildStepsFromBlocks.length > 0" class="cpub-bom-section">
468
+ <h2 class="cpub-tab-section-title"><i class="fa-solid fa-hammer"></i> Build Steps</h2>
469
+ <div class="cpub-build-steps">
470
+ <div v-for="step in buildStepsFromBlocks" :key="step.number" class="cpub-build-step">
471
+ <div class="cpub-build-step-header">
472
+ <span class="cpub-build-step-num">{{ step.number }}</span>
473
+ <h3 class="cpub-build-step-title">{{ step.title }}</h3>
474
+ <span v-if="step.time" class="cpub-build-step-time"><i class="fa-regular fa-clock"></i> {{ step.time }}</span>
475
+ </div>
476
+ <div class="cpub-build-step-body">
477
+ <p>{{ step.instructions }}</p>
478
+ <img v-if="step.image" :src="step.image" :alt="`Step ${step.number}`" class="cpub-build-step-img" />
479
+ </div>
480
+ </div>
481
+ </div>
482
+ </div>
483
+
484
+ <p v-if="partsFromBlocks.length === 0 && !bomProducts?.length && buildStepsFromBlocks.length === 0" class="cpub-tab-empty">
485
+ No parts or build steps have been added to this project yet.
486
+ </p>
487
+ </template>
488
+
489
+ <!-- CODE TAB -->
490
+ <template v-else-if="activeTab === 'code'">
491
+ <div class="cpub-code-tab">
492
+ <div v-for="(snippet, idx) in codeBlocks" :key="idx" class="cpub-code-snippet">
493
+ <div class="cpub-code-snippet-header">
494
+ <span class="cpub-code-lang-label">{{ snippet.language || 'plain text' }}</span>
495
+ <span v-if="snippet.filename" class="cpub-code-filename">{{ snippet.filename }}</span>
496
+ </div>
497
+ <pre class="cpub-code-body"><code>{{ snippet.code }}</code></pre>
498
+ </div>
499
+ </div>
500
+ </template>
501
+
502
+ <!-- FILES TAB -->
503
+ <template v-else-if="activeTab === 'files'">
504
+ <div class="cpub-files-tab">
505
+ <div v-for="(file, idx) in downloadFiles" :key="idx" class="cpub-file-row">
506
+ <div class="cpub-file-icon"><i class="fa-solid fa-file-arrow-down"></i></div>
507
+ <div class="cpub-file-info">
508
+ <a :href="file.url" class="cpub-file-name" download>{{ file.name }}</a>
509
+ <span v-if="file.size" class="cpub-file-size">{{ file.size }}</span>
510
+ </div>
511
+ <a :href="file.url" class="cpub-file-download" download>
512
+ <i class="fa-solid fa-download"></i>
513
+ </a>
514
+ </div>
515
+ </div>
516
+ </template>
517
+
518
+ <!-- DISCUSSION TAB -->
519
+ <template v-else-if="activeTab === 'comments'">
520
+ <CommentSection :target-type="content.type" :target-id="content.id" :federated-content-id="federatedId" />
521
+ </template>
522
+ </div>
523
+
524
+ <!-- RIGHT: SIDEBAR -->
525
+ <aside class="cpub-sidebar">
526
+ <!-- BOM Summary -->
527
+ <div v-if="content.parts?.length || bomProducts?.length" class="cpub-sb-card">
528
+ <div class="cpub-sb-title">BOM Summary</div>
529
+ <div class="cpub-bom-summary-row">
530
+ <span class="cpub-bom-label">Components</span>
531
+ <span class="cpub-bom-val">{{ (content.parts?.length ?? 0) + (bomProducts?.length ?? 0) }}</span>
532
+ </div>
533
+ <div class="cpub-bom-summary-row">
534
+ <span class="cpub-bom-label">Total Cost</span>
535
+ <span class="cpub-bom-val cpub-bom-green">{{ content.estimatedCost || '—' }}</span>
536
+ </div>
537
+ <!-- Linked products from catalog -->
538
+ <template v-if="bomProducts?.length">
539
+ <div class="cpub-bom-products-header">Linked Products</div>
540
+ <div v-for="bp in bomProducts" :key="bp.id" class="cpub-bom-product-row">
541
+ <NuxtLink :to="`/products/${bp.productSlug}`" class="cpub-bom-product-link">
542
+ {{ bp.productName }}
543
+ </NuxtLink>
544
+ <span class="cpub-bom-qty">×{{ bp.quantity }}</span>
545
+ </div>
546
+ </template>
547
+ <div class="cpub-bom-link-row">
548
+ <button class="cpub-link-text" @click="activeTab = 'bom'">
549
+ <i class="fa-solid fa-list"></i> View full BOM
550
+ </button>
551
+ </div>
552
+ </div>
553
+
554
+ <!-- Community Hub -->
555
+ <div v-if="hubsEnabled && content.community" class="cpub-sb-card">
556
+ <div class="cpub-hub-card-inner">
557
+ <div class="cpub-hub-icon"><i class="fa-solid fa-users"></i></div>
558
+ <div class="cpub-hub-name">{{ content.community.name }}</div>
559
+ <div class="cpub-hub-desc">{{ content.community.description }}</div>
560
+ <button class="cpub-btn cpub-btn-sm" @click="navigateTo(`/hubs/${content.community?.slug}`)">Join Community</button>
561
+ </div>
562
+ </div>
563
+ </aside>
564
+ </div>
565
+ </div>
566
+ </div>
567
+ </template>
568
+
569
+ <style scoped>
570
+ /* ── HERO COVER ── */
571
+ .cpub-hero-cover {
572
+ position: relative;
573
+ height: 280px;
574
+ background: var(--surface2);
575
+ overflow: hidden;
576
+ flex-shrink: 0;
577
+ border-bottom: 1px solid var(--border);
578
+ }
579
+
580
+ .cpub-hero-cover-grid {
581
+ position: absolute;
582
+ inset: 0;
583
+ background-image:
584
+ linear-gradient(var(--accent-border) 1px, transparent 1px),
585
+ linear-gradient(90deg, var(--accent-border) 1px, transparent 1px);
586
+ background-size: 32px 32px;
587
+ }
588
+
589
+ .cpub-hero-circuit {
590
+ position: absolute;
591
+ inset: 0;
592
+ display: flex;
593
+ align-items: center;
594
+ justify-content: center;
595
+ opacity: 0.22;
596
+ font-family: var(--font-mono);
597
+ font-size: 11px;
598
+ color: var(--teal);
599
+ letter-spacing: 0.05em;
600
+ }
601
+
602
+ .cpub-chip-row {
603
+ display: flex;
604
+ align-items: center;
605
+ gap: 12px;
606
+ }
607
+
608
+ .cpub-chip {
609
+ border: var(--border-width-default) solid currentColor;
610
+ padding: 8px 16px;
611
+ font-size: 10px;
612
+ }
613
+
614
+ .cpub-chip-line {
615
+ width: 40px;
616
+ height: 2px;
617
+ background: currentColor;
618
+ opacity: 0.5;
619
+ }
620
+
621
+ .cpub-hero-badges {
622
+ position: absolute;
623
+ top: 16px;
624
+ left: 20px;
625
+ display: flex;
626
+ gap: 6px;
627
+ }
628
+
629
+ .cpub-badge {
630
+ font-size: 9px;
631
+ font-family: var(--font-mono);
632
+ font-weight: 700;
633
+ letter-spacing: 0.12em;
634
+ text-transform: uppercase;
635
+ padding: 4px 10px;
636
+ }
637
+
638
+ .cpub-badge-featured {
639
+ background: var(--yellow-bg);
640
+ border: 1px solid var(--border);
641
+ color: var(--yellow);
642
+ box-shadow: var(--shadow-sm);
643
+ }
644
+
645
+ .cpub-badge-outline {
646
+ background: var(--surface);
647
+ border: 1px solid var(--border);
648
+ color: var(--text-dim);
649
+ box-shadow: var(--shadow-sm);
650
+ }
651
+
652
+ /* ── PAGE OUTER ── */
653
+ .cpub-page-outer {
654
+ max-width: 1160px;
655
+ margin: 0 auto;
656
+ padding: 0 32px;
657
+ }
658
+
659
+ /* ── BREADCRUMB ── */
660
+ .cpub-breadcrumb {
661
+ display: flex;
662
+ align-items: center;
663
+ gap: 6px;
664
+ padding: 14px 0 10px;
665
+ font-size: 11px;
666
+ font-family: var(--font-mono);
667
+ color: var(--text-faint);
668
+ }
669
+
670
+ .cpub-breadcrumb a { color: var(--text-dim); text-decoration: none; }
671
+ .cpub-breadcrumb a:hover { color: var(--text); }
672
+ .cpub-bc-sep { color: var(--text-faint); font-size: 8px; }
673
+ .cpub-bc-current { color: var(--text-dim); }
674
+
675
+ /* ── PROJECT META ── */
676
+ .cpub-project-meta { padding: 24px 0 0; }
677
+
678
+ .cpub-project-title {
679
+ font-size: 22px;
680
+ font-weight: 700;
681
+ color: var(--text);
682
+ line-height: 1.25;
683
+ margin-bottom: 8px;
684
+ letter-spacing: -0.02em;
685
+ }
686
+
687
+ .cpub-project-subtitle {
688
+ font-size: 14px;
689
+ color: var(--text-dim);
690
+ line-height: 1.6;
691
+ margin-bottom: 18px;
692
+ }
693
+
694
+ /* ── AUTHOR ROW ── */
695
+ .cpub-author-row {
696
+ display: flex;
697
+ align-items: center;
698
+ gap: 10px;
699
+ margin-bottom: 16px;
700
+ flex-wrap: wrap;
701
+ }
702
+
703
+ .cpub-av {
704
+ width: 28px;
705
+ height: 28px;
706
+ border-radius: 50%;
707
+ background: var(--surface3);
708
+ border: 1px solid var(--border);
709
+ display: flex;
710
+ align-items: center;
711
+ justify-content: center;
712
+ font-size: 10px;
713
+ font-weight: 700;
714
+ color: var(--text-dim);
715
+ font-family: var(--font-mono);
716
+ flex-shrink: 0;
717
+ }
718
+
719
+ .cpub-av-lg { width: 36px; height: 36px; font-size: 12px; }
720
+
721
+ .cpub-author-name {
722
+ font-size: 13px;
723
+ font-weight: 600;
724
+ color: var(--text);
725
+ }
726
+ .cpub-author-link {
727
+ text-decoration: none;
728
+ color: var(--text);
729
+ }
730
+ .cpub-author-link:hover {
731
+ color: var(--accent);
732
+ }
733
+ .cpub-av-link {
734
+ text-decoration: none;
735
+ }
736
+ .cpub-av-img {
737
+ width: 36px;
738
+ height: 36px;
739
+ object-fit: cover;
740
+ border: var(--border-width-default) solid var(--border);
741
+ }
742
+
743
+ .cpub-author-meta-row {
744
+ display: flex;
745
+ align-items: center;
746
+ gap: 6px;
747
+ margin-top: 2px;
748
+ }
749
+
750
+ .cpub-author-org {
751
+ font-size: 11px;
752
+ font-family: var(--font-mono);
753
+ color: var(--accent);
754
+ background: var(--accent-bg);
755
+ border: 1px solid var(--border);
756
+ padding: 2px 7px;
757
+ }
758
+
759
+ .cpub-meta-date {
760
+ font-size: 11px;
761
+ font-family: var(--font-mono);
762
+ color: var(--text-faint);
763
+ }
764
+
765
+ .cpub-meta-sep { color: var(--text-faint); font-size: 11px; }
766
+
767
+ .cpub-author-detail {
768
+ font-size: 11px; color: var(--text-dim); display: inline-flex; align-items: center; gap: 4px;
769
+ }
770
+ .cpub-author-detail i { font-size: 10px; color: var(--text-faint); }
771
+ .cpub-author-detail-link { text-decoration: none; cursor: pointer; }
772
+ .cpub-author-detail-link:hover { color: var(--accent); }
773
+ .cpub-author-tag {
774
+ font-size: 9px; font-family: var(--font-mono); text-transform: uppercase;
775
+ letter-spacing: 0.04em; color: var(--text-faint); padding: 1px 6px;
776
+ border: 1px solid var(--border); background: var(--surface);
777
+ }
778
+
779
+ .cpub-fork-count {
780
+ font-size: 11px;
781
+ font-family: var(--font-mono);
782
+ color: var(--text-dim);
783
+ display: flex;
784
+ align-items: center;
785
+ gap: 4px;
786
+ }
787
+
788
+ .cpub-fork-count i { font-size: 10px; color: var(--text-faint); }
789
+
790
+ /* ── ENGAGEMENT ROW ── */
791
+ .cpub-engagement-row {
792
+ display: flex;
793
+ align-items: center;
794
+ gap: 8px;
795
+ padding-bottom: 20px;
796
+ flex-wrap: wrap;
797
+ }
798
+
799
+ .cpub-engage-btn {
800
+ font-size: 12px;
801
+ padding: 6px 13px;
802
+ border: 1px solid var(--border);
803
+ background: var(--surface);
804
+ color: var(--text-dim);
805
+ cursor: pointer;
806
+ display: inline-flex;
807
+ align-items: center;
808
+ gap: 6px;
809
+ transition: color var(--transition-fast), background var(--transition-fast);
810
+ }
811
+
812
+ .cpub-engage-btn:hover { color: var(--text); background: var(--surface2); }
813
+ .cpub-engage-btn.liked { color: var(--red); background: var(--red-bg); }
814
+ .cpub-engage-btn.bookmarked { color: var(--accent); background: var(--accent-bg); }
815
+
816
+ .cpub-engage-btn-green {
817
+ color: var(--green);
818
+ background: var(--green-bg);
819
+ }
820
+
821
+ .cpub-count {
822
+ font-family: var(--font-mono);
823
+ font-size: 11px;
824
+ color: var(--text-faint);
825
+ }
826
+
827
+ .cpub-engage-sep { width: 2px; height: 24px; background: var(--border); }
828
+
829
+ /* ── INLINE META ── */
830
+ .cpub-inline-meta { margin-top: 12px; }
831
+ .cpub-inline-meta-items { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
832
+ .cpub-inline-meta-chip {
833
+ display: inline-flex; align-items: center; gap: 5px;
834
+ font-size: 11px; font-family: var(--font-mono); color: var(--text-dim);
835
+ padding: 3px 10px; background: var(--surface2); border: 1px solid var(--border);
836
+ white-space: nowrap;
837
+ }
838
+ .cpub-inline-meta-link { text-decoration: none; cursor: pointer; }
839
+ .cpub-inline-meta-link:hover { border-color: var(--accent); color: var(--accent); }
840
+ .cpub-inline-dots { display: inline-flex; gap: 3px; margin-left: 2px; }
841
+ .cpub-idot { width: 6px; height: 6px; background: var(--border2); border-radius: 50%; }
842
+ .cpub-idot.on { background: var(--accent); }
843
+ .cpub-inline-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
844
+ .cpub-itag {
845
+ font-size: 10px; font-family: var(--font-mono); text-transform: uppercase;
846
+ letter-spacing: 0.04em; padding: 2px 8px; color: var(--text-faint);
847
+ border: 1px solid var(--border); background: var(--surface);
848
+ }
849
+
850
+ /* ── TABS ── */
851
+ .cpub-tabs-sticky {
852
+ position: sticky;
853
+ top: 48px;
854
+ z-index: 50;
855
+ background: var(--bg);
856
+ border-bottom: 1px solid var(--border);
857
+ margin-bottom: 28px;
858
+ }
859
+
860
+ .cpub-tabs-inner {
861
+ max-width: 1160px;
862
+ margin: 0 auto;
863
+ padding: 0 32px;
864
+ display: flex;
865
+ align-items: center;
866
+ gap: 0;
867
+ overflow-x: auto;
868
+ scrollbar-width: none;
869
+ }
870
+
871
+ .cpub-tabs-inner::-webkit-scrollbar { display: none; }
872
+
873
+ .cpub-tab {
874
+ font-size: 12px;
875
+ color: var(--text-dim);
876
+ padding: 10px 14px;
877
+ cursor: pointer;
878
+ border: none;
879
+ background: none;
880
+ border-bottom: 3px solid transparent;
881
+ margin-bottom: -2px;
882
+ white-space: nowrap;
883
+ display: flex;
884
+ align-items: center;
885
+ gap: 6px;
886
+ transition: color var(--transition-fast);
887
+ }
888
+
889
+ .cpub-tab:hover { color: var(--text); }
890
+
891
+ .cpub-tab.active {
892
+ color: var(--text);
893
+ font-weight: 600;
894
+ border-bottom-color: var(--border);
895
+ }
896
+
897
+ /* ── CONTENT GRID ── */
898
+ .cpub-content-grid {
899
+ display: grid;
900
+ grid-template-columns: 1fr 260px;
901
+ gap: 32px;
902
+ align-items: start;
903
+ padding-bottom: 64px;
904
+ }
905
+ .cpub-content-grid.cpub-has-toc {
906
+ grid-template-columns: 200px 1fr 260px;
907
+ }
908
+
909
+ /* ── PROSE ── */
910
+ .cpub-prose {
911
+ font-size: 13px;
912
+ line-height: 1.8;
913
+ color: var(--text-dim);
914
+ }
915
+
916
+ .cpub-prose :deep(h2),
917
+ .cpub-prose :deep(.section-title) {
918
+ font-size: 16px;
919
+ font-weight: 700;
920
+ color: var(--text);
921
+ margin-bottom: 12px;
922
+ letter-spacing: -0.01em;
923
+ }
924
+
925
+ .cpub-prose :deep(p) { margin-bottom: 12px; }
926
+ .cpub-prose :deep(strong) { color: var(--text); font-weight: 600; }
927
+ .cpub-prose :deep(a) { color: var(--accent); text-decoration: none; }
928
+ .cpub-prose :deep(a:hover) { text-decoration: underline; }
929
+ .cpub-prose :deep(code) {
930
+ font-family: var(--font-mono);
931
+ font-size: 11px;
932
+ background: var(--surface2);
933
+ padding: 2px 5px;
934
+ border: 1px solid var(--border2);
935
+ color: var(--accent);
936
+ }
937
+
938
+ .cpub-prose :deep(pre code) {
939
+ background: none;
940
+ border: none;
941
+ padding: 0;
942
+ color: inherit;
943
+ font-size: inherit;
944
+ }
945
+
946
+ .cpub-prose :deep(hr) {
947
+ border: none;
948
+ border-top: 1px solid var(--border);
949
+ margin: 24px 0;
950
+ }
951
+
952
+ /* ── SIDEBAR ── */
953
+ .cpub-sidebar {
954
+ display: flex;
955
+ flex-direction: column;
956
+ gap: 12px;
957
+ position: sticky;
958
+ top: 100px;
959
+ align-self: start;
960
+ }
961
+
962
+ /* ── TABLE OF CONTENTS (left column) ── */
963
+ .cpub-toc-col {
964
+ position: sticky;
965
+ top: 100px;
966
+ align-self: start;
967
+ max-height: calc(100vh - 120px);
968
+ overflow-y: auto;
969
+ scrollbar-width: none;
970
+ }
971
+ .cpub-toc-col::-webkit-scrollbar { display: none; }
972
+
973
+ .cpub-toc { padding: 0; }
974
+ .cpub-toc-title {
975
+ font-family: var(--font-mono); font-size: 9px; text-transform: uppercase;
976
+ letter-spacing: 0.1em; color: var(--text-faint); margin-bottom: 12px; font-weight: 700;
977
+ }
978
+ .cpub-toc-nav {
979
+ display: flex; flex-direction: column; gap: 0;
980
+ position: relative;
981
+ border-left: 1px solid var(--border);
982
+ }
983
+ .cpub-toc-item {
984
+ display: flex; align-items: flex-start; text-align: left; background: none;
985
+ border: none; cursor: pointer;
986
+ font-size: 11px; line-height: 1.35; color: var(--text-faint);
987
+ padding: 5px 0 5px 12px;
988
+ margin-left: -1px;
989
+ border-left: var(--border-width-default) solid transparent;
990
+ transition: color 0.15s, font-size 0.15s, border-color 0.15s;
991
+ }
992
+ .cpub-toc-item:hover { color: var(--text); }
993
+ .cpub-toc-item.active {
994
+ color: var(--text);
995
+ font-weight: 600;
996
+ font-size: 12px;
997
+ border-left-color: var(--accent);
998
+ }
999
+ .cpub-toc-text {
1000
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
1001
+ overflow: hidden;
1002
+ }
1003
+ .cpub-toc-h3 { padding-left: 22px; font-size: 10px; }
1004
+ .cpub-toc-h3.active { font-size: 11px; }
1005
+
1006
+ .cpub-sb-card {
1007
+ background: var(--surface);
1008
+ border: 1px solid var(--border);
1009
+ padding: 18px;
1010
+ box-shadow: var(--shadow-sm);
1011
+ }
1012
+
1013
+ .cpub-sb-title {
1014
+ font-size: 10px;
1015
+ font-weight: 700;
1016
+ font-family: var(--font-mono);
1017
+ letter-spacing: 0.12em;
1018
+ text-transform: uppercase;
1019
+ color: var(--text-dim);
1020
+ margin-bottom: 14px;
1021
+ padding-bottom: 8px;
1022
+ border-bottom: 1px solid var(--border);
1023
+ }
1024
+
1025
+ /* Stats grid */
1026
+ .cpub-stats-grid {
1027
+ display: grid;
1028
+ grid-template-columns: 1fr 1fr;
1029
+ border: 1px solid var(--border);
1030
+ overflow: hidden;
1031
+ }
1032
+
1033
+ .cpub-stat-cell {
1034
+ background: var(--surface2);
1035
+ padding: 14px;
1036
+ text-align: center;
1037
+ border-right: 1px solid var(--border);
1038
+ border-bottom: 1px solid var(--border);
1039
+ }
1040
+
1041
+ .cpub-stat-cell:nth-child(2n) { border-right: none; }
1042
+ .cpub-stat-cell:nth-child(n+3) { border-bottom: none; }
1043
+
1044
+ .cpub-stat-val {
1045
+ font-size: 18px;
1046
+ font-weight: 700;
1047
+ font-family: var(--font-mono);
1048
+ color: var(--text);
1049
+ line-height: 1;
1050
+ margin-bottom: 4px;
1051
+ }
1052
+
1053
+ .cpub-stat-label {
1054
+ font-size: 9px;
1055
+ font-family: var(--font-mono);
1056
+ color: var(--text-faint);
1057
+ text-transform: uppercase;
1058
+ letter-spacing: 0.1em;
1059
+ }
1060
+
1061
+ /* Difficulty */
1062
+ .cpub-diff-row {
1063
+ display: flex;
1064
+ align-items: center;
1065
+ justify-content: space-between;
1066
+ font-size: 12px;
1067
+ color: var(--text-dim);
1068
+ margin-bottom: 10px;
1069
+ }
1070
+
1071
+ .cpub-diff-dots {
1072
+ display: flex;
1073
+ gap: 4px;
1074
+ }
1075
+
1076
+ .cpub-diff-dot {
1077
+ width: 8px;
1078
+ height: 8px;
1079
+ border-radius: 50%;
1080
+ background: var(--border2);
1081
+ }
1082
+
1083
+ .cpub-diff-dot.on { background: var(--yellow); }
1084
+
1085
+ /* Meta rows */
1086
+ .cpub-meta-row {
1087
+ display: flex;
1088
+ align-items: center;
1089
+ gap: 10px;
1090
+ padding: 8px 0;
1091
+ border-bottom: 1px solid var(--border2);
1092
+ font-size: 12px;
1093
+ }
1094
+
1095
+ .cpub-meta-row:last-child { border-bottom: none; }
1096
+
1097
+ .cpub-meta-row-icon {
1098
+ width: 28px;
1099
+ height: 28px;
1100
+ background: var(--surface2);
1101
+ border: 1px solid var(--border2);
1102
+ display: flex;
1103
+ align-items: center;
1104
+ justify-content: center;
1105
+ font-size: 11px;
1106
+ color: var(--text-faint);
1107
+ flex-shrink: 0;
1108
+ }
1109
+
1110
+ .cpub-meta-row-label {
1111
+ font-size: 10px;
1112
+ font-family: var(--font-mono);
1113
+ color: var(--text-faint);
1114
+ margin-bottom: 2px;
1115
+ }
1116
+
1117
+ .cpub-meta-row-val {
1118
+ font-size: 12px;
1119
+ color: var(--text);
1120
+ font-weight: 500;
1121
+ }
1122
+
1123
+ /* Tags */
1124
+ .cpub-tag-cloud {
1125
+ display: flex;
1126
+ flex-wrap: wrap;
1127
+ gap: 6px;
1128
+ }
1129
+
1130
+ .cpub-tag {
1131
+ display: inline-flex;
1132
+ align-items: center;
1133
+ font-size: 10px;
1134
+ font-family: var(--font-mono);
1135
+ padding: 4px 10px;
1136
+ border: 1px solid var(--border);
1137
+ color: var(--text-dim);
1138
+ background: var(--surface);
1139
+ cursor: pointer;
1140
+ transition: border-color var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
1141
+ }
1142
+
1143
+ .cpub-tag:hover {
1144
+ border-color: var(--accent);
1145
+ color: var(--accent);
1146
+ background: var(--accent-bg);
1147
+ }
1148
+
1149
+ /* BOM Summary */
1150
+ .cpub-bom-summary-row {
1151
+ display: flex;
1152
+ align-items: center;
1153
+ justify-content: space-between;
1154
+ padding: 7px 0;
1155
+ border-bottom: 1px solid var(--border2);
1156
+ font-size: 12px;
1157
+ }
1158
+
1159
+ .cpub-bom-summary-row:last-child { border-bottom: none; }
1160
+ .cpub-bom-label { color: var(--text-dim); }
1161
+ .cpub-bom-val { font-family: var(--font-mono); color: var(--text); font-weight: 600; }
1162
+ .cpub-bom-green { color: var(--green); }
1163
+
1164
+ /* Hub card */
1165
+ .cpub-hub-card-inner {
1166
+ text-align: center;
1167
+ padding: 4px 0;
1168
+ }
1169
+
1170
+ .cpub-hub-icon {
1171
+ width: 44px;
1172
+ height: 44px;
1173
+ background: var(--purple-bg);
1174
+ border: 1px solid var(--border);
1175
+ display: flex;
1176
+ align-items: center;
1177
+ justify-content: center;
1178
+ font-size: 18px;
1179
+ color: var(--purple);
1180
+ margin: 0 auto 10px;
1181
+ }
1182
+
1183
+ .cpub-hub-name {
1184
+ font-size: 13px;
1185
+ font-weight: 700;
1186
+ color: var(--text);
1187
+ margin-bottom: 4px;
1188
+ }
1189
+
1190
+ .cpub-hub-desc {
1191
+ font-size: 11px;
1192
+ color: var(--text-faint);
1193
+ margin-bottom: 12px;
1194
+ line-height: 1.5;
1195
+ }
1196
+
1197
+ /* Link text */
1198
+ .cpub-link-text {
1199
+ font-size: 11px;
1200
+ font-family: var(--font-mono);
1201
+ color: var(--accent);
1202
+ text-decoration: none;
1203
+ }
1204
+
1205
+ .cpub-link-text:hover { color: var(--accent); text-decoration: underline; }
1206
+
1207
+ /* Buttons */
1208
+ .cpub-btn {
1209
+ font-size: 12px;
1210
+ padding: 6px 14px;
1211
+ border: 1px solid var(--border);
1212
+ background: var(--surface);
1213
+ color: var(--text);
1214
+ cursor: pointer;
1215
+ display: inline-flex;
1216
+ align-items: center;
1217
+ gap: 6px;
1218
+ transition: background var(--transition-fast);
1219
+ }
1220
+
1221
+ .cpub-btn:hover { background: var(--surface2); }
1222
+ .cpub-btn-sm { padding: 4px 10px; font-size: 11px; }
1223
+
1224
+ /* Tab badge */
1225
+ .cpub-tab-badge {
1226
+ font-size: 9px;
1227
+ font-family: var(--font-mono);
1228
+ background: var(--surface3);
1229
+ color: var(--text-faint);
1230
+ padding: 1px 5px;
1231
+ border: 1px solid var(--border2);
1232
+ }
1233
+
1234
+ /* BOM products in sidebar */
1235
+ .cpub-bom-products-header {
1236
+ font-size: 10px;
1237
+ font-family: var(--font-mono);
1238
+ color: var(--text-faint);
1239
+ letter-spacing: 0.08em;
1240
+ text-transform: uppercase;
1241
+ margin-top: 12px;
1242
+ margin-bottom: 8px;
1243
+ padding-top: 8px;
1244
+ border-top: 1px solid var(--border2);
1245
+ }
1246
+
1247
+ .cpub-bom-product-row {
1248
+ display: flex;
1249
+ align-items: center;
1250
+ justify-content: space-between;
1251
+ padding: 5px 0;
1252
+ font-size: 12px;
1253
+ }
1254
+
1255
+ .cpub-bom-product-link {
1256
+ color: var(--accent);
1257
+ text-decoration: none;
1258
+ font-weight: 500;
1259
+ }
1260
+
1261
+ .cpub-bom-product-link:hover { text-decoration: underline; }
1262
+
1263
+ .cpub-bom-qty {
1264
+ font-family: var(--font-mono);
1265
+ font-size: 11px;
1266
+ color: var(--text-faint);
1267
+ }
1268
+
1269
+ .cpub-bom-link-row {
1270
+ padding-top: 10px;
1271
+ text-align: center;
1272
+ }
1273
+
1274
+ /* ── TAB CONTENT ── */
1275
+ .cpub-tab-section-title {
1276
+ font-size: 14px;
1277
+ font-weight: 700;
1278
+ color: var(--text);
1279
+ display: flex;
1280
+ align-items: center;
1281
+ gap: 8px;
1282
+ margin-bottom: 16px;
1283
+ padding-bottom: 10px;
1284
+ border-bottom: 1px solid var(--border);
1285
+ }
1286
+
1287
+ .cpub-tab-section-title i { font-size: 12px; color: var(--text-faint); }
1288
+ .cpub-tab-empty { font-size: 13px; color: var(--text-faint); text-align: center; padding: 48px 0; }
1289
+
1290
+ /* Parts Table */
1291
+ .cpub-bom-section { margin-bottom: 32px; }
1292
+ .cpub-parts-table-wrap { overflow-x: auto; }
1293
+
1294
+ .cpub-parts-table {
1295
+ width: 100%;
1296
+ border-collapse: collapse;
1297
+ font-size: 13px;
1298
+ }
1299
+
1300
+ .cpub-parts-table th {
1301
+ font-family: var(--font-mono);
1302
+ font-size: 10px;
1303
+ font-weight: 600;
1304
+ letter-spacing: 0.08em;
1305
+ text-transform: uppercase;
1306
+ color: var(--text-faint);
1307
+ padding: 8px 12px;
1308
+ text-align: left;
1309
+ background: var(--surface2);
1310
+ border-bottom: 1px solid var(--border);
1311
+ }
1312
+
1313
+ .cpub-parts-table td {
1314
+ padding: 10px 12px;
1315
+ border-bottom: 1px solid var(--border2);
1316
+ color: var(--text-dim);
1317
+ }
1318
+
1319
+ .cpub-part-name { font-weight: 500; color: var(--text); }
1320
+ .cpub-part-qty { font-family: var(--font-mono); font-size: 12px; text-align: center; width: 50px; }
1321
+ .cpub-part-notes { font-size: 12px; color: var(--text-faint); }
1322
+
1323
+ /* Linked Products */
1324
+ .cpub-linked-products { display: flex; flex-direction: column; gap: 8px; }
1325
+
1326
+ .cpub-linked-product {
1327
+ display: flex; align-items: center; gap: 12px;
1328
+ padding: 10px 14px;
1329
+ background: var(--surface);
1330
+ border: 1px solid var(--border);
1331
+ box-shadow: var(--shadow-sm);
1332
+ }
1333
+
1334
+ .cpub-linked-product-icon {
1335
+ width: 32px; height: 32px;
1336
+ background: var(--accent-bg);
1337
+ border: var(--border-width-default) solid var(--accent-border);
1338
+ display: flex; align-items: center; justify-content: center;
1339
+ font-size: 12px; color: var(--accent); flex-shrink: 0;
1340
+ }
1341
+
1342
+ .cpub-linked-product-info { flex: 1; display: flex; align-items: center; gap: 8px; }
1343
+ .cpub-linked-product-name { font-size: 13px; font-weight: 600; color: var(--accent); text-decoration: none; }
1344
+ .cpub-linked-product-name:hover { text-decoration: underline; }
1345
+ .cpub-linked-product-qty { font-family: var(--font-mono); font-size: 11px; color: var(--text-faint); }
1346
+
1347
+ /* Build Steps */
1348
+ .cpub-build-steps { display: flex; flex-direction: column; gap: 16px; }
1349
+
1350
+ .cpub-build-step {
1351
+ border: 1px solid var(--border);
1352
+ overflow: hidden;
1353
+ box-shadow: var(--shadow-sm);
1354
+ }
1355
+
1356
+ .cpub-build-step-header {
1357
+ display: flex; align-items: center; gap: 10px;
1358
+ padding: 12px 16px;
1359
+ background: var(--border);
1360
+ color: var(--surface);
1361
+ }
1362
+
1363
+ .cpub-build-step-num {
1364
+ width: 28px; height: 28px;
1365
+ background: var(--accent);
1366
+ display: flex; align-items: center; justify-content: center;
1367
+ font-family: var(--font-mono); font-size: 13px; font-weight: 700;
1368
+ flex-shrink: 0;
1369
+ }
1370
+
1371
+ .cpub-build-step-title { font-size: 14px; font-weight: 600; flex: 1; }
1372
+ .cpub-build-step-time { font-family: var(--font-mono); font-size: 11px; opacity: 0.7; display: flex; align-items: center; gap: 4px; }
1373
+
1374
+ .cpub-build-step-body {
1375
+ padding: 16px;
1376
+ font-size: 13px;
1377
+ line-height: 1.7;
1378
+ color: var(--text-dim);
1379
+ }
1380
+
1381
+ .cpub-build-step-body p { margin-bottom: 12px; }
1382
+ .cpub-build-step-body p:last-child { margin-bottom: 0; }
1383
+
1384
+ .cpub-build-step-img {
1385
+ width: 100%;
1386
+ max-height: 400px;
1387
+ object-fit: cover;
1388
+ border: 1px solid var(--border);
1389
+ margin-top: 12px;
1390
+ }
1391
+
1392
+ /* Code Tab */
1393
+ .cpub-code-tab { display: flex; flex-direction: column; gap: 16px; }
1394
+
1395
+ .cpub-code-snippet {
1396
+ border: 1px solid var(--border);
1397
+ overflow: hidden;
1398
+ box-shadow: var(--shadow-sm);
1399
+ }
1400
+
1401
+ .cpub-code-snippet-header {
1402
+ display: flex; align-items: center; gap: 8px;
1403
+ padding: 8px 14px;
1404
+ background: var(--surface2);
1405
+ border-bottom: 1px solid var(--border);
1406
+ }
1407
+
1408
+ .cpub-code-lang-label {
1409
+ font-family: var(--font-mono);
1410
+ font-size: 10px;
1411
+ font-weight: 600;
1412
+ text-transform: uppercase;
1413
+ letter-spacing: 0.06em;
1414
+ color: var(--accent);
1415
+ }
1416
+
1417
+ .cpub-code-filename {
1418
+ font-family: var(--font-mono);
1419
+ font-size: 10px;
1420
+ color: var(--text-faint);
1421
+ margin-left: auto;
1422
+ }
1423
+
1424
+ .cpub-code-body {
1425
+ margin: 0;
1426
+ padding: 16px;
1427
+ background: var(--text);
1428
+ color: var(--surface);
1429
+ font-family: var(--font-mono);
1430
+ font-size: 13px;
1431
+ line-height: 1.6;
1432
+ overflow-x: auto;
1433
+ white-space: pre;
1434
+ }
1435
+
1436
+ /* Files Tab */
1437
+ .cpub-files-tab { display: flex; flex-direction: column; gap: 8px; }
1438
+
1439
+ .cpub-file-row {
1440
+ display: flex; align-items: center; gap: 12px;
1441
+ padding: 12px 14px;
1442
+ background: var(--surface);
1443
+ border: 1px solid var(--border);
1444
+ box-shadow: var(--shadow-sm);
1445
+ }
1446
+
1447
+ .cpub-file-icon {
1448
+ width: 32px; height: 32px;
1449
+ background: var(--surface2);
1450
+ border: 1px solid var(--border2);
1451
+ display: flex; align-items: center; justify-content: center;
1452
+ font-size: 12px; color: var(--text-faint); flex-shrink: 0;
1453
+ }
1454
+
1455
+ .cpub-file-info { flex: 1; min-width: 0; }
1456
+ .cpub-file-name { font-size: 13px; font-weight: 500; color: var(--accent); text-decoration: none; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1457
+ .cpub-file-name:hover { text-decoration: underline; }
1458
+ .cpub-file-size { font-family: var(--font-mono); font-size: 10px; color: var(--text-faint); }
1459
+
1460
+ .cpub-file-download {
1461
+ width: 32px; height: 32px;
1462
+ display: flex; align-items: center; justify-content: center;
1463
+ background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border);
1464
+ color: var(--accent); font-size: 12px; text-decoration: none; flex-shrink: 0;
1465
+ }
1466
+
1467
+ .cpub-file-download:hover { background: var(--accent); color: var(--color-text-inverse); }
1468
+
1469
+ /* Cover image */
1470
+ .cpub-hero-cover-has-image {
1471
+ background: var(--border);
1472
+ }
1473
+
1474
+ .cpub-hero-cover-img {
1475
+ width: 100%;
1476
+ height: 100%;
1477
+ object-fit: cover;
1478
+ }
1479
+
1480
+ /* ── RESPONSIVE ── */
1481
+ @media (max-width: 1200px) {
1482
+ .cpub-content-grid.cpub-has-toc {
1483
+ grid-template-columns: 1fr 260px;
1484
+ }
1485
+ .cpub-toc-col { display: none; }
1486
+ }
1487
+ @media (max-width: 1024px) {
1488
+ .cpub-content-grid,
1489
+ .cpub-content-grid.cpub-has-toc {
1490
+ grid-template-columns: 1fr;
1491
+ }
1492
+ .cpub-sidebar { position: static; }
1493
+ .cpub-toc-col { display: none; }
1494
+ }
1495
+
1496
+ @media (max-width: 640px) {
1497
+ .cpub-page-outer { padding: 0 16px; }
1498
+ .cpub-hero-cover { height: 180px; }
1499
+ }
1500
+ </style>