@jant/core 0.3.24 → 0.3.25

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 (206) hide show
  1. package/dist/app.js +50 -25
  2. package/dist/db/schema.js +1 -1
  3. package/dist/i18n/locales/en.js +1 -1
  4. package/dist/i18n/locales/zh-Hans.js +1 -1
  5. package/dist/i18n/locales/zh-Hant.js +1 -1
  6. package/dist/index.js +3 -9
  7. package/dist/lib/constants.js +1 -0
  8. package/dist/lib/nav-reorder.js +1 -1
  9. package/dist/lib/navigation.js +26 -1
  10. package/dist/lib/pagination.js +44 -0
  11. package/dist/lib/render.js +7 -11
  12. package/dist/lib/schemas.js +3 -3
  13. package/dist/lib/theme.js +4 -4
  14. package/dist/lib/timeline.js +24 -48
  15. package/dist/lib/view.js +2 -2
  16. package/dist/routes/api/collections.js +124 -0
  17. package/dist/routes/api/nav-items.js +104 -0
  18. package/dist/routes/api/pages.js +91 -0
  19. package/dist/routes/api/posts.js +2 -2
  20. package/dist/routes/api/search.js +2 -2
  21. package/dist/routes/api/settings.js +68 -0
  22. package/dist/routes/compose.js +48 -0
  23. package/dist/routes/dash/collections.js +2 -2
  24. package/dist/routes/dash/index.js +1 -1
  25. package/dist/routes/dash/media.js +2 -2
  26. package/dist/routes/dash/pages.js +411 -62
  27. package/dist/routes/dash/posts.js +3 -5
  28. package/dist/routes/dash/redirects.js +2 -2
  29. package/dist/routes/dash/settings.js +79 -5
  30. package/dist/routes/feed/rss.js +2 -2
  31. package/dist/routes/feed/sitemap.js +1 -1
  32. package/dist/routes/pages/archive.js +3 -6
  33. package/dist/routes/pages/collection.js +3 -6
  34. package/dist/routes/pages/collections.js +28 -0
  35. package/dist/routes/pages/featured.js +32 -0
  36. package/dist/routes/pages/home.js +9 -50
  37. package/dist/routes/pages/page.js +29 -32
  38. package/dist/routes/pages/post.js +3 -6
  39. package/dist/routes/pages/search.js +3 -6
  40. package/dist/services/page.js +5 -1
  41. package/dist/services/post.js +40 -6
  42. package/dist/services/search.js +1 -1
  43. package/dist/ui/compose/ComposeDialog.js +452 -0
  44. package/dist/ui/compose/ComposePrompt.js +55 -0
  45. package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +1 -2
  46. package/dist/{theme/components → ui/dash}/PostForm.js +0 -27
  47. package/dist/{theme/components → ui/dash}/PostList.js +6 -6
  48. package/dist/{theme/components/VisibilityBadge.js → ui/dash/StatusBadge.js} +1 -2
  49. package/dist/{theme/components → ui/dash}/index.js +3 -6
  50. package/dist/{themes/threads/timeline → ui/feed}/LinkCard.js +6 -2
  51. package/dist/{themes/threads/timeline → ui/feed}/NoteCard.js +11 -6
  52. package/dist/{themes/threads/timeline → ui/feed}/QuoteCard.js +10 -6
  53. package/dist/{themes/threads/timeline → ui/feed}/ThreadPreview.js +7 -9
  54. package/dist/ui/feed/TimelineFeed.js +41 -0
  55. package/dist/ui/feed/TimelineItem.js +27 -0
  56. package/dist/{theme → ui}/layouts/BaseLayout.js +10 -0
  57. package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
  58. package/dist/ui/layouts/SiteLayout.js +141 -0
  59. package/dist/{themes/threads → ui}/pages/ArchivePage.js +16 -14
  60. package/dist/{themes/threads → ui}/pages/CollectionPage.js +6 -1
  61. package/dist/ui/pages/CollectionsPage.js +76 -0
  62. package/dist/ui/pages/FeaturedPage.js +24 -0
  63. package/dist/ui/pages/HomePage.js +24 -0
  64. package/dist/{themes/threads → ui}/pages/PostPage.js +13 -8
  65. package/dist/{themes/threads → ui}/pages/SearchPage.js +9 -7
  66. package/dist/{themes/threads → ui}/pages/SinglePage.js +3 -2
  67. package/dist/{theme/components → ui/shared}/MediaGallery.js +1 -1
  68. package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
  69. package/dist/{theme/components → ui/shared}/ThreadView.js +2 -2
  70. package/dist/ui/shared/index.js +5 -0
  71. package/package.json +1 -9
  72. package/src/__tests__/helpers/db.ts +3 -0
  73. package/src/app.tsx +57 -27
  74. package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
  75. package/src/db/migrations/meta/_journal.json +7 -0
  76. package/src/db/schema.ts +1 -1
  77. package/src/i18n/locales/en.po +332 -181
  78. package/src/i18n/locales/en.ts +1 -1
  79. package/src/i18n/locales/zh-Hans.po +332 -181
  80. package/src/i18n/locales/zh-Hans.ts +1 -1
  81. package/src/i18n/locales/zh-Hant.po +332 -181
  82. package/src/i18n/locales/zh-Hant.ts +1 -1
  83. package/src/index.ts +7 -36
  84. package/src/lib/__tests__/schemas.test.ts +60 -19
  85. package/src/lib/__tests__/timeline.test.ts +45 -81
  86. package/src/lib/__tests__/view.test.ts +13 -7
  87. package/src/lib/constants.ts +1 -0
  88. package/src/lib/nav-reorder.ts +1 -1
  89. package/src/lib/navigation.ts +40 -2
  90. package/src/lib/pagination.ts +50 -0
  91. package/src/lib/render.tsx +7 -14
  92. package/src/lib/schemas.ts +8 -6
  93. package/src/lib/theme.ts +5 -5
  94. package/src/lib/timeline.ts +28 -57
  95. package/src/lib/view.ts +2 -2
  96. package/src/preset.css +2 -1
  97. package/src/routes/__tests__/compose.test.ts +199 -0
  98. package/src/routes/api/__tests__/collections.test.ts +249 -0
  99. package/src/routes/api/__tests__/nav-items.test.ts +222 -0
  100. package/src/routes/api/__tests__/pages.test.ts +218 -0
  101. package/src/routes/api/__tests__/settings.test.ts +132 -0
  102. package/src/routes/api/collections.ts +143 -0
  103. package/src/routes/api/nav-items.ts +115 -0
  104. package/src/routes/api/pages.ts +101 -0
  105. package/src/routes/api/posts.ts +2 -2
  106. package/src/routes/api/search.ts +2 -2
  107. package/src/routes/api/settings.ts +91 -0
  108. package/src/routes/compose.ts +63 -0
  109. package/src/routes/dash/__tests__/pages.test.ts +225 -0
  110. package/src/routes/dash/collections.tsx +2 -2
  111. package/src/routes/dash/index.tsx +1 -1
  112. package/src/routes/dash/media.tsx +2 -2
  113. package/src/routes/dash/pages.tsx +443 -70
  114. package/src/routes/dash/posts.tsx +3 -7
  115. package/src/routes/dash/redirects.tsx +2 -2
  116. package/src/routes/dash/settings.tsx +83 -5
  117. package/src/routes/feed/rss.ts +2 -2
  118. package/src/routes/feed/sitemap.ts +1 -1
  119. package/src/routes/pages/__tests__/collections.test.ts +94 -0
  120. package/src/routes/pages/__tests__/featured.test.ts +94 -0
  121. package/src/routes/pages/archive.tsx +2 -6
  122. package/src/routes/pages/collection.tsx +2 -6
  123. package/src/routes/pages/collections.tsx +36 -0
  124. package/src/routes/pages/featured.tsx +38 -0
  125. package/src/routes/pages/home.tsx +9 -55
  126. package/src/routes/pages/page.tsx +28 -30
  127. package/src/routes/pages/post.tsx +2 -5
  128. package/src/routes/pages/search.tsx +2 -6
  129. package/src/services/__tests__/page.test.ts +106 -0
  130. package/src/services/__tests__/post.test.ts +114 -15
  131. package/src/services/page.ts +13 -1
  132. package/src/services/post.ts +57 -7
  133. package/src/services/search.ts +2 -2
  134. package/src/styles/tokens.css +47 -0
  135. package/src/styles/ui.css +491 -0
  136. package/src/types.ts +29 -159
  137. package/src/ui/compose/ComposeDialog.tsx +395 -0
  138. package/src/ui/compose/ComposePrompt.tsx +55 -0
  139. package/src/{theme/components/TypeBadge.tsx → ui/dash/FormatBadge.tsx} +2 -3
  140. package/src/{theme/components → ui/dash}/PostForm.tsx +0 -25
  141. package/src/{theme/components → ui/dash}/PostList.tsx +7 -7
  142. package/src/{theme/components/VisibilityBadge.tsx → ui/dash/StatusBadge.tsx} +2 -3
  143. package/src/ui/dash/index.ts +10 -0
  144. package/src/{themes/threads/timeline → ui/feed}/LinkCard.tsx +9 -4
  145. package/src/{themes/threads/timeline → ui/feed}/NoteCard.tsx +13 -8
  146. package/src/{themes/threads/timeline → ui/feed}/QuoteCard.tsx +13 -8
  147. package/src/{themes/threads/timeline → ui/feed}/ThreadPreview.tsx +7 -8
  148. package/src/ui/feed/TimelineFeed.tsx +49 -0
  149. package/src/ui/feed/TimelineItem.tsx +45 -0
  150. package/src/{theme → ui}/layouts/BaseLayout.tsx +11 -1
  151. package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
  152. package/src/ui/layouts/SiteLayout.tsx +150 -0
  153. package/src/{themes/threads → ui}/pages/ArchivePage.tsx +22 -17
  154. package/src/{themes/threads → ui}/pages/CollectionPage.tsx +14 -5
  155. package/src/ui/pages/CollectionsPage.tsx +73 -0
  156. package/src/ui/pages/FeaturedPage.tsx +31 -0
  157. package/src/{themes/threads → ui}/pages/HomePage.tsx +11 -15
  158. package/src/{themes/threads → ui}/pages/PostPage.tsx +23 -14
  159. package/src/{themes/threads → ui}/pages/SearchPage.tsx +13 -11
  160. package/src/{themes/threads → ui}/pages/SinglePage.tsx +4 -4
  161. package/src/{theme/components → ui/shared}/MediaGallery.tsx +1 -1
  162. package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
  163. package/src/{theme/components → ui/shared}/ThreadView.tsx +2 -2
  164. package/src/ui/shared/__tests__/pagination.test.ts +46 -0
  165. package/src/ui/shared/index.ts +12 -0
  166. package/bin/jant.js +0 -185
  167. package/dist/lib/theme-components.js +0 -46
  168. package/dist/routes/dash/navigation.js +0 -289
  169. package/dist/theme/index.js +0 -18
  170. package/dist/theme/layouts/index.js +0 -2
  171. package/dist/themes/threads/ThreadsSiteLayout.js +0 -172
  172. package/dist/themes/threads/index.js +0 -81
  173. package/dist/themes/threads/pages/HomePage.js +0 -25
  174. package/dist/themes/threads/timeline/TimelineFeed.js +0 -58
  175. package/dist/themes/threads/timeline/TimelineItem.js +0 -36
  176. package/dist/themes/threads/timeline/TimelineLoadMore.js +0 -23
  177. package/dist/themes/threads/timeline/groupByDate.js +0 -22
  178. package/dist/themes/threads/timeline/timelineMore.js +0 -107
  179. package/src/lib/__tests__/theme-components.test.ts +0 -105
  180. package/src/lib/theme-components.ts +0 -65
  181. package/src/routes/dash/navigation.tsx +0 -317
  182. package/src/theme/components/index.ts +0 -23
  183. package/src/theme/index.ts +0 -22
  184. package/src/theme/layouts/index.ts +0 -7
  185. package/src/themes/threads/ThreadsSiteLayout.tsx +0 -194
  186. package/src/themes/threads/index.ts +0 -100
  187. package/src/themes/threads/style.css +0 -336
  188. package/src/themes/threads/timeline/TimelineFeed.tsx +0 -62
  189. package/src/themes/threads/timeline/TimelineItem.tsx +0 -67
  190. package/src/themes/threads/timeline/TimelineLoadMore.tsx +0 -35
  191. package/src/themes/threads/timeline/groupByDate.ts +0 -30
  192. package/src/themes/threads/timeline/timelineMore.tsx +0 -130
  193. /package/dist/{theme → ui}/color-themes.js +0 -0
  194. /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
  195. /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
  196. /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
  197. /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
  198. /package/dist/{theme/components → ui/dash}/PageForm.js +0 -0
  199. /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
  200. /package/src/{theme → ui}/color-themes.ts +0 -0
  201. /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
  202. /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
  203. /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
  204. /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
  205. /package/src/{theme/components → ui/dash}/PageForm.tsx +0 -0
  206. /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
