@jant/core 0.3.7 → 0.3.9

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 (258) hide show
  1. package/dist/app.js +11 -4
  2. package/dist/client.js +1 -0
  3. package/dist/db/schema.js +15 -1
  4. package/dist/i18n/locales/en.js +1 -1
  5. package/dist/i18n/locales/zh-Hans.js +1 -1
  6. package/dist/i18n/locales/zh-Hant.js +1 -1
  7. package/dist/lib/image.js +39 -15
  8. package/dist/lib/media-helpers.js +49 -0
  9. package/dist/lib/nav-reorder.js +27 -0
  10. package/dist/lib/navigation.js +35 -0
  11. package/dist/lib/storage.js +164 -0
  12. package/dist/lib/theme-components.js +49 -0
  13. package/dist/routes/api/posts.js +12 -7
  14. package/dist/routes/api/timeline.js +116 -0
  15. package/dist/routes/api/upload.js +35 -24
  16. package/dist/routes/dash/media.js +24 -14
  17. package/dist/routes/dash/navigation.js +274 -0
  18. package/dist/routes/dash/posts.js +4 -1
  19. package/dist/routes/feed/rss.js +3 -2
  20. package/dist/routes/pages/archive.js +14 -27
  21. package/dist/routes/pages/collection.js +10 -19
  22. package/dist/routes/pages/home.js +84 -126
  23. package/dist/routes/pages/page.js +19 -38
  24. package/dist/routes/pages/post.js +47 -56
  25. package/dist/routes/pages/search.js +13 -26
  26. package/dist/services/index.js +3 -1
  27. package/dist/services/media.js +8 -6
  28. package/dist/services/navigation.js +115 -0
  29. package/dist/services/post.js +26 -1
  30. package/dist/theme/components/PostForm.js +4 -3
  31. package/dist/theme/components/PostList.js +5 -0
  32. package/dist/theme/components/index.js +2 -0
  33. package/dist/theme/components/timeline/ArticleCard.js +50 -0
  34. package/dist/theme/components/timeline/ImageCard.js +86 -0
  35. package/dist/theme/components/timeline/LinkCard.js +62 -0
  36. package/dist/theme/components/timeline/NoteCard.js +37 -0
  37. package/dist/theme/components/timeline/QuoteCard.js +51 -0
  38. package/dist/theme/components/timeline/ThreadPreview.js +52 -0
  39. package/dist/theme/components/timeline/TimelineFeed.js +43 -0
  40. package/dist/theme/components/timeline/TimelineItem.js +25 -0
  41. package/dist/theme/components/timeline/index.js +8 -0
  42. package/dist/theme/layouts/DashLayout.js +8 -0
  43. package/dist/theme/layouts/SiteLayout.js +160 -0
  44. package/dist/theme/layouts/index.js +1 -0
  45. package/dist/types/sortablejs.d.js +5 -0
  46. package/dist/types.js +32 -0
  47. package/package.json +4 -2
  48. package/src/__tests__/helpers/app.ts +1 -0
  49. package/src/__tests__/helpers/db.ts +20 -0
  50. package/src/app.tsx +12 -7
  51. package/src/client.ts +1 -0
  52. package/src/db/migrations/0003_add_navigation_links.sql +8 -0
  53. package/src/db/migrations/0004_add_storage_provider.sql +3 -0
  54. package/src/db/migrations/meta/0003_snapshot.json +821 -0
  55. package/src/db/migrations/meta/_journal.json +21 -0
  56. package/src/db/schema.ts +15 -1
  57. package/src/i18n/locales/en.po +148 -80
  58. package/src/i18n/locales/en.ts +1 -1
  59. package/src/i18n/locales/zh-Hans.po +150 -103
  60. package/src/i18n/locales/zh-Hans.ts +1 -1
  61. package/src/i18n/locales/zh-Hant.po +150 -103
  62. package/src/i18n/locales/zh-Hant.ts +1 -1
  63. package/src/index.ts +5 -0
  64. package/src/lib/__tests__/image.test.ts +96 -0
  65. package/src/lib/__tests__/storage.test.ts +162 -0
  66. package/src/lib/__tests__/theme-components.test.ts +107 -0
  67. package/src/lib/image.ts +46 -16
  68. package/src/lib/media-helpers.ts +65 -0
  69. package/src/lib/nav-reorder.ts +26 -0
  70. package/src/lib/navigation.ts +46 -0
  71. package/src/lib/storage.ts +236 -0
  72. package/src/lib/theme-components.ts +76 -0
  73. package/src/routes/api/__tests__/posts.test.ts +8 -8
  74. package/src/routes/api/__tests__/timeline.test.ts +242 -0
  75. package/src/routes/api/posts.ts +20 -6
  76. package/src/routes/api/timeline.tsx +152 -0
  77. package/src/routes/api/upload.ts +52 -25
  78. package/src/routes/dash/media.tsx +40 -8
  79. package/src/routes/dash/navigation.tsx +306 -0
  80. package/src/routes/dash/posts.tsx +5 -0
  81. package/src/routes/feed/rss.ts +3 -2
  82. package/src/routes/pages/archive.tsx +15 -23
  83. package/src/routes/pages/collection.tsx +8 -15
  84. package/src/routes/pages/home.tsx +118 -122
  85. package/src/routes/pages/page.tsx +17 -30
  86. package/src/routes/pages/post.tsx +63 -60
  87. package/src/routes/pages/search.tsx +18 -22
  88. package/src/services/__tests__/media.test.ts +73 -28
  89. package/src/services/__tests__/navigation.test.ts +213 -0
  90. package/src/services/__tests__/post-timeline.test.ts +220 -0
  91. package/src/services/index.ts +7 -0
  92. package/src/services/media.ts +12 -8
  93. package/src/services/navigation.ts +165 -0
  94. package/src/services/post.ts +48 -1
  95. package/src/styles/components.css +59 -0
  96. package/src/theme/components/PostForm.tsx +13 -2
  97. package/src/theme/components/PostList.tsx +7 -0
  98. package/src/theme/components/index.ts +12 -0
  99. package/src/theme/components/timeline/ArticleCard.tsx +57 -0
  100. package/src/theme/components/timeline/ImageCard.tsx +80 -0
  101. package/src/theme/components/timeline/LinkCard.tsx +66 -0
  102. package/src/theme/components/timeline/NoteCard.tsx +41 -0
  103. package/src/theme/components/timeline/QuoteCard.tsx +55 -0
  104. package/src/theme/components/timeline/ThreadPreview.tsx +49 -0
  105. package/src/theme/components/timeline/TimelineFeed.tsx +52 -0
  106. package/src/theme/components/timeline/TimelineItem.tsx +39 -0
  107. package/src/theme/components/timeline/index.ts +8 -0
  108. package/src/theme/layouts/DashLayout.tsx +10 -0
  109. package/src/theme/layouts/SiteLayout.tsx +184 -0
  110. package/src/theme/layouts/index.ts +1 -0
  111. package/src/types/sortablejs.d.ts +23 -0
  112. package/src/types.ts +102 -1
  113. package/dist/app.d.ts +0 -38
  114. package/dist/app.d.ts.map +0 -1
  115. package/dist/auth.d.ts +0 -25
  116. package/dist/auth.d.ts.map +0 -1
  117. package/dist/db/index.d.ts +0 -10
  118. package/dist/db/index.d.ts.map +0 -1
  119. package/dist/db/schema.d.ts +0 -1543
  120. package/dist/db/schema.d.ts.map +0 -1
  121. package/dist/i18n/Trans.d.ts +0 -25
  122. package/dist/i18n/Trans.d.ts.map +0 -1
  123. package/dist/i18n/context.d.ts +0 -69
  124. package/dist/i18n/context.d.ts.map +0 -1
  125. package/dist/i18n/detect.d.ts +0 -20
  126. package/dist/i18n/detect.d.ts.map +0 -1
  127. package/dist/i18n/i18n.d.ts +0 -32
  128. package/dist/i18n/i18n.d.ts.map +0 -1
  129. package/dist/i18n/index.d.ts +0 -41
  130. package/dist/i18n/index.d.ts.map +0 -1
  131. package/dist/i18n/locales/en.d.ts +0 -3
  132. package/dist/i18n/locales/en.d.ts.map +0 -1
  133. package/dist/i18n/locales/zh-Hans.d.ts +0 -3
  134. package/dist/i18n/locales/zh-Hans.d.ts.map +0 -1
  135. package/dist/i18n/locales/zh-Hant.d.ts +0 -3
  136. package/dist/i18n/locales/zh-Hant.d.ts.map +0 -1
  137. package/dist/i18n/locales.d.ts +0 -11
  138. package/dist/i18n/locales.d.ts.map +0 -1
  139. package/dist/i18n/middleware.d.ts +0 -21
  140. package/dist/i18n/middleware.d.ts.map +0 -1
  141. package/dist/index.d.ts +0 -16
  142. package/dist/index.d.ts.map +0 -1
  143. package/dist/lib/config.d.ts +0 -83
  144. package/dist/lib/config.d.ts.map +0 -1
  145. package/dist/lib/constants.d.ts +0 -37
  146. package/dist/lib/constants.d.ts.map +0 -1
  147. package/dist/lib/image.d.ts +0 -73
  148. package/dist/lib/image.d.ts.map +0 -1
  149. package/dist/lib/index.d.ts +0 -9
  150. package/dist/lib/index.d.ts.map +0 -1
  151. package/dist/lib/markdown.d.ts +0 -60
  152. package/dist/lib/markdown.d.ts.map +0 -1
  153. package/dist/lib/schemas.d.ts +0 -130
  154. package/dist/lib/schemas.d.ts.map +0 -1
  155. package/dist/lib/sqid.d.ts +0 -60
  156. package/dist/lib/sqid.d.ts.map +0 -1
  157. package/dist/lib/sse.d.ts +0 -192
  158. package/dist/lib/sse.d.ts.map +0 -1
  159. package/dist/lib/theme.d.ts +0 -44
  160. package/dist/lib/theme.d.ts.map +0 -1
  161. package/dist/lib/time.d.ts +0 -90
  162. package/dist/lib/time.d.ts.map +0 -1
  163. package/dist/lib/url.d.ts +0 -82
  164. package/dist/lib/url.d.ts.map +0 -1
  165. package/dist/middleware/auth.d.ts +0 -24
  166. package/dist/middleware/auth.d.ts.map +0 -1
  167. package/dist/middleware/onboarding.d.ts +0 -26
  168. package/dist/middleware/onboarding.d.ts.map +0 -1
  169. package/dist/routes/api/posts.d.ts +0 -13
  170. package/dist/routes/api/posts.d.ts.map +0 -1
  171. package/dist/routes/api/search.d.ts +0 -13
  172. package/dist/routes/api/search.d.ts.map +0 -1
  173. package/dist/routes/api/upload.d.ts +0 -16
  174. package/dist/routes/api/upload.d.ts.map +0 -1
  175. package/dist/routes/dash/collections.d.ts +0 -13
  176. package/dist/routes/dash/collections.d.ts.map +0 -1
  177. package/dist/routes/dash/index.d.ts +0 -15
  178. package/dist/routes/dash/index.d.ts.map +0 -1
  179. package/dist/routes/dash/media.d.ts +0 -16
  180. package/dist/routes/dash/media.d.ts.map +0 -1
  181. package/dist/routes/dash/pages.d.ts +0 -15
  182. package/dist/routes/dash/pages.d.ts.map +0 -1
  183. package/dist/routes/dash/posts.d.ts +0 -13
  184. package/dist/routes/dash/posts.d.ts.map +0 -1
  185. package/dist/routes/dash/redirects.d.ts +0 -13
  186. package/dist/routes/dash/redirects.d.ts.map +0 -1
  187. package/dist/routes/dash/settings.d.ts +0 -15
  188. package/dist/routes/dash/settings.d.ts.map +0 -1
  189. package/dist/routes/feed/rss.d.ts +0 -13
  190. package/dist/routes/feed/rss.d.ts.map +0 -1
  191. package/dist/routes/feed/sitemap.d.ts +0 -13
  192. package/dist/routes/feed/sitemap.d.ts.map +0 -1
  193. package/dist/routes/pages/archive.d.ts +0 -15
  194. package/dist/routes/pages/archive.d.ts.map +0 -1
  195. package/dist/routes/pages/collection.d.ts +0 -13
  196. package/dist/routes/pages/collection.d.ts.map +0 -1
  197. package/dist/routes/pages/home.d.ts +0 -13
  198. package/dist/routes/pages/home.d.ts.map +0 -1
  199. package/dist/routes/pages/page.d.ts +0 -15
  200. package/dist/routes/pages/page.d.ts.map +0 -1
  201. package/dist/routes/pages/post.d.ts +0 -13
  202. package/dist/routes/pages/post.d.ts.map +0 -1
  203. package/dist/routes/pages/search.d.ts +0 -13
  204. package/dist/routes/pages/search.d.ts.map +0 -1
  205. package/dist/services/collection.d.ts +0 -32
  206. package/dist/services/collection.d.ts.map +0 -1
  207. package/dist/services/index.d.ts +0 -28
  208. package/dist/services/index.d.ts.map +0 -1
  209. package/dist/services/media.d.ts +0 -34
  210. package/dist/services/media.d.ts.map +0 -1
  211. package/dist/services/post.d.ts +0 -31
  212. package/dist/services/post.d.ts.map +0 -1
  213. package/dist/services/redirect.d.ts +0 -15
  214. package/dist/services/redirect.d.ts.map +0 -1
  215. package/dist/services/search.d.ts +0 -26
  216. package/dist/services/search.d.ts.map +0 -1
  217. package/dist/services/settings.d.ts +0 -18
  218. package/dist/services/settings.d.ts.map +0 -1
  219. package/dist/theme/color-themes.d.ts +0 -30
  220. package/dist/theme/color-themes.d.ts.map +0 -1
  221. package/dist/theme/components/ActionButtons.d.ts +0 -43
  222. package/dist/theme/components/ActionButtons.d.ts.map +0 -1
  223. package/dist/theme/components/CrudPageHeader.d.ts +0 -23
  224. package/dist/theme/components/CrudPageHeader.d.ts.map +0 -1
  225. package/dist/theme/components/DangerZone.d.ts +0 -36
  226. package/dist/theme/components/DangerZone.d.ts.map +0 -1
  227. package/dist/theme/components/EmptyState.d.ts +0 -27
  228. package/dist/theme/components/EmptyState.d.ts.map +0 -1
  229. package/dist/theme/components/ListItemRow.d.ts +0 -15
  230. package/dist/theme/components/ListItemRow.d.ts.map +0 -1
  231. package/dist/theme/components/MediaGallery.d.ts +0 -13
  232. package/dist/theme/components/MediaGallery.d.ts.map +0 -1
  233. package/dist/theme/components/PageForm.d.ts +0 -14
  234. package/dist/theme/components/PageForm.d.ts.map +0 -1
  235. package/dist/theme/components/Pagination.d.ts +0 -46
  236. package/dist/theme/components/Pagination.d.ts.map +0 -1
  237. package/dist/theme/components/PostForm.d.ts +0 -16
  238. package/dist/theme/components/PostForm.d.ts.map +0 -1
  239. package/dist/theme/components/PostList.d.ts +0 -10
  240. package/dist/theme/components/PostList.d.ts.map +0 -1
  241. package/dist/theme/components/ThreadView.d.ts +0 -15
  242. package/dist/theme/components/ThreadView.d.ts.map +0 -1
  243. package/dist/theme/components/TypeBadge.d.ts +0 -12
  244. package/dist/theme/components/TypeBadge.d.ts.map +0 -1
  245. package/dist/theme/components/VisibilityBadge.d.ts +0 -12
  246. package/dist/theme/components/VisibilityBadge.d.ts.map +0 -1
  247. package/dist/theme/components/index.d.ts +0 -14
  248. package/dist/theme/components/index.d.ts.map +0 -1
  249. package/dist/theme/index.d.ts +0 -21
  250. package/dist/theme/index.d.ts.map +0 -1
  251. package/dist/theme/layouts/BaseLayout.d.ts +0 -23
  252. package/dist/theme/layouts/BaseLayout.d.ts.map +0 -1
  253. package/dist/theme/layouts/DashLayout.d.ts +0 -17
  254. package/dist/theme/layouts/DashLayout.d.ts.map +0 -1
  255. package/dist/theme/layouts/index.d.ts +0 -3
  256. package/dist/theme/layouts/index.d.ts.map +0 -1
  257. package/dist/types.d.ts +0 -237
  258. package/dist/types.d.ts.map +0 -1