@@ -1 +1 @@
1
- /*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"+7Wr2a\":[\"Edit: \",[\"title\"]],\"+MACwa\":[\"尚未有任何收藏。\"],\"+bHzpy\":[\"顯示連結的文字\"],\"+owNNn\":[\"文章\"],\"+zy2Nq\":[\"類型\"],\"/0D1Xp\":[\"編輯收藏集\"],\"/Rj5P4\":[\"您的姓名\"],\"07Epll\":[\"這將為您的網站和儀表板設置主題。所有顏色主題都支持深色模式。\"],\"0JkyS7\":[\"建立您的第一個頁面\"],\"0a6MpL\":[\"新重定向\"],\"1CU1Td\":[\"網址安全識別碼(小寫、數字、連字符)\"],\"1DBGsz\":[\"筆記\"],\"1o+wgo\":[\"例如:The Verge,約翰·多伊\"],\"2N0qpv\":[\"文章標題...\"],\"2fUwEY\":[\"選擇媒體\"],\"2q/Q7x\":[\"可見性\"],\"2rJGtU\":[\"頁面標題...\"],\"3Yvsaz\":[\"302(臨時)\"],\"4/SFQS\":[\"查看網站\"],\"40TVQj\":[\"自訂路徑(選填)\"],\"4KzVT6\":[\"刪除頁面\"],\"4b3oEV\":[\"內容\"],\"4mDPGp\":[\"此頁面的 URL 路徑。使用小寫字母、數字和連字符。\"],\"6WdDG7\":[\"頁面\"],\"6YtxFj\":[\"名稱\"],\"7G4SBz\":[\"頁面內容(支持Markdown)...\"],\"7Mk+/h\":[\"更新收藏集\"],\"7Q1KKN\":[\"來源路徑\"],\"7aECQB\":[\"無效或已過期的連結\"],\"7nGhhM\":[\"你在想什麼?\"],\"7p5kLi\":[\"儀表板\"],\"7vhWI8\":[\"新密碼\"],\"87a/t/\":[\"標籤\"],\"8ZsakT\":[\"密碼\"],\"90Luob\":[[\"count\"],\" 條回覆\"],\"A1taO8\":[\"搜尋\"],\"AeXO77\":[\"帳戶\"],\"AyHO4m\":[\"這個收藏是關於什麼的?\"],\"B373X+\":[\"編輯文章\"],\"B495Gs\":[\"檔案館\"],\"BjF0Jv\":[\"僅限小寫字母、數字和連字符\"],\"D9Oea+\":[\"永久鏈接\"],\"DCKkhU\":[\"當前密碼\"],\"DHhJ7s\":[\"上一頁\"],\"DPfwMq\":[\"完成\"],\"DoJzLz\":[\"收藏夾\"],\"E80cJw\":[\"刪除此媒體將永久從存儲中移除它。\"],\"EEYbdt\":[\"發佈\"],\"EGwzOK\":[\"完成設置\"],\"EdQY6l\":[\"None\"],\"EkH9pt\":[\"更新\"],\"FGrimz\":[\"新帖子\"],\"FkMol5\":[\"精選\"],\"Fxf4jq\":[\"描述(可選)\"],\"GA5A5H\":[\"刪除收藏夾\"],\"GX2VMa\":[\"建立您的管理員帳戶。\"],\"GbVAnd\":[\"此密碼重設連結無效或已過期。請生成一個新的連結。\"],\"GorKul\":[\"歡迎來到 Jant\"],\"GrZ6fH\":[\"新頁面\"],\"GxkJXS\":[\"上傳中...\"],\"HfyyXl\":[\"My Blog\"],\"HiETwV\":[\"安靜(正常)\"],\"Hzi9AA\":[\"未找到任何帖子。\"],\"I6gXOa\":[\"路徑\"],\"I8hDlV\":[\"至少需要 1 張圖片才能發佈圖片帖子。\"],\"IUwGEM\":[\"保存更改\"],\"IagCbF\":[\"網址\"],\"J4FNfC\":[\"此集合中沒有帖子。\"],\"JIBC/T\":[\"Supported formats: JPEG, PNG, GIF, WebP, SVG. Max size: 10MB.\"],\"Jed1wB\":[\"需要幫助嗎?請訪問<0>文檔</0>。\"],\"JiP4aa\":[\"已發佈的頁面可以通過其路徑訪問。草稿不可見。\"],\"K9NcLu\":[\"使用此 URL 將媒體嵌入到您的帖子中。\"],\"KbS2K9\":[\"重設密碼\"],\"KiJn9B\":[\"備註\"],\"KmGXnO\":[\"Are you sure you want to delete this post? This cannot be undone.\"],\"L85WcV\":[\"縮略名\"],\"LkvLQe\":[\"尚未有頁面。\"],\"M1RvTd\":[\"點擊圖片以查看完整大小\"],\"M8kJqa\":[\"草稿\"],\"M9xgHy\":[\"重定向\"],\"MHrjPM\":[\"標題\"],\"MLSRl9\":[\"Quote Text\"],\"MWBOxm\":[\"收藏(可選)\"],\"MZbQHL\":[\"未找到結果。\"],\"Mhf/H/\":[\"建立重定向\"],\"MqghUt\":[\"搜尋帖子...\"],\"N40H+G\":[\"所有\"],\"O3oNi5\":[\"電子郵件\"],\"OCNZaU\":[\"重定向來源的路徑\"],\"ODiSoW\":[\"尚未有帖子。\"],\"ONWvwQ\":[\"上傳\"],\"Pbm2/N\":[\"創建收藏夾\"],\"QEbNBb\":[\"路徑(例如 /archive)或完整網址(例如 https://example.com)\"],\"QLkhbH\":[\"The text being quoted...\"],\"RDjuBN\":[\"Setup\"],\"Rj01Fz\":[\"連結\"],\"RwGhWy\":[\"包含 \",[\"count\"],\" 則帖子的主題\"],\"SJmfuf\":[\"網站名稱\"],\"ST+lN2\":[\"尚未上傳任何媒體。\"],\"Tt5T6+\":[\"文章\"],\"TxE+Mj\":[\"1 條回覆\"],\"Tz0i8g\":[\"設定\"],\"U5v6Gh\":[\"編輯頁面\"],\"UDMjsP\":[\"快速操作\"],\"UGT5vp\":[\"保存設定\"],\"UxKoFf\":[\"導航\"],\"VUSy8D\":[\"Search failed. Please try again.\"],\"VhMDMg\":[\"更改密碼\"],\"WDcQq9\":[\"不公開\"],\"Weq9zb\":[\"一般設定\"],\"WmZ/rP\":[\"到路徑\"],\"Y+7JGK\":[\"創建頁面\"],\"Z3FXyt\":[\"載入中...\"],\"ZQKLI1\":[\"危險區域\"],\"ZhhOwV\":[\"引用\"],\"aAIQg2\":[\"外觀\"],\"aaGV/9\":[\"新連結\"],\"an5hVd\":[\"圖片\"],\"b+/jO6\":[\"301(永久)\"],\"bHYIks\":[\"登出\"],\"biOepV\":[\"← Back to home\"],\"cnGeoo\":[\"刪除\"],\"dEgA5A\":[\"取消\"],\"e6Jr7Q\":[\"← 返回收藏夾\"],\"ePK91l\":[\"編輯\"],\"eWLklq\":[\"引用\"],\"eneWvv\":[\"草稿\"],\"er8+x7\":[\"示範帳戶已預填。只需點擊登入。\"],\"f6e0Ry\":[\"文章\"],\"fG7BxZ\":[\"Upload images via the API: POST /api/upload with a file form field.\"],\"fttd2R\":[\"我的收藏\"],\"gDx5MG\":[\"編輯連結\"],\"hG89Ed\":[\"圖片\"],\"hWOZIv\":[\"請輸入您的新密碼。\"],\"hXzOVo\":[\"下一頁\"],\"he3ygx\":[\"複製\"],\"iBc+/N\":[\"Custom URL path. Leave empty to use default /p/ID format.\"],\"iDAqU6\":[\"URL (optional)\"],\"iH8pgl\":[\"返回\"],\"ig4hg2\":[\"Let's set up your site.\"],\"jSRrXo\":[\"Published pages are accessible via their slug. Drafts are not visible.\"],\"jpctdh\":[\"查看\"],\"k1ifdL\":[\"處理中...\"],\"kI1qVD\":[\"Format\"],\"kNiQp6\":[\"Pinned\"],\"kd7eBB\":[\"建立連結\"],\"mTOYla\":[\"View all posts →\"],\"mnkknn\":[\"Collection (optional)\"],\"n1ekoW\":[\"登入\"],\"oJFOZk\":[\"來源名稱(選填)\"],\"oYPBa0\":[\"更新頁面\"],\"p2/GCq\":[\"確認密碼\"],\"pRhYH2\":[\"收藏中的帖子 (\",[\"count\"],\")\"],\"pZq3aX\":[\"上傳失敗。請再試一次。\"],\"qMyM2u\":[\"來源網址(選填)\"],\"qiXmlF\":[\"添加媒體\"],\"quFPTj\":[\"Custom Slug (optional)\"],\"r1MpXi\":[\"安靜\"],\"rFmBG3\":[\"顏色主題\"],\"rdUucN\":[\"預覽\"],\"rzNUSl\":[\"包含 1 則貼文的主題\"],\"sGajR7\":[\"線程開始\"],\"smzF8S\":[\"顯示 \",[\"remainingCount\"],\" 個更多 \",[\"0\"]],\"ssqvZi\":[\"保存個人資料\"],\"t/YqKh\":[\"移除\"],\"tfrt7B\":[\"未配置任何重定向。\"],\"tiq7kl\":[\"頁面 \",[\"page\"]],\"u2f7vd\":[\"網站描述\"],\"u3wRF+\":[\"已發佈\"],\"u6Hp4N\":[\"Markdown\"],\"uAQUqI\":[\"狀態\"],\"vERlcd\":[\"個人資料\"],\"vXIe7J\":[\"語言\"],\"vzU4k9\":[\"新收藏集\"],\"wEF6Ix\":[\"目的地路徑或 URL\"],\"wK4OTM\":[\"標題(選填)\"],\"wM5UXj\":[\"刪除媒體\"],\"wRR604\":[\"頁面\"],\"wdGjkd\":[\"未配置導航連結。\"],\"wja8aL\":[\"無標題\"],\"x+doid\":[\"圖片會自動優化:調整大小至最大 1920 像素,轉換為 WebP 格式,並去除元數據。\"],\"x0mzE0\":[\"創建你的第一篇帖子\"],\"x4RuFo\":[\"Back to home\"],\"xYilR2\":[\"媒體\"],\"y28hnO\":[\"文章\"],\"yQ2kGp\":[\"載入更多\"],\"yjkELF\":[\"確認新密碼\"],\"yzF66j\":[\"連結\"],\"z8ajIE\":[\"找到 1 個結果\"],\"zH6KqE\":[\"找到 \",[\"count\"],\" 個結果\"]}")as Messages;
1
+ /*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"+7Wr2a\":[\"Edit: \",[\"title\"]],\"+MACwa\":[\"尚未有任何收藏。\"],\"+MH6k9\":[\"Add to nav\"],\"+bHzpy\":[\"顯示連結的文字\"],\"+owNNn\":[\"文章\"],\"+zy2Nq\":[\"類型\"],\"/0D1Xp\":[\"編輯收藏集\"],\"/Rj5P4\":[\"您的姓名\"],\"/rkqRV\":[\"All pages are in your navigation.\"],\"07Epll\":[\"這將為您的網站和儀表板設置主題。所有顏色主題都支持深色模式。\"],\"0JkyS7\":[\"建立您的第一個頁面\"],\"0a6MpL\":[\"新重定向\"],\"0yIy82\":[\"No featured posts yet.\"],\"1CU1Td\":[\"網址安全識別碼(小寫、數字、連字符)\"],\"1DBGsz\":[\"筆記\"],\"1o+wgo\":[\"例如:The Verge,約翰·多伊\"],\"2N0qpv\":[\"文章標題...\"],\"2fUwEY\":[\"選擇媒體\"],\"2q/Q7x\":[\"可見性\"],\"2rJGtU\":[\"頁面標題...\"],\"3Yvsaz\":[\"302(臨時)\"],\"4/SFQS\":[\"查看網站\"],\"40TVQj\":[\"自訂路徑(選填)\"],\"4KzVT6\":[\"刪除頁面\"],\"4b3oEV\":[\"內容\"],\"4mDPGp\":[\"此頁面的 URL 路徑。使用小寫字母、數字和連字符。\"],\"6WdDG7\":[\"頁面\"],\"6YtxFj\":[\"名稱\"],\"7G4SBz\":[\"頁面內容(支持Markdown)...\"],\"7Mk+/h\":[\"更新收藏集\"],\"7Q1KKN\":[\"來源路徑\"],\"7aECQB\":[\"無效或已過期的連結\"],\"7nGhhM\":[\"你在想什麼?\"],\"7p5kLi\":[\"儀表板\"],\"7vhWI8\":[\"新密碼\"],\"87a/t/\":[\"標籤\"],\"8ZsakT\":[\"密碼\"],\"9+vGLh\":[\"Custom CSS\"],\"90Luob\":[[\"count\"],\" 條回覆\"],\"A1taO8\":[\"搜尋\"],\"AeXO77\":[\"帳戶\"],\"AyHO4m\":[\"這個收藏是關於什麼的?\"],\"B373X+\":[\"編輯文章\"],\"B495Gs\":[\"檔案館\"],\"BjF0Jv\":[\"僅限小寫字母、數字和連字符\"],\"D9Oea+\":[\"永久鏈接\"],\"DCKkhU\":[\"當前密碼\"],\"DHhJ7s\":[\"上一頁\"],\"DPfwMq\":[\"完成\"],\"DoJzLz\":[\"收藏夾\"],\"E80cJw\":[\"刪除此媒體將永久從存儲中移除它。\"],\"EEYbdt\":[\"發佈\"],\"EGwzOK\":[\"完成設置\"],\"EdQY6l\":[\"None\"],\"EkH9pt\":[\"更新\"],\"FGrimz\":[\"新帖子\"],\"FkMol5\":[\"精選\"],\"Fxf4jq\":[\"描述(可選)\"],\"GA5A5H\":[\"刪除收藏夾\"],\"GHg6h/\":[\"post\"],\"GTPbOX\":[\"Your site navigation\"],\"GX2VMa\":[\"建立您的管理員帳戶。\"],\"GbVAnd\":[\"此密碼重設連結無效或已過期。請生成一個新的連結。\"],\"GorKul\":[\"歡迎來到 Jant\"],\"GrZ6fH\":[\"新頁面\"],\"GxkJXS\":[\"上傳中...\"],\"HfyyXl\":[\"My Blog\"],\"HiETwV\":[\"安靜(正常)\"],\"Hzi9AA\":[\"未找到任何帖子。\"],\"I6gXOa\":[\"路徑\"],\"I8hDlV\":[\"至少需要 1 張圖片才能發佈圖片帖子。\"],\"IUwGEM\":[\"保存更改\"],\"IagCbF\":[\"網址\"],\"J4FNfC\":[\"此集合中沒有帖子。\"],\"JIBC/T\":[\"Supported formats: JPEG, PNG, GIF, WebP, SVG. Max size: 10MB.\"],\"Jed1wB\":[\"需要幫助嗎?請訪問<0>文檔</0>。\"],\"JiP4aa\":[\"已發佈的頁面可以通過其路徑訪問。草稿不可見。\"],\"K0r7TC\":[\"What's new?\"],\"K9NcLu\":[\"使用此 URL 將媒體嵌入到您的帖子中。\"],\"KbS2K9\":[\"重設密碼\"],\"KiJn9B\":[\"備註\"],\"KmGXnO\":[\"Are you sure you want to delete this post? This cannot be undone.\"],\"L85WcV\":[\"縮略名\"],\"LdyooL\":[\"link\"],\"LkvLQe\":[\"尚未有頁面。\"],\"M1RvTd\":[\"點擊圖片以查看完整大小\"],\"M8kJqa\":[\"草稿\"],\"M9xgHy\":[\"重定向\"],\"MHrjPM\":[\"標題\"],\"MLSRl9\":[\"Quote Text\"],\"MWBOxm\":[\"收藏(可選)\"],\"MZbQHL\":[\"未找到結果。\"],\"Mhf/H/\":[\"建立重定向\"],\"MnbH31\":[\"page\"],\"MqghUt\":[\"搜尋帖子...\"],\"N40H+G\":[\"所有\"],\"NU2Fqi\":[\"Save CSS\"],\"O3oNi5\":[\"電子郵件\"],\"OCNZaU\":[\"重定向來源的路徑\"],\"ODiSoW\":[\"尚未有帖子。\"],\"ONWvwQ\":[\"上傳\"],\"Pbm2/N\":[\"創建收藏夾\"],\"QEbNBb\":[\"路徑(例如 /archive)或完整網址(例如 https://example.com)\"],\"QLkhbH\":[\"The text being quoted...\"],\"RDjuBN\":[\"Setup\"],\"Rj01Fz\":[\"連結\"],\"RwGhWy\":[\"包含 \",[\"count\"],\" 則帖子的主題\"],\"SJmfuf\":[\"網站名稱\"],\"ST+lN2\":[\"尚未上傳任何媒體。\"],\"Tt5T6+\":[\"文章\"],\"TxE+Mj\":[\"1 條回覆\"],\"Tz0i8g\":[\"設定\"],\"U5v6Gh\":[\"編輯頁面\"],\"UDMjsP\":[\"快速操作\"],\"UGT5vp\":[\"保存設定\"],\"UxKoFf\":[\"導航\"],\"V4WsyL\":[\"Add Link\"],\"VUSy8D\":[\"Search failed. Please try again.\"],\"VhMDMg\":[\"更改密碼\"],\"WDcQq9\":[\"不公開\"],\"Weq9zb\":[\"一般設定\"],\"WmZ/rP\":[\"到路徑\"],\"Y+7JGK\":[\"創建頁面\"],\"Y75ho6\":[\"Other pages\"],\"Z3FXyt\":[\"載入中...\"],\"ZQKLI1\":[\"危險區域\"],\"ZhhOwV\":[\"引用\"],\"aAIQg2\":[\"外觀\"],\"aaGV/9\":[\"新連結\"],\"an5hVd\":[\"圖片\"],\"b+/jO6\":[\"301(永久)\"],\"bHYIks\":[\"登出\"],\"biOepV\":[\"← Back to home\"],\"cnGeoo\":[\"刪除\"],\"dEgA5A\":[\"取消\"],\"e6Jr7Q\":[\"← 返回收藏夾\"],\"ePK91l\":[\"編輯\"],\"eWLklq\":[\"引用\"],\"eneWvv\":[\"草稿\"],\"er8+x7\":[\"示範帳戶已預填。只需點擊登入。\"],\"f6e0Ry\":[\"文章\"],\"fG7BxZ\":[\"Upload images via the API: POST /api/upload with a file form field.\"],\"fttd2R\":[\"我的收藏\"],\"g3mKmM\":[\"Un-nav\"],\"gDx5MG\":[\"編輯連結\"],\"hG89Ed\":[\"圖片\"],\"hWOZIv\":[\"請輸入您的新密碼。\"],\"hXzOVo\":[\"下一頁\"],\"he3ygx\":[\"複製\"],\"iBc+/N\":[\"Custom URL path. Leave empty to use default /p/ID format.\"],\"iDAqU6\":[\"URL (optional)\"],\"iH8pgl\":[\"返回\"],\"iPHeYN\":[\"Posting...\"],\"ig4hg2\":[\"Let's set up your site.\"],\"jSRrXo\":[\"Published pages are accessible via their slug. Drafts are not visible.\"],\"jpctdh\":[\"查看\"],\"jt/Ow/\":[\"posts\"],\"k1ifdL\":[\"處理中...\"],\"kI1qVD\":[\"Format\"],\"kNiQp6\":[\"Pinned\"],\"kd7eBB\":[\"建立連結\"],\"mTOYla\":[\"View all posts →\"],\"mnkknn\":[\"Collection (optional)\"],\"n1ekoW\":[\"登入\"],\"oJFOZk\":[\"來源名稱(選填)\"],\"oYPBa0\":[\"更新頁面\"],\"p2/GCq\":[\"確認密碼\"],\"pRhYH2\":[\"收藏中的帖子 (\",[\"count\"],\")\"],\"pZq3aX\":[\"上傳失敗。請再試一次。\"],\"q+hNag\":[\"Collection\"],\"qMyM2u\":[\"來源網址(選填)\"],\"qiXmlF\":[\"添加媒體\"],\"quFPTj\":[\"Custom Slug (optional)\"],\"r1MpXi\":[\"安靜\"],\"rFmBG3\":[\"顏色主題\"],\"rdUucN\":[\"預覽\"],\"rzNUSl\":[\"包含 1 則貼文的主題\"],\"sGajR7\":[\"線程開始\"],\"smzF8S\":[\"顯示 \",[\"remainingCount\"],\" 個更多 \",[\"0\"]],\"ssqvZi\":[\"保存個人資料\"],\"t/YqKh\":[\"移除\"],\"tfrt7B\":[\"未配置任何重定向。\"],\"tiq7kl\":[\"頁面 \",[\"page\"]],\"u2f7vd\":[\"網站描述\"],\"u3wRF+\":[\"已發佈\"],\"u6Hp4N\":[\"Markdown\"],\"uAQUqI\":[\"狀態\"],\"vERlcd\":[\"個人資料\"],\"vXIe7J\":[\"語言\"],\"vh0C9b\":[\"No navigation links yet. Add pages to navigation or create links.\"],\"vmQmHx\":[\"Add custom CSS to override any styles. Use data attributes like [data-page], [data-post], [data-format] to target specific elements.\"],\"vzU4k9\":[\"新收藏集\"],\"wEF6Ix\":[\"目的地路徑或 URL\"],\"wK4OTM\":[\"標題(選填)\"],\"wL3cK8\":[\"Latest\"],\"wM5UXj\":[\"刪除媒體\"],\"wRR604\":[\"頁面\"],\"wc+17X\":[\"/* Your custom CSS here */\"],\"wdGjkd\":[\"未配置導航連結。\"],\"wja8aL\":[\"無標題\"],\"x+doid\":[\"圖片會自動優化:調整大小至最大 1920 像素,轉換為 WebP 格式,並去除元數據。\"],\"x0mzE0\":[\"創建你的第一篇帖子\"],\"x4RuFo\":[\"Back to home\"],\"xYilR2\":[\"媒體\"],\"y28hnO\":[\"文章\"],\"yQ2kGp\":[\"載入更多\"],\"yjkELF\":[\"確認新密碼\"],\"yzF66j\":[\"連結\"],\"z1U/Fh\":[\"Rating\"],\"z8ajIE\":[\"找到 1 個結果\"],\"zH6KqE\":[\"找到 \",[\"count\"],\" 個結果\"]}")as Messages;
package/src/index.ts CHANGED
@@ -8,10 +8,6 @@
8
8
  export { createApp } from "./app.js";