@@ -1 +1 @@
1
- /*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"+7Wr2a\":[\"Edit: \",[\"title\"]],\"+MACwa\":[\"尚未有任何收藏。\"],\"+owNNn\":[\"文章\"],\"+zy2Nq\":[\"類型\"],\"/0D1Xp\":[\"編輯收藏集\"],\"/Rj5P4\":[\"您的姓名\"],\"07Epll\":[\"這將為您的網站和儀表板設置主題。所有顏色主題都支持深色模式。\"],\"0JkyS7\":[\"建立您的第一個頁面\"],\"0a6MpL\":[\"新重定向\"],\"1CU1Td\":[\"網址安全識別碼(小寫、數字、連字符)\"],\"1DBGsz\":[\"筆記\"],\"1o+wgo\":[\"e.g. The Verge, John Doe\"],\"2N0qpv\":[\"文章標題...\"],\"2fUwEY\":[\"Select Media\"],\"2q/Q7x\":[\"可見性\"],\"2rJGtU\":[\"頁面標題...\"],\"3Yvsaz\":[\"302(臨時)\"],\"4/SFQS\":[\"查看網站\"],\"40TVQj\":[\"自訂路徑(選填)\"],\"4KzVT6\":[\"刪除頁面\"],\"4b3oEV\":[\"內容\"],\"4mDPGp\":[\"此頁面的 URL 路徑。使用小寫字母、數字和連字符。\"],\"6WdDG7\":[\"頁面\"],\"6YtxFj\":[\"名稱\"],\"7G4SBz\":[\"頁面內容(支持Markdown)...\"],\"7Mk+/h\":[\"更新收藏集\"],\"7Q1KKN\":[\"來源路徑\"],\"7aECQB\":[\"無效或已過期的連結\"],\"7nGhhM\":[\"你在想什麼?\"],\"7p5kLi\":[\"儀表板\"],\"7vhWI8\":[\"新密碼\"],\"8ZsakT\":[\"密碼\"],\"90Luob\":[[\"count\"],\" 條回覆\"],\"A1taO8\":[\"搜尋\"],\"AeXO77\":[\"帳戶\"],\"AyHO4m\":[\"這個收藏是關於什麼的?\"],\"B373X+\":[\"編輯文章\"],\"B495Gs\":[\"檔案館\"],\"BjF0Jv\":[\"僅限小寫字母、數字和連字符\"],\"D9Oea+\":[\"永久鏈接\"],\"DCKkhU\":[\"當前密碼\"],\"DHhJ7s\":[\"上一頁\"],\"DPfwMq\":[\"Done\"],\"DoJzLz\":[\"收藏夾\"],\"E80cJw\":[\"刪除此媒體將永久從存儲中移除它。\"],\"EEYbdt\":[\"發佈\"],\"EGwzOK\":[\"完成設置\"],\"EkH9pt\":[\"更新\"],\"FGrimz\":[\"新帖子\"],\"FkMol5\":[\"精選\"],\"Fxf4jq\":[\"描述(可選)\"],\"GA5A5H\":[\"刪除收藏夾\"],\"GX2VMa\":[\"建立您的管理員帳戶。\"],\"GbVAnd\":[\"此密碼重設連結無效或已過期。請生成一個新的連結。\"],\"GorKul\":[\"歡迎來到 Jant\"],\"GrZ6fH\":[\"新頁面\"],\"GxkJXS\":[\"上傳中...\"],\"HfyyXl\":[\"My Blog\"],\"HiETwV\":[\"安靜(正常)\"],\"Hzi9AA\":[\"未找到任何帖子。\"],\"I6gXOa\":[\"路徑\"],\"I8hDlV\":[\"At least 1 image required for image posts.\"],\"IagCbF\":[\"網址\"],\"J4FNfC\":[\"此集合中沒有帖子。\"],\"JIBC/T\":[\"Supported formats: JPEG, PNG, GIF, WebP, SVG. Max size: 10MB.\"],\"Jed1wB\":[\"需要幫助嗎?請訪問<0>文檔</0>。\"],\"JiP4aa\":[\"已發佈的頁面可以通過其路徑訪問。草稿不可見。\"],\"K9NcLu\":[\"使用此 URL 將媒體嵌入到您的帖子中。\"],\"KbS2K9\":[\"重設密碼\"],\"KiJn9B\":[\"備註\"],\"L85WcV\":[\"縮略名\"],\"LkvLQe\":[\"尚未有頁面。\"],\"M1RvTd\":[\"點擊圖片以查看完整大小\"],\"M8kJqa\":[\"草稿\"],\"M9xgHy\":[\"重定向\"],\"MHrjPM\":[\"標題\"],\"MWBOxm\":[\"Collections (optional)\"],\"MZbQHL\":[\"未找到結果。\"],\"Mhf/H/\":[\"建立重定向\"],\"MqghUt\":[\"搜尋帖子...\"],\"N40H+G\":[\"所有\"],\"O3oNi5\":[\"電子郵件\"],\"OCNZaU\":[\"重定向來源的路徑\"],\"ODiSoW\":[\"尚未有帖子。\"],\"ONWvwQ\":[\"上傳\"],\"Pbm2/N\":[\"創建收藏夾\"],\"RDjuBN\":[\"Setup\"],\"Rj01Fz\":[\"連結\"],\"RwGhWy\":[\"包含 \",[\"count\"],\" 則帖子的主題\"],\"SJmfuf\":[\"網站名稱\"],\"ST+lN2\":[\"尚未上傳任何媒體。\"],\"Tt5T6+\":[\"文章\"],\"TxE+Mj\":[\"1 條回覆\"],\"Tz0i8g\":[\"設定\"],\"U5v6Gh\":[\"編輯頁面\"],\"UDMjsP\":[\"快速操作\"],\"UGT5vp\":[\"保存設定\"],\"VUSy8D\":[\"Search failed. Please try again.\"],\"VhMDMg\":[\"更改密碼\"],\"WDcQq9\":[\"不公開\"],\"Weq9zb\":[\"一般設定\"],\"WmZ/rP\":[\"到路徑\"],\"Y+7JGK\":[\"創建頁面\"],\"Z3FXyt\":[\"Loading...\"],\"ZQKLI1\":[\"危險區域\"],\"ZhhOwV\":[\"引用\"],\"aAIQg2\":[\"外觀\"],\"an5hVd\":[\"圖片\"],\"b+/jO6\":[\"301(永久)\"],\"bHYIks\":[\"登出\"],\"biOepV\":[\"← 返回首頁\"],\"cnGeoo\":[\"刪除\"],\"dEgA5A\":[\"取消\"],\"e6Jr7Q\":[\"← 返回收藏夾\"],\"ePK91l\":[\"編輯\"],\"eWLklq\":[\"引用\"],\"eneWvv\":[\"草稿\"],\"er8+x7\":[\"示範帳戶已預填。只需點擊登入。\"],\"f6e0Ry\":[\"文章\"],\"fG7BxZ\":[\"Upload images via the API: POST /api/upload with a file form field.\"],\"fttd2R\":[\"我的收藏\"],\"hG89Ed\":[\"圖片\"],\"hWOZIv\":[\"請輸入您的新密碼。\"],\"hXzOVo\":[\"下一頁\"],\"he3ygx\":[\"複製\"],\"iH8pgl\":[\"返回\"],\"ig4hg2\":[\"Let's set up your site.\"],\"jpctdh\":[\"查看\"],\"k1ifdL\":[\"處理中...\"],\"mTOYla\":[\"查看所有文章 →\"],\"n1ekoW\":[\"登入\"],\"oJFOZk\":[\"Source Name (optional)\"],\"oYPBa0\":[\"更新頁面\"],\"p2/GCq\":[\"確認密碼\"],\"pRhYH2\":[\"收藏中的帖子 (\",[\"count\"],\")\"],\"pZq3aX\":[\"上傳失敗。請再試一次。\"],\"qMyM2u\":[\"來源網址(選填)\"],\"qiXmlF\":[\"Add Media\"],\"r1MpXi\":[\"安靜\"],\"rFmBG3\":[\"顏色主題\"],\"rdUucN\":[\"預覽\"],\"rzNUSl\":[\"包含 1 則貼文的主題\"],\"sGajR7\":[\"線程開始\"],\"ssqvZi\":[\"保存個人資料\"],\"t/YqKh\":[\"移除\"],\"tfrt7B\":[\"未配置任何重定向。\"],\"tiq7kl\":[\"頁面 \",[\"page\"]],\"u2f7vd\":[\"網站描述\"],\"u3wRF+\":[\"已發佈\"],\"u6Hp4N\":[\"Markdown\"],\"uAQUqI\":[\"狀態\"],\"vERlcd\":[\"個人資料\"],\"vXIe7J\":[\"語言\"],\"vzU4k9\":[\"新收藏集\"],\"wEF6Ix\":[\"目的地路徑或 URL\"],\"wK4OTM\":[\"標題(選填)\"],\"wM5UXj\":[\"刪除媒體\"],\"wRR604\":[\"頁面\"],\"wja8aL\":[\"無標題\"],\"x+doid\":[\"圖片會自動優化:調整大小至最大 1920 像素,轉換為 WebP 格式,並去除元數據。\"],\"x0mzE0\":[\"創建你的第一篇帖子\"],\"x4RuFo\":[\"回到首頁\"],\"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\":[\"尚未有任何收藏。\"],\"+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\":[\"完成設置\"],\"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\":[\"標題\"],\"MWBOxm\":[\"收藏(可選)\"],\"MZbQHL\":[\"未找到結果。\"],\"Mhf/H/\":[\"建立重定向\"],\"MqghUt\":[\"搜尋帖子...\"],\"N40H+G\":[\"所有\"],\"O3oNi5\":[\"電子郵件\"],\"OCNZaU\":[\"重定向來源的路徑\"],\"ODiSoW\":[\"尚未有帖子。\"],\"ONWvwQ\":[\"上傳\"],\"Pbm2/N\":[\"創建收藏夾\"],\"QEbNBb\":[\"路徑(例如 /archive)或完整網址(例如 https://example.com)\"],\"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\":[\"複製\"],\"iH8pgl\":[\"返回\"],\"ig4hg2\":[\"Let's set up your site.\"],\"jpctdh\":[\"查看\"],\"k1ifdL\":[\"處理中...\"],\"kd7eBB\":[\"建立連結\"],\"mTOYla\":[\"View all posts →\"],\"n1ekoW\":[\"登入\"],\"oJFOZk\":[\"來源名稱(選填)\"],\"oYPBa0\":[\"更新頁面\"],\"p2/GCq\":[\"確認密碼\"],\"pRhYH2\":[\"收藏中的帖子 (\",[\"count\"],\")\"],\"pZq3aX\":[\"上傳失敗。請再試一次。\"],\"qMyM2u\":[\"來源網址(選填)\"],\"qiXmlF\":[\"添加媒體\"],\"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;
package/src/index.ts CHANGED
@@ -23,11 +23,16 @@ export type {
23
23
  PostCollection,
24
24
  Redirect,
25
25
  Setting,
26
+ NavigationLink,
26
27
  CreatePost,
27
28
  UpdatePost,
28
29
  JantConfig,
29
30
  JantTheme,
30
31
  ThemeComponents,
32
+ TimelineCardProps,
33
+ ThreadPreviewProps,
34
+ TimelineItemData,
35
+ TimelineFeedProps,
31
36
  } from "./types.js";
32
37
 
33
38
  export {
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "../image.js";
3
+
4
+ describe("getPublicUrlForProvider", () => {
5
+ it("returns r2PublicUrl for r2 provider", () => {
6
+ const result = getPublicUrlForProvider(
7
+ "r2",
8
+ "https://r2.example.com",
9
+ "https://s3.example.com",
10
+ );
11
+ expect(result).toBe("https://r2.example.com");
12
+ });
13
+
14
+ it("returns s3PublicUrl for s3 provider", () => {
15
+ const result = getPublicUrlForProvider(
16
+ "s3",
17
+ "https://r2.example.com",
18
+ "https://s3.example.com",
19
+ );
20
+ expect(result).toBe("https://s3.example.com");
21
+ });
22
+
23
+ it("returns undefined when r2 provider has no r2PublicUrl", () => {
24
+ const result = getPublicUrlForProvider(
25
+ "r2",
26
+ undefined,
27
+ "https://s3.example.com",
28
+ );
29
+ expect(result).toBeUndefined();
30
+ });
31
+
32
+ it("returns undefined when s3 provider has no s3PublicUrl", () => {
33
+ const result = getPublicUrlForProvider(
34
+ "s3",
35
+ "https://r2.example.com",
36
+ undefined,
37
+ );
38
+ expect(result).toBeUndefined();
39
+ });
40
+
41
+ it("defaults to r2PublicUrl for unknown providers", () => {
42
+ const result = getPublicUrlForProvider(
43
+ "unknown",
44
+ "https://r2.example.com",
45
+ "https://s3.example.com",
46
+ );
47
+ expect(result).toBe("https://r2.example.com");
48
+ });
49
+ });
50
+
51
+ describe("getMediaUrl", () => {
52
+ it("returns local proxy URL when no publicUrl provided", () => {
53
+ const result = getMediaUrl(
54
+ "01902a9f-1a2b-7c3d",
55
+ "media/2025/01/01902a9f-1a2b-7c3d.webp",
56
+ );
57
+ expect(result).toBe("/media/01902a9f-1a2b-7c3d.webp");
58
+ });
59
+
60
+ it("returns CDN URL when publicUrl is provided", () => {
61
+ const result = getMediaUrl(
62
+ "01902a9f-1a2b-7c3d",
63
+ "media/2025/01/01902a9f-1a2b-7c3d.webp",
64
+ "https://cdn.example.com",
65
+ );
66
+ expect(result).toBe(
67
+ "https://cdn.example.com/media/2025/01/01902a9f-1a2b-7c3d.webp",
68
+ );
69
+ });
70
+ });
71
+
72
+ describe("getImageUrl", () => {
73
+ it("returns original URL when no transform URL provided", () => {
74
+ const result = getImageUrl("/media/test.jpg", undefined, { width: 200 });
75
+ expect(result).toBe("/media/test.jpg");
76
+ });
77
+
78
+ it("returns transformed URL with options", () => {
79
+ const result = getImageUrl(
80
+ "/media/test.jpg",
81
+ "https://example.com/cdn-cgi/image",
82
+ { width: 200, quality: 80, format: "auto" },
83
+ );
84
+ expect(result).toBe(
85
+ "https://example.com/cdn-cgi/image/width=200,quality=80,format=auto//media/test.jpg",
86
+ );
87
+ });
88
+
89
+ it("returns original URL when no options provided", () => {
90
+ const result = getImageUrl(
91
+ "/media/test.jpg",
92
+ "https://example.com/cdn-cgi/image",
93
+ );
94
+ expect(result).toBe("/media/test.jpg");
95
+ });
96
+ });
@@ -0,0 +1,162 @@
1
+ /* eslint-disable @typescript-eslint/no-non-null-assertion -- Test assertions use ! for readability */
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { createR2Driver, createStorageDriver } from "../storage.js";
4
+ import type { Bindings } from "../../types.js";
5
+
6
+ describe("createStorageDriver", () => {
7
+ it("returns null when no storage is configured", () => {
8
+ const env = { DB: {} } as Bindings;
9
+ const driver = createStorageDriver(env);
10
+ expect(driver).toBeNull();
11
+ });
12
+
13
+ it("returns R2 driver when R2 binding is present", () => {
14
+ const env = {
15
+ DB: {},
16
+ R2: { put: vi.fn(), get: vi.fn(), delete: vi.fn() },
17
+ } as unknown as Bindings;
18
+ const driver = createStorageDriver(env);
19
+ expect(driver).not.toBeNull();
20
+ });
21
+
22
+ it("returns R2 driver by default even with STORAGE_DRIVER unset", () => {
23
+ const env = {
24
+ DB: {},
25
+ R2: { put: vi.fn(), get: vi.fn(), delete: vi.fn() },
26
+ } as unknown as Bindings;
27
+ const driver = createStorageDriver(env);
28
+ expect(driver).not.toBeNull();
29
+ });
30
+
31
+ it("returns null for S3 driver when S3 config is incomplete", () => {
32
+ const env = {
33
+ DB: {},
34
+ STORAGE_DRIVER: "s3",
35
+ S3_ENDPOINT: "https://s3.example.com",
36
+ // Missing S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY
37
+ } as unknown as Bindings;
38
+ const driver = createStorageDriver(env);
39
+ expect(driver).toBeNull();
40
+ });
41
+
42
+ it("returns S3 driver when fully configured", () => {
43
+ const env = {
44
+ DB: {},
45
+ STORAGE_DRIVER: "s3",
46
+ S3_ENDPOINT: "https://s3.example.com",
47
+ S3_BUCKET: "my-bucket",
48
+ S3_ACCESS_KEY_ID: "access-key",
49
+ S3_SECRET_ACCESS_KEY: "secret-key",
50
+ S3_REGION: "us-east-1",
51
+ } as unknown as Bindings;
52
+ const driver = createStorageDriver(env);
53
+ expect(driver).not.toBeNull();
54
+ });
55
+
56
+ it("defaults S3_REGION to 'auto' when not set", () => {
57
+ const env = {
58
+ DB: {},
59
+ STORAGE_DRIVER: "s3",
60
+ S3_ENDPOINT: "https://s3.example.com",
61
+ S3_BUCKET: "my-bucket",
62
+ S3_ACCESS_KEY_ID: "access-key",
63
+ S3_SECRET_ACCESS_KEY: "secret-key",
64
+ } as unknown as Bindings;
65
+ // Should not throw - region defaults to "auto"
66
+ const driver = createStorageDriver(env);
67
+ expect(driver).not.toBeNull();
68
+ });
69
+
70
+ it("prefers S3 driver over R2 when STORAGE_DRIVER=s3", () => {
71
+ const env = {
72
+ DB: {},
73
+ R2: { put: vi.fn(), get: vi.fn(), delete: vi.fn() },
74
+ STORAGE_DRIVER: "s3",
75
+ S3_ENDPOINT: "https://s3.example.com",
76
+ S3_BUCKET: "my-bucket",
77
+ S3_ACCESS_KEY_ID: "access-key",
78
+ S3_SECRET_ACCESS_KEY: "secret-key",
79
+ } as unknown as Bindings;
80
+ const driver = createStorageDriver(env);
81
+ expect(driver).not.toBeNull();
82
+ });
83
+ });
84
+
85
+ describe("createR2Driver", () => {
86
+ it("delegates put to R2 bucket", async () => {
87
+ const mockR2 = {
88
+ put: vi.fn().mockResolvedValue(undefined),
89
+ get: vi.fn(),
90
+ delete: vi.fn(),
91
+ } as unknown as R2Bucket;
92
+
93
+ const driver = createR2Driver(mockR2);
94
+ const body = new ReadableStream();
95
+ await driver.put("media/test.jpg", body, { contentType: "image/jpeg" });
96
+
97
+ expect(mockR2.put).toHaveBeenCalledWith("media/test.jpg", body, {
98
+ httpMetadata: { contentType: "image/jpeg" },
99
+ });
100
+ });
101
+
102
+ it("delegates put without contentType", async () => {
103
+ const mockR2 = {
104
+ put: vi.fn().mockResolvedValue(undefined),
105
+ get: vi.fn(),
106
+ delete: vi.fn(),
107
+ } as unknown as R2Bucket;
108
+
109
+ const driver = createR2Driver(mockR2);
110
+ await driver.put("media/test.jpg", new ReadableStream());
111
+
112
+ expect(mockR2.put).toHaveBeenCalledWith(
113
+ "media/test.jpg",
114
+ expect.any(ReadableStream),
115
+ { httpMetadata: undefined },
116
+ );
117
+ });
118
+
119
+ it("delegates get and returns body and contentType", async () => {
120
+ const mockBody = new ReadableStream();
121
+ const mockR2 = {
122
+ put: vi.fn(),
123
+ get: vi.fn().mockResolvedValue({
124
+ body: mockBody,
125
+ httpMetadata: { contentType: "image/jpeg" },
126
+ }),
127
+ delete: vi.fn(),
128
+ } as unknown as R2Bucket;
129
+
130
+ const driver = createR2Driver(mockR2);
131
+ const result = await driver.get("media/test.jpg");
132
+
133
+ expect(result).not.toBeNull();
134
+ expect(result!.body).toBe(mockBody);
135
+ expect(result!.contentType).toBe("image/jpeg");
136
+ });
137
+
138
+ it("returns null when R2 get returns null", async () => {
139
+ const mockR2 = {
140
+ put: vi.fn(),
141
+ get: vi.fn().mockResolvedValue(null),
142
+ delete: vi.fn(),
143
+ } as unknown as R2Bucket;
144
+
145
+ const driver = createR2Driver(mockR2);
146
+ const result = await driver.get("nonexistent");
147
+ expect(result).toBeNull();
148
+ });
149
+
150
+ it("delegates delete to R2 bucket", async () => {
151
+ const mockR2 = {
152
+ put: vi.fn(),
153
+ get: vi.fn(),
154
+ delete: vi.fn().mockResolvedValue(undefined),
155
+ } as unknown as R2Bucket;
156
+
157
+ const driver = createR2Driver(mockR2);
158
+ await driver.delete("media/test.jpg");
159
+
160
+ expect(mockR2.delete).toHaveBeenCalledWith("media/test.jpg");
161
+ });
162
+ });
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ resolveCardComponent,
4
+ resolveThreadPreview,
5
+ resolveTimelineFeed,
6
+ } from "../theme-components.js";
7
+ import type {
8
+ ThemeComponents,
9
+ TimelineCardProps,
10
+ ThreadPreviewProps,
11
+ TimelineFeedProps,
12
+ PostType,
13
+ } from "../../types.js";
14
+ import type { FC } from "hono/jsx";
15
+
16
+ // Create simple mock components for testing (avoids importing .tsx files with i18n)
17
+ const MockNoteCard: FC<TimelineCardProps> = () => null;
18
+ const MockArticleCard: FC<TimelineCardProps> = () => null;
19
+ const MockLinkCard: FC<TimelineCardProps> = () => null;
20
+ const MockQuoteCard: FC<TimelineCardProps> = () => null;
21
+ const MockImageCard: FC<TimelineCardProps> = () => null;
22
+ const MockThreadPreview: FC<ThreadPreviewProps> = () => null;
23
+ const MockTimelineFeed: FC<TimelineFeedProps> = () => null;
24
+
25
+ const DEFAULT_CARD_MAP: Record<PostType, FC<TimelineCardProps>> = {
26
+ note: MockNoteCard,
27
+ article: MockArticleCard,
28
+ link: MockLinkCard,
29
+ quote: MockQuoteCard,
30
+ image: MockImageCard,
31
+ page: MockNoteCard,
32
+ };
33
+
34
+ describe("theme-components", () => {
35
+ describe("resolveCardComponent", () => {
36
+ it("returns default NoteCard for note type", () => {
37
+ expect(resolveCardComponent("note", DEFAULT_CARD_MAP)).toBe(MockNoteCard);
38
+ });
39
+
40
+ it("returns default ArticleCard for article type", () => {
41
+ expect(resolveCardComponent("article", DEFAULT_CARD_MAP)).toBe(
42
+ MockArticleCard,
43
+ );
44
+ });
45
+
46
+ it("returns default LinkCard for link type", () => {
47
+ expect(resolveCardComponent("link", DEFAULT_CARD_MAP)).toBe(MockLinkCard);
48
+ });
49
+
50
+ it("returns default QuoteCard for quote type", () => {
51
+ expect(resolveCardComponent("quote", DEFAULT_CARD_MAP)).toBe(
52
+ MockQuoteCard,
53
+ );
54
+ });
55
+
56
+ it("returns default ImageCard for image type", () => {
57
+ expect(resolveCardComponent("image", DEFAULT_CARD_MAP)).toBe(
58
+ MockImageCard,
59
+ );
60
+ });
61
+
62
+ it("returns NoteCard as fallback for page type", () => {
63
+ expect(resolveCardComponent("page", DEFAULT_CARD_MAP)).toBe(MockNoteCard);
64
+ });
65
+
66
+ it("returns theme override when provided", () => {
67
+ const CustomNote: FC<TimelineCardProps> = () => null;
68
+ const overrides: ThemeComponents = { NoteCard: CustomNote };
69
+ expect(resolveCardComponent("note", DEFAULT_CARD_MAP, overrides)).toBe(
70
+ CustomNote,
71
+ );
72
+ });
73
+
74
+ it("returns default when theme has no override for type", () => {
75
+ const overrides: ThemeComponents = {};
76
+ expect(resolveCardComponent("article", DEFAULT_CARD_MAP, overrides)).toBe(
77
+ MockArticleCard,
78
+ );
79
+ });
80
+ });
81
+
82
+ describe("resolveThreadPreview", () => {
83
+ it("returns default ThreadPreview when no override", () => {
84
+ expect(resolveThreadPreview(MockThreadPreview)).toBe(MockThreadPreview);
85
+ });
86
+
87
+ it("returns theme override when provided", () => {
88
+ const Custom: FC<ThreadPreviewProps> = () => null;
89
+ expect(
90
+ resolveThreadPreview(MockThreadPreview, { ThreadPreview: Custom }),
91
+ ).toBe(Custom);
92
+ });
93
+ });
94
+
95
+ describe("resolveTimelineFeed", () => {
96
+ it("returns default TimelineFeed when no override", () => {
97
+ expect(resolveTimelineFeed(MockTimelineFeed)).toBe(MockTimelineFeed);
98
+ });
99
+
100
+ it("returns theme override when provided", () => {
101
+ const Custom: FC<TimelineFeedProps> = () => null;
102
+ expect(
103
+ resolveTimelineFeed(MockTimelineFeed, { TimelineFeed: Custom }),
104
+ ).toBe(Custom);
105
+ });
106
+ });
107
+ });
package/src/lib/image.ts CHANGED
@@ -71,37 +71,67 @@ export function getImageUrl(
71
71
  return `${transformUrl}/${params.join(",")}/${originalUrl}`;
72
72
  }
73
73
 
74
+ /**
75
+ * Returns the appropriate public URL base for a given storage provider.
76
+ *
77
+ * For `"s3"` provider, returns `s3PublicUrl`. For all other providers
78
+ * (including `"r2"`), returns `r2PublicUrl`. Falls back to `undefined`
79
+ * if the matching URL is not configured.
80
+ *
81
+ * @param provider - The storage provider identifier (e.g., `"r2"`, `"s3"`)
82
+ * @param r2PublicUrl - Optional R2 public URL
83
+ * @param s3PublicUrl - Optional S3 public URL
84
+ * @returns The public URL base for the provider, or undefined
85
+ *
86
+ * @example
87
+ * ```ts
88
+ * getPublicUrlForProvider("r2", "https://r2.example.com", "https://s3.example.com");
89
+ * // Returns: "https://r2.example.com"
90
+ *
91
+ * getPublicUrlForProvider("s3", "https://r2.example.com", "https://s3.example.com");
92
+ * // Returns: "https://s3.example.com"
93
+ * ```
94
+ */
95
+ export function getPublicUrlForProvider(
96
+ provider: string,
97
+ r2PublicUrl?: string,
98
+ s3PublicUrl?: string,
99
+ ): string | undefined {
100
+ if (provider === "s3") return s3PublicUrl;
101
+ return r2PublicUrl;
102
+ }
103
+
74
104
  /**
75
105
  * Generates a media URL using UUIDv7-based paths.
76
106
  *
77
- * Returns a public URL for a media file. If `r2PublicUrl` is set, uses that directly
78
- * with the r2Key. Otherwise, generates a `/media/{id}.{ext}` URL.
107
+ * Returns a public URL for a media file. If `publicUrl` is set, uses that directly
108
+ * with the storage key. Otherwise, generates a `/media/{id}.{ext}` local proxy URL.
79
109
  *
80
110
  * @param mediaId - The UUIDv7 database ID of the media
81
- * @param r2Key - The R2 storage key (used to extract extension)
82
- * @param r2PublicUrl - Optional R2 public URL for direct CDN access
111
+ * @param storageKey - The storage object key (used to build CDN path and extract extension)
112
+ * @param publicUrl - Optional public URL base for direct CDN access
83
113
  * @returns The public URL for the media file
84
114
  *
85
115
  * @example
86
116
  * ```ts
87
- * // Without R2 public URL - uses UUID with extension
88
- * getMediaUrl("01902a9f-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "uploads/file.webp");
89
- * // Returns: "/media/01902a9f-1a2b-7c3d-8e4f-5a6b7c8d9e0f.webp"
117
+ * // Without public URL - uses local proxy with UUID and extension
118
+ * getMediaUrl("01902a9f-1a2b-7c3d", "media/2025/01/01902a9f-1a2b-7c3d.webp");
119
+ * // Returns: "/media/01902a9f-1a2b-7c3d.webp"
90
120
  *
91
- * // With R2 public URL - uses direct CDN
92
- * getMediaUrl("01902a9f-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "uploads/file.webp", "https://cdn.example.com");
93
- * // Returns: "https://cdn.example.com/uploads/file.webp"
121
+ * // With public URL - uses direct CDN
122
+ * getMediaUrl("01902a9f-1a2b-7c3d", "media/2025/01/01902a9f-1a2b-7c3d.webp", "https://cdn.example.com");
123
+ * // Returns: "https://cdn.example.com/media/2025/01/01902a9f-1a2b-7c3d.webp"
94
124
  * ```
95
125
  */