9
9
  export type { App, AppVariables } from "./app.js";
10
10
 
11
- // Default theme
12
- export { theme as threadsTheme } from "./themes/threads/index.js";
13
- export type { ThemeOptions as ThreadsThemeOptions } from "./themes/threads/index.js";
14
-
15
11
  // Types
16
12
  export type {
17
13
  Format,
@@ -37,9 +33,7 @@ export type {
37
33
  CreateCollection,
38
34
  UpdateCollection,
39
35
  JantConfig,
40
- JantTheme,
41
- ThemeComponents,
42
- // View Model types (for theme authors)
36
+ // View Model types
43
37
  PostView,
44
38
  PageView,
45
39
  MediaView,
@@ -47,30 +41,15 @@ export type {
47
41
  SearchResultView,
48
42
  TimelineItemView,
49
43
  ArchiveGroup,
50
- // Timeline types
51
- TimelineCardProps,
52
- ThreadPreviewProps,
53
- TimelineFeedProps,
54
- TimelineLoadMoreProps,
55
- DateGroup,
56
- TimelinePatch,
57
- TimelineMoreProps,
58
- // Site layout
59
- SiteLayoutProps,
60
- // Page-level props (for theme authors)
61
- HomePageProps,
62
- PostPageProps,
63
- SinglePageProps,
64
- ArchivePageProps,
65
- SearchPageProps,
66
- CollectionPageProps,
67
- // Feed types (for theme authors)
44
+ // Feed types
68
45
  FeedData,
69
46
  SitemapData,
70
47
  // Search
71
48
  SearchResult,
72
49
  } from "./types.js";
73
50
 
51
+ export type { ColorTheme } from "./ui/color-themes.js";
52
+
74
53
  export {
75
54
  FORMATS,
76
55
  STATUSES,
@@ -80,13 +59,13 @@ export {
80
59
  MAX_PINNED_POSTS,
81
60
  } from "./types.js";
82
61
 
83
- // Utilities (for theme authors)
62
+ // Utilities
84
63
  export * as time from "./lib/time.js";
85
64
  export * as sqid from "./lib/sqid.js";
86
65
  export * as url from "./lib/url.js";
87
66
  export * as markdown from "./lib/markdown.js";
88
67
 
89
- // View Model conversion utilities (for advanced theme use)
68
+ // View Model conversion utilities
90
69
  export {
91
70
  createMediaContext,
92
71
  toPostView,
@@ -100,15 +79,7 @@ export {
100
79
  } from "./lib/view.js";
101
80
  export type { MediaContext } from "./lib/view.js";
102
81
 
103
- // Render helper (for theme authors adding custom routes)
104
- export { renderPublicPage } from "./lib/render.js";
105
- export type { RenderPublicPageOptions } from "./lib/render.js";
106
-
107
- // Navigation helper (for theme authors)
108
- export { getNavigationData } from "./lib/navigation.js";
109
- export type { NavigationData } from "./lib/navigation.js";
110
-
111
- // Default feed renderers (for theme authors to extend)
82
+ // Default feed renderers (for custom feed implementations)
112
83
  export {
113
84
  defaultRssRenderer,
114
85
  defaultAtomRenderer,
@@ -82,48 +82,82 @@ describe("CreatePostSchema", () => {
82
82
  expect(result.title).toBe("My Post");
83
83
  });
84
84
 
85
- it("accepts valid slug format", () => {
85
+ it("accepts valid path format", () => {
86
86
  const result = CreatePostSchema.parse({
87
87
  ...validPost,
88
- slug: "my-post-slug",
88
+ path: "my-post-path",
89
89
  });
90
- expect(result.slug).toBe("my-post-slug");
90
+ expect(result.path).toBe("my-post-path");
91
91
  });
92
92
 
93
- it("accepts single-character slug", () => {
93
+ it("accepts single-character path", () => {
94
94
  const result = CreatePostSchema.parse({
95
95
  ...validPost,
96
- slug: "a",
96
+ path: "a",
97
97
  });
98
- expect(result.slug).toBe("a");
98
+ expect(result.path).toBe("a");
99
99
  });
100
100
 
101
- it("accepts empty slug (transforms to undefined)", () => {
102
- const result = CreatePostSchema.parse({ ...validPost, slug: "" });
103
- expect(result.slug).toBeUndefined();
101
+ it("accepts empty path (transforms to undefined)", () => {
102
+ const result = CreatePostSchema.parse({ ...validPost, path: "" });
103
+ expect(result.path).toBeUndefined();
104
104
  });
105
105
 
106
- it("rejects invalid slug format (uppercase)", () => {
106
+ it("accepts multi-level path", () => {
107
+ const result = CreatePostSchema.parse({
108
+ ...validPost,
109
+ path: "2024/my-post",
110
+ });
111
+ expect(result.path).toBe("2024/my-post");
112
+ });
113
+
114
+ it("accepts deeply nested path", () => {
115
+ const result = CreatePostSchema.parse({
116
+ ...validPost,
117
+ path: "2024/01/my-post",
118
+ });
119
+ expect(result.path).toBe("2024/01/my-post");
120
+ });
121
+
122
+ it("rejects invalid path format (uppercase)", () => {
123
+ expect(() =>
124
+ CreatePostSchema.parse({ ...validPost, path: "MyPost" }),
125
+ ).toThrow();
126
+ });
127
+
128
+ it("rejects invalid path format (special chars)", () => {
107
129
  expect(() =>
108
- CreatePostSchema.parse({ ...validPost, slug: "MyPost" }),
130
+ CreatePostSchema.parse({ ...validPost, path: "my post!" }),
109
131
  ).toThrow();
110
132
  });
111
133
 
112
- it("rejects invalid slug format (special chars)", () => {
134
+ it("rejects path starting with hyphen", () => {
113
135
  expect(() =>
114
- CreatePostSchema.parse({ ...validPost, slug: "my post!" }),
136
+ CreatePostSchema.parse({ ...validPost, path: "-my-post" }),
115
137
  ).toThrow();
116
138
  });
117
139
 
118
- it("rejects slug starting with hyphen", () => {
140
+ it("rejects path ending with hyphen", () => {
119
141
  expect(() =>
120
- CreatePostSchema.parse({ ...validPost, slug: "-my-post" }),
142
+ CreatePostSchema.parse({ ...validPost, path: "my-post-" }),
121
143
  ).toThrow();
122
144
  });
123
145
 
124
- it("rejects slug ending with hyphen", () => {
146
+ it("rejects path with leading slash", () => {
125
147
  expect(() =>
126
- CreatePostSchema.parse({ ...validPost, slug: "my-post-" }),
148
+ CreatePostSchema.parse({ ...validPost, path: "/my-post" }),
149
+ ).toThrow();
150
+ });
151
+
152
+ it("rejects path with trailing slash", () => {
153
+ expect(() =>
154
+ CreatePostSchema.parse({ ...validPost, path: "my-post/" }),
155
+ ).toThrow();
156
+ });
157
+
158
+ it("rejects path with consecutive slashes", () => {
159
+ expect(() =>
160
+ CreatePostSchema.parse({ ...validPost, path: "2024//my-post" }),
127
161
  ).toThrow();
128
162
  });
129
163
 
@@ -232,11 +266,18 @@ describe("CreatePostSchema", () => {
232
266
  }
233
267
  });
234
268
 
235
- it("rejects rating outside 1-5 range", () => {
236
- expect(() => CreatePostSchema.parse({ ...validPost, rating: 0 })).toThrow();
269
+ it("rejects rating outside 0-5 range", () => {
270
+ expect(() =>
271
+ CreatePostSchema.parse({ ...validPost, rating: -1 }),
272
+ ).toThrow();
237
273
  expect(() => CreatePostSchema.parse({ ...validPost, rating: 6 })).toThrow();
238
274
  });
239
275
 
276
+ it("accepts rating 0 (transforms to undefined)", () => {
277
+ const result = CreatePostSchema.parse({ ...validPost, rating: 0 });
278
+ expect(result.rating).toBeUndefined();
279
+ });
280
+
240
281
  it("accepts empty string rating (transforms to undefined)", () => {
241
282
  const result = CreatePostSchema.parse({ ...validPost, rating: "" });
242
283
  expect(result.rating).toBeUndefined();
@@ -12,9 +12,8 @@ import { createTestDatabase } from "../../__tests__/helpers/db.js";
12
12
  import { createPostService } from "../../services/post.js";
13
13
  import { createMediaService } from "../../services/media.js";
14
14
  import { buildMediaMap } from "../media-helpers.js";
15
- import { groupByDate } from "../timeline.js";
16
15
  import type { Database } from "../../db/index.js";
17
- import type { PostWithMedia, TimelineItemView } from "../../types.js";
16
+ import type { PostWithMedia } from "../../types.js";
18
17
 
19
18
  describe("Timeline data assembly", () => {
20
19
  let db: Database;
@@ -184,101 +183,66 @@ describe("Timeline data assembly", () => {
184
183
  expect(page2.every((p) => p.id < (lastPost?.id ?? 0))).toBe(true);
185
184
  });
186
185
 
187
- it("correctly determines hasMore flag", async () => {
188
- for (let i = 0; i < 3; i++) {
186
+ it("supports offset-based pagination for page navigation", async () => {
187
+ for (let i = 0; i < 5; i++) {
189
188
  await postService.create({
190
189
  format: "note",
191
190
  body: `Post ${i}`,
191
+ publishedAt: 1000 + i,
192
192
  });
193
193
  }
194
194
 
195
- // Request limit + 1 to check for more
196
195
  const pageSize = 2;
197
- const posts = await postService.list({
196
+
197
+ // Page 1
198
+ const page1 = await postService.list({
198
199
  status: "published",
199
200
  excludeReplies: true,
200
- limit: pageSize + 1,
201
+ limit: pageSize,
202
+ offset: 0,
201
203
  });
204
+ expect(page1).toHaveLength(2);
205
+ expect(page1[0]?.body).toBe("Post 4");
206
+ expect(page1[1]?.body).toBe("Post 3");
202
207
 
203
- const hasMore = posts.length > pageSize;
204
- expect(hasMore).toBe(true);
208
+ // Page 2
209
+ const page2 = await postService.list({
210
+ status: "published",
211
+ excludeReplies: true,
212
+ limit: pageSize,
213
+ offset: 2,
214
+ });
215
+ expect(page2).toHaveLength(2);
216
+ expect(page2[0]?.body).toBe("Post 2");
217
+ expect(page2[1]?.body).toBe("Post 1");
205
218
 
206
- const displayPosts = posts.slice(0, pageSize);
207
- expect(displayPosts).toHaveLength(2);
219
+ // Page 3 (partial)
220
+ const page3 = await postService.list({
221
+ status: "published",
222
+ excludeReplies: true,
223
+ limit: pageSize,
224
+ offset: 4,
225
+ });
226
+ expect(page3).toHaveLength(1);
227
+ expect(page3[0]?.body).toBe("Post 0");
208
228
  });
209
- });
210
229
 
211
- describe("groupByDate", () => {
212
- function makeItem(dateStr: string, formatted: string): TimelineItemView {
213
- return {
214
- post: {
215
- id: 1,
216
- permalink: "/p/1",
230
+ it("computes total pages from count", async () => {
231
+ for (let i = 0; i < 5; i++) {
232
+ await postService.create({
217
233
  format: "note",
218
- status: "published",
219
- featured: true,
220
- pinned: false,
221
- publishedAt: `${dateStr}T12:00:00.000Z`,
222
- publishedAtFormatted: formatted,
223
- publishedAtTime: "12:00",
224
- publishedAtRelative: "1d",
225
- updatedAt: `${dateStr}T12:00:00.000Z`,
226
- media: [],
227
- },
228
- };
229
- }
230
-
231
- it("returns empty array for empty input", () => {
232
- expect(groupByDate([])).toEqual([]);
233
- });
234
-
235
- it("groups items by YYYY-MM-DD date key", () => {
236
- const items = [
237
- makeItem("2024-02-01", "Feb 1, 2024"),
238
- makeItem("2024-02-01", "Feb 1, 2024"),
239
- makeItem("2024-02-02", "Feb 2, 2024"),
240
- ];
241
-
242
- const groups = groupByDate(items);
243
- expect(groups).toHaveLength(2);
244
- expect(groups[0]?.dateKey).toBe("2024-02-01");
245
- expect(groups[0]?.label).toBe("Feb 1, 2024");
246
- expect(groups[0]?.items).toHaveLength(2);
247
- expect(groups[1]?.dateKey).toBe("2024-02-02");
248
- expect(groups[1]?.items).toHaveLength(1);
249
- });
250
-
251
- it("creates separate groups for non-contiguous same dates", () => {
252
- const items = [
253
- makeItem("2024-02-01", "Feb 1, 2024"),
254
- makeItem("2024-02-02", "Feb 2, 2024"),
255
- makeItem("2024-02-01", "Feb 1, 2024"),
256
- ];
257
-
258
- const groups = groupByDate(items);
259
- expect(groups).toHaveLength(3);
260
- expect(groups[0]?.dateKey).toBe("2024-02-01");
261
- expect(groups[1]?.dateKey).toBe("2024-02-02");
262
- expect(groups[2]?.dateKey).toBe("2024-02-01");
263
- });
264
-
265
- it("handles a single item", () => {
266
- const items = [makeItem("2024-06-15", "Jun 15, 2024")];
267
- const groups = groupByDate(items);
268
- expect(groups).toHaveLength(1);
269
- expect(groups[0]?.dateKey).toBe("2024-06-15");
270
- expect(groups[0]?.items).toHaveLength(1);
271
- });
234
+ body: `Post ${i}`,
235
+ });
236
+ }
272
237
 
273
- it("uses the first item's formatted date as the group label", () => {
274
- const items = [
275
- makeItem("2024-03-10", "Mar 10, 2024"),
276
- makeItem("2024-03-10", "March 10"),
277
- ];
238
+ const pageSize = 2;
239
+ const totalCount = await postService.count({
240
+ status: "published",
241
+ excludeReplies: true,
242
+ });
278
243
 
279
- const groups = groupByDate(items);
280
- expect(groups).toHaveLength(1);
281
- // Label comes from first item in the group
282
- expect(groups[0]?.label).toBe("Mar 10, 2024");
244
+ expect(totalCount).toBe(5);
245
+ const totalPages = Math.ceil(totalCount / pageSize);
246
+ expect(totalPages).toBe(3);
283
247
  });
284
248
  });
@@ -34,7 +34,7 @@ function makePost(overrides: Partial<Post> = {}): Post {
34
34
  status: "published",
35
35
  featured: 0,
36
36
  pinned: 0,
37
- slug: null,
37
+ path: null,
38
38
  title: null,
39
39
  url: null,
40
40
  body: "Hello world",
@@ -100,19 +100,25 @@ function makeNavItem(overrides: Partial<NavItem> = {}): NavItem {
100
100
  // =============================================================================
101
101
 
102
102
  describe("toPostView", () => {
103
- it("generates permalink from post id when no slug", () => {
104
- const post = makePostWithMedia({ id: 123, slug: null });
103
+ it("generates permalink from post id when no path", () => {
104
+ const post = makePostWithMedia({ id: 123, path: null });
105
105
  const view = toPostView(post, EMPTY_CTX);
106
106
  expect(view.permalink).toMatch(/^\/p\/.+$/);
107
107
  expect(view.permalink.length).toBeGreaterThan(3);
108
108
  });
109
109
 
110
- it("generates permalink from slug when slug is set", () => {
111
- const post = makePostWithMedia({ id: 123, slug: "my-post" });
110
+ it("generates permalink from path when path is set", () => {
111
+ const post = makePostWithMedia({ id: 123, path: "my-post" });
112
112
  const view = toPostView(post, EMPTY_CTX);
113
113
  expect(view.permalink).toBe("/my-post");
114
114
  });
115
115
 
116
+ it("generates permalink from multi-level path", () => {
117
+ const post = makePostWithMedia({ id: 123, path: "2024/01/my-post" });
118
+ const view = toPostView(post, EMPTY_CTX);
119
+ expect(view.permalink).toBe("/2024/01/my-post");
120
+ });
121
+
116
122
  it("formats dates correctly", () => {
117
123
  const post = makePostWithMedia({ publishedAt: 1706745600 });
118
124
  const view = toPostView(post, EMPTY_CTX);
@@ -202,7 +208,7 @@ describe("toPostView", () => {
202
208
  it("converts null fields to undefined", () => {
203
209
  const view = toPostView(makePostWithMedia(), EMPTY_CTX);
204
210
  expect(view.title).toBeUndefined();
205
- expect(view.slug).toBeUndefined();
211
+ expect(view.path).toBeUndefined();
206
212
  expect(view.url).toBeUndefined();
207
213
  expect(view.quoteText).toBeUndefined();
208
214
  expect(view.rating).toBeUndefined();
@@ -465,7 +471,7 @@ describe("toSearchResultView", () => {
465
471
  featured: 1,
466
472
  pinned: 0,
467
473
  url: "https://example.com",
468
- slug: "my-link",
474
+ path: "my-link",
469
475
  }),
470
476
  rank: 0.8,
471
477
  };
@@ -50,6 +50,7 @@ export const SETTINGS_KEYS = {
50
50
  SITE_DESCRIPTION: "SITE_DESCRIPTION",
51
51
  SITE_LANGUAGE: "SITE_LANGUAGE",
52
52
  THEME: "THEME",
53
+ CUSTOM_CSS: "CUSTOM_CSS",
53
54
  PASSWORD_RESET_TOKEN: "PASSWORD_RESET_TOKEN",
54
55
  } as const;
55
56
 
@@ -16,7 +16,7 @@ if (list) {
16
16
  const ids = [...list.querySelectorAll<HTMLElement>("[data-id]")].map(
17
17
  (el) => Number(el.dataset.id),
18
18
  );
19
- fetch("/dash/navigation/reorder", {
19
+ fetch("/dash/pages/reorder", {
20
20
  method: "POST",
21
21
  headers: { "Content-Type": "application/json" },
22
22
  body: JSON.stringify({ ids }),
@@ -6,7 +6,7 @@
6
6
 
7
7
  import type { Context } from "hono";
8
8
  import { getSiteName } from "./config.js";
9
- import type { NavItemView } from "../types.js";
9
+ import type { Collection, NavItemView } from "../types.js";
10
10
  import { toNavItemViews } from "./view.js";
11
11
 
12
12
  /**
@@ -16,12 +16,16 @@ export interface NavigationData {
16
16
  links: NavItemView[];
17
17
  currentPath: string;
18
18
  siteName: string;
19
+ siteDescription: string;
20
+ isAuthenticated: boolean;
21
+ collections: Collection[];
19
22
  }
20
23
 
21
24
  /**
22
25
  * Fetch navigation data for public pages.
23
26
  *
24
27
  * Returns NavItemView[] with pre-computed isActive/isExternal state.
28
+ * Also checks authentication status and loads collections for authenticated users.
25
29
  *
26
30
  * @param c - Hono context
27
31
  * @returns Navigation data for SiteLayout
@@ -40,6 +44,40 @@ export async function getNavigationData(c: Context): Promise<NavigationData> {
40
44
  const items = await c.var.services.navItems.list();
41
45
  const currentPath = new URL(c.req.url).pathname;
42
46
  const siteName = await getSiteName(c);
47
+
48
+ // Only include description if explicitly set (DB or env), not the default
49
+ const dbDescription = await c.var.services.settings.get("SITE_DESCRIPTION");
50
+ const envDescription = c.env.SITE_DESCRIPTION;
51
+ const siteDescription =
52
+ dbDescription || (typeof envDescription === "string" ? envDescription : "");
53
+
43
54
  const links = toNavItemViews(items, currentPath);
44
- return { links, currentPath, siteName };
55
+
56
+ // Check auth status for compose button
57
+ let isAuthenticated = false;
58
+ let collections: Collection[] = [];
59
+ if (c.var.auth) {
60
+ try {
61
+ const session = await c.var.auth.api.getSession({
62
+ headers: c.req.raw.headers,
63
+ });
64
+ isAuthenticated = !!session?.user;
65
+ } catch {
66
+ // Not authenticated
67
+ }
68
+ }
69
+
70
+ // Only load collections when authenticated (for compose dialog)
71
+ if (isAuthenticated) {
72
+ collections = await c.var.services.collections.list();
73
+ }
74
+
75
+ return {
76
+ links,
77
+ currentPath,
78
+ siteName,
79
+ siteDescription,
80
+ isAuthenticated,
81
+ collections,
82
+ };
45
83
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Pagination Utilities
3
+ *
4
+ * Pure utility functions for page-based pagination.
5
+ */
6
+
7
+ /**
8
+ * Computes which page numbers to display in a numbered pagination control.
9
+ * Always includes: first page, last page, current page, and 1 page on each side of current.
10
+ * Gaps between non-consecutive pages are represented by 0 (ellipsis marker).
11
+ *
12
+ * @param currentPage - The current active page (1-indexed)
13
+ * @param totalPages - Total number of pages
14
+ * @returns Array of page numbers, with 0 representing ellipsis gaps
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * getPageNumbers(1, 5) // [1, 2, 3, 4, 5]
19
+ * getPageNumbers(1, 20) // [1, 2, 0, 20]
20
+ * getPageNumbers(10, 20) // [1, 0, 9, 10, 11, 0, 20]
21
+ * ```
22
+ */
23
+ export function getPageNumbers(
24
+ currentPage: number,
25
+ totalPages: number,
26
+ ): number[] {
27
+ if (totalPages <= 7) {
28
+ return Array.from({ length: totalPages }, (_, i) => i + 1);
29
+ }
30
+
31
+ const pages = new Set<number>();
32
+ pages.add(1);
33
+ pages.add(totalPages);
34
+ pages.add(currentPage);
35
+ if (currentPage > 1) pages.add(currentPage - 1);
36
+ if (currentPage < totalPages) pages.add(currentPage + 1);
37
+
38
+ const sorted = [...pages].sort((a, b) => a - b);
39
+
40
+ // Insert 0 for gaps
41
+ const result: number[] = [];
42
+ for (let i = 0; i < sorted.length; i++) {
43
+ if (i > 0 && sorted[i]! - sorted[i - 1]! > 1) {
44
+ result.push(0); // ellipsis marker
45
+ }
46
+ result.push(sorted[i]!);
47
+ }
48
+
49
+ return result;
50
+ }
@@ -3,16 +3,13 @@
3
3
  *
4
4
  * Provides a single entry point for rendering public pages with the
5
5
  * correct layout stack: BaseLayout > SiteLayout > content.
6
- *
7
- * BaseLayout is always the built-in implementation (handles Vite assets,
8
- * I18nProvider, toast). SiteLayout is resolved from theme components.
9
6
  */
10
7
 
11
8
  import type { Context } from "hono";
12
9
  import type { Child } from "hono/jsx";
13
- import type { ThemeComponents, SiteLayoutProps } from "../types.js";
14
- import { BaseLayout } from "../theme/layouts/BaseLayout.js";
15
- import { ThreadsSiteLayout as DefaultSiteLayout } from "../themes/threads/ThreadsSiteLayout.js";
10
+ import type { SiteLayoutProps } from "../types.js";
11
+ import { BaseLayout } from "../ui/layouts/BaseLayout.js";
12
+ import { SiteLayout } from "../ui/layouts/SiteLayout.js";
16
13
  import type { NavigationData } from "./navigation.js";
17
14
 
18
15
  export interface RenderPublicPageOptions {
@@ -29,8 +26,6 @@ export interface RenderPublicPageOptions {
29
26
  /**
30
27
  * Render a public page with the standard layout stack.
31
28
  *
32
- * Always uses the built-in BaseLayout, resolves SiteLayout from theme config.
33
- *
34
29
  * @param c - Hono context
35
30
  * @param options - Page rendering options
36
31
  * @returns Hono HTML response
@@ -48,20 +43,18 @@ export interface RenderPublicPageOptions {
48
43
  export function renderPublicPage(c: Context, options: RenderPublicPageOptions) {
49
44
  const { title, description, navData, content } = options;
50
45
 
51
- const components = c.var.config?.theme?.components as
52
- | ThemeComponents
53
- | undefined;
54
- const Layout = components?.SiteLayout ?? DefaultSiteLayout;
55
-
56
46
  const layoutProps: SiteLayoutProps = {
57
47
  siteName: navData.siteName,
48
+ siteDescription: navData.siteDescription,
58
49
  links: navData.links,
59
50
  currentPath: navData.currentPath,
51
+ isAuthenticated: navData.isAuthenticated,
52
+ collections: navData.collections,
60
53
  };
61
54
 
62
55
  return c.html(
63
56
  <BaseLayout title={title} description={description} c={c}>
64
- <Layout {...layoutProps}>{content}</Layout>
57
+ <SiteLayout {...layoutProps}>{content}</SiteLayout>
65
58
  </BaseLayout>,
66
59
  );
67
60
  }