96
126
  export function getMediaUrl(
97
127
  mediaId: string,
98
- r2Key: string,
99
- r2PublicUrl?: string,
128
+ storageKey: string,
129
+ publicUrl?: string,
100
130
  ): string {
101
- if (r2PublicUrl) {
102
- return `${r2PublicUrl}/${r2Key}`;
131
+ if (publicUrl) {
132
+ return `${publicUrl.replace(/\/+$/, "")}/${storageKey}`;
103
133
  }
104
- // Extract extension from r2Key
105
- const ext = r2Key.split(".").pop() || "bin";
134
+ // Extract extension from storage key
135
+ const ext = storageKey.split(".").pop() || "bin";
106
136
  return `/media/${mediaId}.${ext}`;
107
137
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Media Helper Utilities
3
+ *
4
+ * Shared logic for building MediaAttachment maps from raw media data.
5
+ */
6
+
7
+ import type { Media, MediaAttachment } from "../types.js";
8
+ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
9
+
10
+ /**
11
+ * Builds a map of post IDs to MediaAttachment arrays from raw media data.
12
+ *
13
+ * Transforms raw Media objects (with storage keys) into MediaAttachment objects
14
+ * (with public URLs and preview URLs) suitable for rendering.
15
+ * Automatically resolves the correct public URL based on each media item's
16
+ * storage provider (`"r2"` or `"s3"`).
17
+ *
18
+ * @param rawMediaMap - Map of post IDs to raw Media arrays from the media service
19
+ * @param r2PublicUrl - Optional R2 public URL for direct CDN access
20
+ * @param imageTransformUrl - Optional image transformation service URL
21
+ * @param s3PublicUrl - Optional S3 public URL for direct CDN access
22
+ * @returns Map of post IDs to MediaAttachment arrays
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * const rawMediaMap = await services.media.getByPostIds(postIds);
27
+ * const mediaMap = buildMediaMap(rawMediaMap, c.env.R2_PUBLIC_URL, c.env.IMAGE_TRANSFORM_URL, c.env.S3_PUBLIC_URL);
28
+ * ```
29
+ */
30
+ export function buildMediaMap(
31
+ rawMediaMap: Map<number, Media[]>,
32
+ r2PublicUrl?: string,
33
+ imageTransformUrl?: string,
34
+ s3PublicUrl?: string,
35
+ ): Map<number, MediaAttachment[]> {
36
+ const mediaMap = new Map<number, MediaAttachment[]>();
37
+ for (const [postId, mediaList] of rawMediaMap) {
38
+ mediaMap.set(
39
+ postId,
40
+ mediaList.map((m) => {
41
+ const publicUrl = getPublicUrlForProvider(
42
+ m.provider,
43
+ r2PublicUrl,
44
+ s3PublicUrl,
45
+ );
46
+ return {
47
+ id: m.id,
48
+ url: getMediaUrl(m.id, m.storageKey, publicUrl),
49
+ previewUrl: getImageUrl(
50
+ getMediaUrl(m.id, m.storageKey, publicUrl),
51
+ imageTransformUrl,
52
+ { width: 400, quality: 80, format: "auto", fit: "cover" },
53
+ ),
54
+ alt: m.alt,
55
+ blurhash: m.blurhash,
56
+ width: m.width,
57
+ height: m.height,
58
+ position: m.position,
59
+ mimeType: m.mimeType,
60
+ };
61
+ }),
62
+ );
63
+ }
64
+ return mediaMap;
65
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Navigation Link Reorder
3
+ *
4
+ * Initializes SortableJS on the navigation links list in the dashboard.
5
+ * Auto-detects the list element and only activates when present.
6
+ */
7
+
8
+ import Sortable from "sortablejs";
9
+
10
+ const list = document.getElementById("nav-links-list");
11
+ if (list) {
12
+ Sortable.create(list, {
13
+ animation: 150,
14
+ handle: "[data-id]",
15
+ onEnd() {
16
+ const ids = [...list.querySelectorAll<HTMLElement>("[data-id]")].map(
17
+ (el) => Number(el.dataset.id),
18
+ );
19
+ fetch("/dash/navigation/reorder", {
20
+ method: "POST",
21
+ headers: { "Content-Type": "application/json" },
22
+ body: JSON.stringify({ ids }),
23
+ });
24
+ },
25
+ });
26
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Navigation Helper
3
+ *
4
+ * Provides shared data fetching for public page navigation.
5
+ */
6
+
7
+ import type { Context } from "hono";
8
+ import { getSiteName } from "./config.js";
9
+ import type { NavigationLink } from "../types.js";
10
+
11
+ /**
12
+ * Navigation data needed by SiteLayout
13
+ */
14
+ export interface NavigationData {
15
+ navigationLinks: NavigationLink[];
16
+ currentPath: string;
17
+ siteName: string;
18
+ }
19
+
20
+ /**
21
+ * Fetch navigation data for public pages.
22
+ *
23
+ * Ensures default links exist (Home, Archive, RSS) and returns
24
+ * the current path and site name alongside the links.
25
+ *
26
+ * @param c - Hono context
27
+ * @returns Navigation data for SiteLayout
28
+ *
29
+ * @example
30
+ * ```typescript
31
+ * const navData = await getNavigationData(c);
32
+ * return c.html(
33
+ * <BaseLayout c={c}>
34
+ * <SiteLayout {...navData}>
35
+ * <MyContent />
36
+ * </SiteLayout>
37
+ * </BaseLayout>
38
+ * );
39
+ * ```
40
+ */
41
+ export async function getNavigationData(c: Context): Promise<NavigationData> {
42
+ const navigationLinks = await c.var.services.navigationLinks.ensureDefaults();
43
+ const currentPath = new URL(c.req.url).pathname;
44
+ const siteName = await getSiteName(c);
45
+ return { navigationLinks, currentPath, siteName };
46
+ }