@jant/core 0.2.16 → 0.2.18

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 (120) hide show
  1. package/dist/app.d.ts +5 -1
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +332 -119
  4. package/dist/i18n/context.d.ts +2 -2
  5. package/dist/i18n/context.js +1 -1
  6. package/dist/i18n/i18n.d.ts +1 -1
  7. package/dist/i18n/i18n.js +1 -1
  8. package/dist/i18n/index.d.ts +1 -1
  9. package/dist/i18n/index.js +1 -1
  10. package/dist/i18n/locales/en.d.ts.map +1 -1
  11. package/dist/i18n/locales/en.js +1 -1
  12. package/dist/i18n/locales/zh-Hans.d.ts.map +1 -1
  13. package/dist/i18n/locales/zh-Hans.js +1 -1
  14. package/dist/i18n/locales/zh-Hant.d.ts.map +1 -1
  15. package/dist/i18n/locales/zh-Hant.js +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/lib/config.d.ts +83 -0
  19. package/dist/lib/config.d.ts.map +1 -0
  20. package/dist/lib/config.js +104 -0
  21. package/dist/lib/constants.d.ts +2 -1
  22. package/dist/lib/constants.d.ts.map +1 -1
  23. package/dist/lib/constants.js +5 -2
  24. package/dist/lib/sse.d.ts +15 -0
  25. package/dist/lib/sse.d.ts.map +1 -1
  26. package/dist/lib/sse.js +13 -0
  27. package/dist/lib/theme.d.ts +44 -0
  28. package/dist/lib/theme.d.ts.map +1 -0
  29. package/dist/lib/theme.js +65 -0
  30. package/dist/routes/dash/appearance.d.ts +13 -0
  31. package/dist/routes/dash/appearance.d.ts.map +1 -0
  32. package/dist/routes/dash/appearance.js +164 -0
  33. package/dist/routes/dash/collections.d.ts.map +1 -1
  34. package/dist/routes/dash/collections.js +5 -4
  35. package/dist/routes/dash/index.d.ts.map +1 -1
  36. package/dist/routes/dash/index.js +2 -1
  37. package/dist/routes/dash/media.d.ts.map +1 -1
  38. package/dist/routes/dash/media.js +3 -2
  39. package/dist/routes/dash/pages.d.ts.map +1 -1
  40. package/dist/routes/dash/pages.js +5 -4
  41. package/dist/routes/dash/posts.d.ts.map +1 -1
  42. package/dist/routes/dash/posts.js +5 -4
  43. package/dist/routes/dash/redirects.d.ts.map +1 -1
  44. package/dist/routes/dash/redirects.js +3 -2
  45. package/dist/routes/dash/settings.d.ts.map +1 -1
  46. package/dist/routes/dash/settings.js +39 -38
  47. package/dist/routes/pages/archive.d.ts.map +1 -1
  48. package/dist/routes/pages/archive.js +2 -1
  49. package/dist/routes/pages/collection.d.ts.map +1 -1
  50. package/dist/routes/pages/collection.js +2 -1
  51. package/dist/routes/pages/home.d.ts.map +1 -1
  52. package/dist/routes/pages/home.js +2 -1
  53. package/dist/routes/pages/page.d.ts.map +1 -1
  54. package/dist/routes/pages/page.js +2 -1
  55. package/dist/routes/pages/post.d.ts.map +1 -1
  56. package/dist/routes/pages/post.js +2 -1
  57. package/dist/routes/pages/search.d.ts.map +1 -1
  58. package/dist/routes/pages/search.js +2 -1
  59. package/dist/services/settings.d.ts +1 -0
  60. package/dist/services/settings.d.ts.map +1 -1
  61. package/dist/services/settings.js +3 -0
  62. package/dist/theme/color-themes.d.ts +30 -0
  63. package/dist/theme/color-themes.d.ts.map +1 -0
  64. package/dist/theme/color-themes.js +268 -0
  65. package/dist/theme/layouts/BaseLayout.d.ts +5 -0
  66. package/dist/theme/layouts/BaseLayout.d.ts.map +1 -1
  67. package/dist/theme/layouts/BaseLayout.js +70 -3
  68. package/dist/theme/layouts/DashLayout.d.ts +2 -0
  69. package/dist/theme/layouts/DashLayout.d.ts.map +1 -1
  70. package/dist/theme/layouts/DashLayout.js +10 -1
  71. package/dist/theme/layouts/index.d.ts +1 -1
  72. package/dist/theme/layouts/index.d.ts.map +1 -1
  73. package/dist/types.d.ts +64 -32
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/types.js +52 -0
  76. package/package.json +1 -1
  77. package/src/app.tsx +286 -59
  78. package/src/db/migrations/{0000_solid_moon_knight.sql → 0000_square_wallflower.sql} +3 -3
  79. package/src/db/migrations/meta/0000_snapshot.json +9 -9
  80. package/src/db/migrations/meta/_journal.json +2 -30
  81. package/src/i18n/context.tsx +2 -2
  82. package/src/i18n/i18n.ts +1 -1
  83. package/src/i18n/index.ts +1 -1
  84. package/src/i18n/locales/en.po +328 -252
  85. package/src/i18n/locales/en.ts +1 -1
  86. package/src/i18n/locales/zh-Hans.po +315 -278
  87. package/src/i18n/locales/zh-Hans.ts +1 -1
  88. package/src/i18n/locales/zh-Hant.po +315 -278
  89. package/src/i18n/locales/zh-Hant.ts +1 -1
  90. package/src/index.ts +0 -2
  91. package/src/lib/config.ts +120 -0
  92. package/src/lib/constants.ts +3 -0
  93. package/src/lib/sse.ts +38 -0
  94. package/src/lib/theme.ts +86 -0
  95. package/src/preset.css +9 -0
  96. package/src/routes/dash/appearance.tsx +180 -0
  97. package/src/routes/dash/collections.tsx +5 -4
  98. package/src/routes/dash/index.tsx +2 -1
  99. package/src/routes/dash/media.tsx +3 -2
  100. package/src/routes/dash/pages.tsx +5 -4
  101. package/src/routes/dash/posts.tsx +5 -4
  102. package/src/routes/dash/redirects.tsx +3 -2
  103. package/src/routes/dash/settings.tsx +51 -49
  104. package/src/routes/pages/archive.tsx +2 -1
  105. package/src/routes/pages/collection.tsx +2 -1
  106. package/src/routes/pages/home.tsx +2 -1
  107. package/src/routes/pages/page.tsx +2 -1
  108. package/src/routes/pages/post.tsx +2 -1
  109. package/src/routes/pages/search.tsx +2 -1
  110. package/src/services/settings.ts +5 -0
  111. package/src/styles/components.css +93 -0
  112. package/src/theme/color-themes.ts +321 -0
  113. package/src/theme/layouts/BaseLayout.tsx +61 -1
  114. package/src/theme/layouts/DashLayout.tsx +13 -2
  115. package/src/theme/layouts/index.ts +5 -1
  116. package/src/types.ts +74 -34
  117. package/src/db/migrations/0001_add_search_fts.sql +0 -40
  118. package/src/db/migrations/0002_collection_path.sql +0 -2
  119. package/src/db/migrations/0003_collection_path_nullable.sql +0 -21
  120. package/src/db/migrations/0004_media_uuid.sql +0 -35
@@ -1 +1 @@
1
- /*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"+7Wr2a\":[\"編輯: \",[\"title\"]],\"+MACwa\":[\"尚未有任何收藏。\"],\"+owNNn\":[\"文章\"],\"+zy2Nq\":[\"類型\"],\"/0D1Xp\":[\"編輯收藏集\"],\"/Rj5P4\":[\"您的姓名\"],\"0JkyS7\":[\"建立您的第一個頁面\"],\"0a6MpL\":[\"新重定向\"],\"1CU1Td\":[\"網址安全識別碼(小寫、數字、連字符)\"],\"1DBGsz\":[\"筆記\"],\"2N0qpv\":[\"文章標題...\"],\"2q/Q7x\":[\"可見性\"],\"2rJGtU\":[\"頁面標題...\"],\"3Yvsaz\":[\"302(臨時)\"],\"4/SFQS\":[\"查看網站\"],\"40TVQj\":[\"自訂路徑(選填)\"],\"4KzVT6\":[\"刪除頁面\"],\"4b3oEV\":[\"內容\"],\"4mDPGp\":[\"此頁面的 URL 路徑。使用小寫字母、數字和連字符。\"],\"6WdDG7\":[\"頁面\"],\"7G4SBz\":[\"頁面內容(支持Markdown)...\"],\"7Mk+/h\":[\"更新收藏集\"],\"7Q1KKN\":[\"來源路徑\"],\"7nGhhM\":[\"你在想什麼?\"],\"7p5kLi\":[\"儀表板\"],\"8ZsakT\":[\"密碼\"],\"90Luob\":[[\"count\"],\" 條回覆\"],\"A1taO8\":[\"搜尋\"],\"AyHO4m\":[\"這個收藏是關於什麼的?\"],\"B373X+\":[\"編輯文章\"],\"B495Gs\":[\"檔案館\"],\"BjF0Jv\":[\"僅限小寫字母、數字和連字符\"],\"D9Oea+\":[\"永久鏈接\"],\"DHhJ7s\":[\"上一頁\"],\"DoJzLz\":[\"收藏夾\"],\"E80cJw\":[\"刪除此媒體將永久從存儲中移除它。\"],\"EEYbdt\":[\"發佈\"],\"EGwzOK\":[\"完成設置\"],\"EkH9pt\":[\"更新\"],\"FGrimz\":[\"新帖子\"],\"FkMol5\":[\"精選\"],\"Fxf4jq\":[\"描述(可選)\"],\"GA5A5H\":[\"刪除收藏夾\"],\"GorKul\":[\"歡迎來到 Jant\"],\"GrZ6fH\":[\"新頁面\"],\"HfyyXl\":[\"我的部落格\"],\"HiETwV\":[\"安靜(正常)\"],\"Hzi9AA\":[\"未找到任何帖子。\"],\"I6gXOa\":[\"路徑\"],\"IagCbF\":[\"網址\"],\"J4FNfC\":[\"此集合中沒有帖子。\"],\"JIBC/T\":[\"支援的格式:JPEGPNGGIFWebPSVG。最大大小:10MB。\"],\"JiP4aa\":[\"已發佈的頁面可以通過其路徑訪問。草稿不可見。\"],\"K9NcLu\":[\"使用此 URL 將媒體嵌入到您的帖子中。\"],\"KiJn9B\":[\"備註\"],\"L85WcV\":[\"縮略名\"],\"LkvLQe\":[\"尚未有頁面。\"],\"M8kJqa\":[\"草稿\"],\"M9xgHy\":[\"重定向\"],\"MHrjPM\":[\"標題\"],\"MZbQHL\":[\"未找到結果。\"],\"Mhf/H/\":[\"建立重定向\"],\"MqghUt\":[\"搜尋帖子...\"],\"N40H+G\":[\"所有\"],\"O3oNi5\":[\"電子郵件\"],\"OCNZaU\":[\"重定向來源的路徑\"],\"ODiSoW\":[\"尚未有帖子。\"],\"ONWvwQ\":[\"上傳\"],\"Pbm2/N\":[\"創建收藏夾\"],\"RDjuBN\":[\"設置\"],\"Rj01Fz\":[\"連結\"],\"RwGhWy\":[\"包含 \",[\"count\"],\" 則帖子的主題\"],\"SJmfuf\":[\"網站名稱\"],\"ST+lN2\":[\"尚未上傳任何媒體。\"],\"Tt5T6+\":[\"文章\"],\"TxE+Mj\":[\"1 條回覆\"],\"Tz0i8g\":[\"設定\"],\"U5v6Gh\":[\"編輯頁面\"],\"UDMjsP\":[\"快速操作\"],\"UGT5vp\":[\"保存設定\"],\"VUSy8D\":[\"搜尋失敗。請再試一次。\"],\"WDcQq9\":[\"不公開\"],\"Weq9zb\":[\"一般設定\"],\"WmZ/rP\":[\"到路徑\"],\"Y+7JGK\":[\"創建頁面\"],\"ZQKLI1\":[\"危險區域\"],\"ZhhOwV\":[\"引用\"],\"an5hVd\":[\"圖片\"],\"b+/jO6\":[\"301(永久)\"],\"bHYIks\":[\"登出\"],\"biOepV\":[\"← 返回首頁\"],\"cnGeoo\":[\"刪除\"],\"dEgA5A\":[\"取消\"],\"e6Jr7Q\":[\"← 返回收藏夾\"],\"ePK91l\":[\"編輯\"],\"eWLklq\":[\"引用\"],\"eneWvv\":[\"草稿\"],\"f6e0Ry\":[\"文章\"],\"fG7BxZ\":[\"透過 API 上傳圖片:POST /api/upload,並使用文件表單字段。\"],\"fttd2R\":[\"我的收藏\"],\"hG89Ed\":[\"圖片\"],\"hXzOVo\":[\"下一頁\"],\"he3ygx\":[\"複製\"],\"iH8pgl\":[\"返回\"],\"ig4hg2\":[\"讓我們設置您的網站。\"],\"jpctdh\":[\"查看\"],\"mTOYla\":[\"查看所有文章 →\"],\"n1ekoW\":[\"登入\"],\"oYPBa0\":[\"更新頁面\"],\"pRhYH2\":[\"收藏中的帖子 (\",[\"count\"],\")\"],\"qMyM2u\":[\"來源網址(選填)\"],\"r1MpXi\":[\"安靜\"],\"rdUucN\":[\"預覽\"],\"rzNUSl\":[\"包含 1 則貼文的主題\"],\"sGajR7\":[\"線程開始\"],\"t/YqKh\":[\"移除\"],\"tfrt7B\":[\"未配置任何重定向。\"],\"tiq7kl\":[\"頁面 \",[\"page\"]],\"u2f7vd\":[\"網站描述\"],\"u3wRF+\":[\"已發佈\"],\"u6Hp4N\":[\"Markdown\"],\"uAQUqI\":[\"狀態\"],\"vXIe7J\":[\"語言\"],\"vzU4k9\":[\"新收藏集\"],\"wEF6Ix\":[\"目的地路徑或 URL\"],\"wK4OTM\":[\"標題(選填)\"],\"wM5UXj\":[\"刪除媒體\"],\"wRR604\":[\"頁面\"],\"wja8aL\":[\"無標題\"],\"x0mzE0\":[\"創建你的第一篇帖子\"],\"x4RuFo\":[\"回到首頁\"],\"xYilR2\":[\"媒體\"],\"y28hnO\":[\"文章\"],\"yQ2kGp\":[\"載入更多\"],\"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\":[\"尚未有任何收藏。\"],\"+owNNn\":[\"文章\"],\"+zy2Nq\":[\"類型\"],\"/0D1Xp\":[\"編輯收藏集\"],\"/Rj5P4\":[\"您的姓名\"],\"07Epll\":[\"這將為您的網站和儀表板設置主題。所有顏色主題都支持深色模式。\"],\"0JkyS7\":[\"建立您的第一個頁面\"],\"0a6MpL\":[\"新重定向\"],\"1CU1Td\":[\"網址安全識別碼(小寫、數字、連字符)\"],\"1DBGsz\":[\"筆記\"],\"2N0qpv\":[\"文章標題...\"],\"2q/Q7x\":[\"可見性\"],\"2rJGtU\":[\"頁面標題...\"],\"3Yvsaz\":[\"302(臨時)\"],\"4/SFQS\":[\"查看網站\"],\"40TVQj\":[\"自訂路徑(選填)\"],\"4KzVT6\":[\"刪除頁面\"],\"4b3oEV\":[\"內容\"],\"4mDPGp\":[\"此頁面的 URL 路徑。使用小寫字母、數字和連字符。\"],\"6WdDG7\":[\"頁面\"],\"7G4SBz\":[\"頁面內容(支持Markdown)...\"],\"7Mk+/h\":[\"更新收藏集\"],\"7Q1KKN\":[\"來源路徑\"],\"7aECQB\":[\"無效或已過期的連結\"],\"7nGhhM\":[\"你在想什麼?\"],\"7p5kLi\":[\"儀表板\"],\"7vhWI8\":[\"新密碼\"],\"8ZsakT\":[\"密碼\"],\"90Luob\":[[\"count\"],\" 條回覆\"],\"A1taO8\":[\"搜尋\"],\"AyHO4m\":[\"這個收藏是關於什麼的?\"],\"B373X+\":[\"編輯文章\"],\"B495Gs\":[\"檔案館\"],\"BjF0Jv\":[\"僅限小寫字母、數字和連字符\"],\"D9Oea+\":[\"永久鏈接\"],\"DCKkhU\":[\"當前密碼\"],\"DHhJ7s\":[\"上一頁\"],\"DoJzLz\":[\"收藏夾\"],\"E80cJw\":[\"刪除此媒體將永久從存儲中移除它。\"],\"EEYbdt\":[\"發佈\"],\"EGwzOK\":[\"完成設置\"],\"EkH9pt\":[\"更新\"],\"FGrimz\":[\"新帖子\"],\"FkMol5\":[\"精選\"],\"Fxf4jq\":[\"描述(可選)\"],\"GA5A5H\":[\"刪除收藏夾\"],\"GX2VMa\":[\"建立您的管理員帳戶。\"],\"GbVAnd\":[\"此密碼重設連結無效或已過期。請生成一個新的連結。\"],\"GorKul\":[\"歡迎來到 Jant\"],\"GrZ6fH\":[\"新頁面\"],\"GxkJXS\":[\"上傳中...\"],\"HfyyXl\":[\"My Blog\"],\"HiETwV\":[\"安靜(正常)\"],\"Hzi9AA\":[\"未找到任何帖子。\"],\"I6gXOa\":[\"路徑\"],\"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\":[\"標題\"],\"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\":[\"創建頁面\"],\"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\":[\"登入\"],\"oYPBa0\":[\"更新頁面\"],\"p2/GCq\":[\"確認密碼\"],\"pRhYH2\":[\"收藏中的帖子 (\",[\"count\"],\")\"],\"pZq3aX\":[\"上傳失敗。請再試一次。\"],\"qMyM2u\":[\"來源網址(選填)\"],\"r1MpXi\":[\"安靜\"],\"rFmBG3\":[\"顏色主題\"],\"rdUucN\":[\"預覽\"],\"rzNUSl\":[\"包含 1 則貼文的主題\"],\"sGajR7\":[\"線程開始\"],\"t/YqKh\":[\"移除\"],\"tfrt7B\":[\"未配置任何重定向。\"],\"tiq7kl\":[\"頁面 \",[\"page\"]],\"u2f7vd\":[\"網站描述\"],\"u3wRF+\":[\"已發佈\"],\"u6Hp4N\":[\"Markdown\"],\"uAQUqI\":[\"狀態\"],\"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;
package/src/index.ts CHANGED
@@ -25,8 +25,6 @@ export type {
25
25
  UpdatePost,
26
26
  JantConfig,
27
27
  JantTheme,
28
- SiteConfig,
29
- FeatureConfig,
30
28
  ThemeComponents,
31
29
  } from "./types.js";
32
30
 
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Unified Configuration System
3
+ *
4
+ * Provides a flexible configuration system with two priority modes:
5
+ * - User-configurable (envOnly: false): Database > Environment > Default
6
+ * - Environment-only (envOnly: true): Environment > Default
7
+ *
8
+ * All configuration fields are defined in CONFIG_FIELDS (types.ts).
9
+ */
10
+
11
+ import type { Context } from "hono";
12
+ import { CONFIG_FIELDS, type ConfigKey } from "../types.js";
13
+
14
+ /**
15
+ * Get the fallback value for a config key (ENV > Default), skipping the database.
16
+ * Used for placeholder values in forms where the DB value is shown separately.
17
+ *
18
+ * @param c - Hono context
19
+ * @param key - Configuration key from CONFIG_FIELDS
20
+ * @returns Fallback value from environment or default
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * const placeholder = getConfigFallback(c, "SITE_NAME");
25
+ * // Returns: c.env.SITE_NAME ?? "Jant"
26
+ * ```
27
+ */
28
+ export function getConfigFallback(c: Context, key: ConfigKey): string {
29
+ const field = CONFIG_FIELDS[key];
30
+ const envValue = c.env[key as keyof typeof c.env];
31
+ if (envValue && typeof envValue === "string") return envValue;
32
+ return field.defaultValue;
33
+ }
34
+
35
+ /**
36
+ * Generic configuration getter that respects priority settings
37
+ *
38
+ * @param c - Hono context
39
+ * @param key - Configuration key from CONFIG_FIELDS
40
+ * @returns Configuration value following the defined priority
41
+ *
42
+ * @example
43
+ * ```typescript
44
+ * // For user-configurable configs (SITE_NAME):
45
+ * // Returns: (DB: SITE_NAME) ?? c.env.SITE_NAME ?? "Jant"
46
+ *
47
+ * // For environment-only configs (SITE_URL):
48
+ * // Returns: c.env.SITE_URL ?? ""
49
+ * ```
50
+ */
51
+ export async function getConfig(c: Context, key: ConfigKey): Promise<string> {
52
+ const field = CONFIG_FIELDS[key];
53
+
54
+ if (!field.envOnly) {
55
+ // User-configurable: DB > ENV > Default
56
+ // 1. Check database setting first
57
+ const dbValue = await c.var.services.settings.get(key);
58
+ if (dbValue) {
59
+ return dbValue;
60
+ }
61
+ }
62
+
63
+ // ENV > Default
64
+ // 2. Check environment variable
65
+ const envValue = c.env[key as keyof typeof c.env];
66
+ if (envValue && typeof envValue === "string") {
67
+ return envValue;
68
+ }
69
+
70
+ // 3. Default value
71
+ return field.defaultValue;
72
+ }
73
+
74
+ /**
75
+ * Get site name with fallback chain: DB > ENV > Default
76
+ *
77
+ * @param c - Hono context
78
+ * @returns Site name
79
+ *
80
+ * @example
81
+ * ```typescript
82
+ * const siteName = await getSiteName(c);
83
+ * // Returns: (DB: SITE_NAME) ?? c.env.SITE_NAME ?? "Jant"
84
+ * ```
85
+ */
86
+ export async function getSiteName(c: Context): Promise<string> {
87
+ return getConfig(c, "SITE_NAME");
88
+ }
89
+
90
+ /**
91
+ * Get site description with fallback chain: DB > ENV > Default
92
+ *
93
+ * @param c - Hono context
94
+ * @returns Site description
95
+ *
96
+ * @example
97
+ * ```typescript
98
+ * const description = await getSiteDescription(c);
99
+ * // Returns: (DB: SITE_DESCRIPTION) ?? c.env.SITE_DESCRIPTION ?? "A microblog powered by Jant"
100
+ * ```
101
+ */
102
+ export async function getSiteDescription(c: Context): Promise<string> {
103
+ return getConfig(c, "SITE_DESCRIPTION");
104
+ }
105
+
106
+ /**
107
+ * Get site language with fallback chain: DB > ENV > Default
108
+ *
109
+ * @param c - Hono context
110
+ * @returns Site language code
111
+ *
112
+ * @example
113
+ * ```typescript
114
+ * const lang = await getSiteLanguage(c);
115
+ * // Returns: (DB: SITE_LANGUAGE) ?? c.env.SITE_LANGUAGE ?? "en"
116
+ * ```
117
+ */
118
+ export async function getSiteLanguage(c: Context): Promise<string> {
119
+ return getConfig(c, "SITE_LANGUAGE");
120
+ }
@@ -21,11 +21,13 @@ export const RESERVED_PATHS = [
21
21
  "quotes",
22
22
  "media",
23
23
  "pages",
24
+ "reset",
24
25
  "p",
25
26
  "c",
26
27
  "static",
27
28
  "assets",
28
29
  "health",
30
+ "appearance",
29
31
  ] as const;
30
32
 
31
33
  export type ReservedPath = (typeof RESERVED_PATHS)[number];
@@ -52,6 +54,7 @@ export const SETTINGS_KEYS = {
52
54
  SITE_DESCRIPTION: "SITE_DESCRIPTION",
53
55
  SITE_LANGUAGE: "SITE_LANGUAGE",
54
56
  THEME: "THEME",
57
+ PASSWORD_RESET_TOKEN: "PASSWORD_RESET_TOKEN",
55
58
  } as const;
56
59
 
57
60
  export type SettingsKey = (typeof SETTINGS_KEYS)[keyof typeof SETTINGS_KEYS];
package/src/lib/sse.ts CHANGED
@@ -106,6 +106,22 @@ export interface SSEStream {
106
106
  * ```
107
107
  */
108
108
  remove(selector: string): void;
109
+
110
+ /**
111
+ * Show a toast notification
112
+ *
113
+ * Appends a toast element to `#toast-container` with auto-dismiss after 3s.
114
+ *
115
+ * @param message - The message to display
116
+ * @param type - Toast type: "success" (default) or "error"
117
+ *
118
+ * @example
119
+ * ```ts
120
+ * await stream.toast("Settings saved successfully.");
121
+ * await stream.toast("Something went wrong.", "error");
122
+ * ```
123
+ */
124
+ toast(message: string, type?: "success" | "error"): void;
109
125
  }
110
126
 
111
127
  /**
@@ -216,6 +232,28 @@ export function sse(
216
232
  ),
217
233
  );
218
234
  },
235
+
236
+ toast(message, type = "success") {
237
+ const cls = type === "error" ? "toast-error" : "toast-success";
238
+ const icon =
239
+ type === "error"
240
+ ? '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6M9 9l6 6"/></svg>'
241
+ : '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>';
242
+ const closeBtn = `<button class="toast-close" data-on:click="el.closest('.toast').classList.add('toast-out'); el.closest('.toast').addEventListener('animationend', () => el.closest('.toast').remove())"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path d="M18 6 6 18M6 6l12 12"/></svg></button>`;
243
+ const escapedMessage = message
244
+ .replace(/&/g, "&amp;")
245
+ .replace(/</g, "&lt;")
246
+ .replace(/>/g, "&gt;");
247
+ const html = `<div class="toast ${cls}" data-init="setTimeout(() => { el.classList.add('toast-out'); el.addEventListener('animationend', () => el.remove()) }, 3000)">${icon}<span>${escapedMessage}</span>${closeBtn}</div>`;
248
+ const dataLines: string[] = [
249
+ `elements ${html}`,
250
+ "mode append",
251
+ "selector #toast-container",
252
+ ];
253
+ controller.enqueue(
254
+ encoder.encode(formatEvent("datastar-patch-elements", dataLines)),
255
+ );
256
+ },
219
257
  };
220
258
 
221
259
  await handler(stream);
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Theme Resolution Helpers
3
+ *
4
+ * Resolves the active color theme and builds CSS for injection into `<head>`.
5
+ */
6
+
7
+ import type { ColorTheme } from "../theme/color-themes.js";
8
+ import { BUILTIN_COLOR_THEMES } from "../theme/color-themes.js";
9
+ import type { JantConfig } from "../types.js";
10
+
11
+ /**
12
+ * Get the list of available color themes.
13
+ *
14
+ * Returns `config.theme.colorThemes` if provided, otherwise the built-in list.
15
+ *
16
+ * @param config - The Jant configuration
17
+ * @returns Array of available color themes
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * const themes = getAvailableThemes(c.var.config);
22
+ * ```
23
+ */
24
+ export function getAvailableThemes(config: JantConfig): ColorTheme[] {
25
+ return config.theme?.colorThemes ?? BUILTIN_COLOR_THEMES;
26
+ }
27
+
28
+ /**
29
+ * Build a `<style>` CSS string from a color theme and optional cssVariables overlay.
30
+ *
31
+ * Priority (lowest → highest):
32
+ * BaseCoat defaults → selected theme → cssVariables
33
+ *
34
+ * @param theme - The active color theme (undefined = no theme overrides)
35
+ * @param cssVariables - Extra CSS variable overrides from `createApp({ theme: { cssVariables } })`
36
+ * @returns CSS string to inject in `<head>`, or empty string if nothing to inject
37
+ *
38
+ * Uses `:root:root` and `:root.dark` selectors for higher specificity than
39
+ * BaseCoat defaults (`:root` and `.dark`). This ensures theme overrides win
40
+ * regardless of source order — important because Vite dev mode injects CSS
41
+ * as `<style>` tags after the theme `<style>`.
42
+ *
43
+ * @example
44
+ * ```typescript
45
+ * const css = buildThemeStyle(blueTheme, { "--radius": "0.5rem" });
46
+ * // => ":root:root { --primary: oklch(...); ... }\n:root.dark { ... }"
47
+ * ```
48
+ */
49
+ export function buildThemeStyle(
50
+ theme: ColorTheme | undefined,
51
+ cssVariables?: Record<string, string>,
52
+ ): string {
53
+ const lightVars: Record<string, string> = {
54
+ ...(theme?.light ?? {}),
55
+ ...(cssVariables ?? {}),
56
+ };
57
+ const darkVars: Record<string, string> = {
58
+ ...(theme?.dark ?? {}),
59
+ ...(cssVariables ?? {}),
60
+ };
61
+
62
+ const hasLight = Object.keys(lightVars).length > 0;
63
+ const hasDark = Object.keys(darkVars).length > 0;
64
+
65
+ if (!hasLight && !hasDark) return "";
66
+
67
+ const parts: string[] = [];
68
+
69
+ if (hasLight) {
70
+ const declarations = Object.entries(lightVars)
71
+ .map(([k, v]) => ` ${k}: ${v};`)
72
+ .join("\n");
73
+ // :root:root has specificity (0,0,2) > BaseCoat's :root (0,0,1)
74
+ parts.push(`:root:root {\n${declarations}\n}`);
75
+ }
76
+
77
+ if (hasDark) {
78
+ const declarations = Object.entries(darkVars)
79
+ .map(([k, v]) => ` ${k}: ${v};`)
80
+ .join("\n");
81
+ // :root.dark has specificity (0,1,1) > BaseCoat's .dark (0,1,0)
82
+ parts.push(`:root.dark {\n${declarations}\n}`);
83
+ }
84
+
85
+ return parts.join("\n");
86
+ }
package/src/preset.css CHANGED
@@ -12,4 +12,13 @@
12
12
 
13
13
  @theme {
14
14
  --radius-default: 0.5rem;
15
+ --color-success: var(--success);
16
+ }
17
+
18
+ :root {
19
+ --success: oklch(0.518 0.16 145.071);
20
+ }
21
+
22
+ .dark {
23
+ --success: oklch(0.627 0.194 149.214);
15
24
  }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Dashboard Appearance Routes
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import { useLingui } from "@lingui/react/macro";
7
+ import type { Bindings } from "../../types.js";
8
+ import type { AppVariables } from "../../app.js";
9
+ import { DashLayout } from "../../theme/layouts/index.js";
10
+ import { sse } from "../../lib/sse.js";
11
+ import { getSiteName } from "../../lib/config.js";
12
+ import { SETTINGS_KEYS } from "../../lib/constants.js";
13
+ import { getAvailableThemes } from "../../lib/theme.js";
14
+ import type { ColorTheme } from "../../theme/color-themes.js";
15
+
16
+ type Env = { Bindings: Bindings; Variables: AppVariables };
17
+
18
+ export const appearanceRoutes = new Hono<Env>();
19
+
20
+ function ThemeCard({
21
+ theme,
22
+ selected,
23
+ }: {
24
+ theme: ColorTheme;
25
+ selected: boolean;
26
+ }) {
27
+ const expr = `$theme === '${theme.id}'`;
28
+ const { preview } = theme;
29
+
30
+ return (
31
+ <label
32
+ class={`block cursor-pointer rounded-lg border overflow-hidden transition-colors ${selected ? "border-primary" : "border-border"}`}
33
+ data-class:border-primary={expr}
34
+ data-class:border-border={`$theme !== '${theme.id}'`}
35
+ >
36
+ <div class="grid grid-cols-2">
37
+ <div
38
+ class="p-5"
39
+ style={`background-color:${preview.lightBg};color:${preview.lightText}`}
40
+ >
41
+ <input
42
+ type="radio"
43
+ name="theme"
44
+ value={theme.id}
45
+ data-bind="theme"
46
+ checked={selected || undefined}
47
+ class="mb-1"
48
+ />
49
+ <h3 class="font-bold text-lg">{theme.name}</h3>
50
+ <p class="text-sm mt-2 leading-relaxed">
51
+ This is the {theme.name} theme in light mode. Links{" "}
52
+ <a
53
+ tabIndex={-1}
54
+ class="underline"
55
+ style={`color:${preview.lightLink}`}
56
+ >
57
+ look like this
58
+ </a>
59
+ . We'll show the correct light or dark mode based on your visitor's
60
+ settings.
61
+ </p>
62
+ </div>
63
+ <div
64
+ class="p-5"
65
+ style={`background-color:${preview.darkBg};color:${preview.darkText}`}
66
+ >
67
+ <h3 class="font-bold text-lg">{theme.name}</h3>
68
+ <p class="text-sm mt-2 leading-relaxed">
69
+ This is the {theme.name} theme in dark mode. Links{" "}
70
+ <a
71
+ tabIndex={-1}
72
+ class="underline"
73
+ style={`color:${preview.darkLink}`}
74
+ >
75
+ look like this
76
+ </a>
77
+ . We'll show the correct light or dark mode based on your visitor's
78
+ settings.
79
+ </p>
80
+ </div>
81
+ </div>
82
+ </label>
83
+ );
84
+ }
85
+
86
+ function AppearanceContent({
87
+ themes,
88
+ currentThemeId,
89
+ }: {
90
+ themes: ColorTheme[];
91
+ currentThemeId: string;
92
+ }) {
93
+ const { t } = useLingui();
94
+
95
+ const signals = JSON.stringify({ theme: currentThemeId }).replace(
96
+ /</g,
97
+ "\\u003c",
98
+ );
99
+
100
+ return (
101
+ <div
102
+ data-signals={signals}
103
+ data-on:change="@post('/dash/appearance')"
104
+ class="max-w-3xl"
105
+ >
106
+ <fieldset>
107
+ <legend class="text-lg font-semibold">
108
+ {t({
109
+ message: "Color theme",
110
+ comment: "@context: Appearance settings heading",
111
+ })}
112
+ </legend>
113
+ <p class="text-sm text-muted-foreground mb-4">
114
+ {t({
115
+ message:
116
+ "This will theme both your site and your dashboard. All color themes support dark mode.",
117
+ comment: "@context: Appearance settings description",
118
+ })}
119
+ </p>
120
+
121
+ <div class="flex flex-col gap-4">
122
+ {themes.map((theme) => (
123
+ <ThemeCard
124
+ key={theme.id}
125
+ theme={theme}
126
+ selected={theme.id === currentThemeId}
127
+ />
128
+ ))}
129
+ </div>
130
+ </fieldset>
131
+ </div>
132
+ );
133
+ }
134
+
135
+ // Appearance page
136
+ appearanceRoutes.get("/", async (c) => {
137
+ const { settings } = c.var.services;
138
+ const siteName = await getSiteName(c);
139
+ const currentThemeId = (await settings.get(SETTINGS_KEYS.THEME)) ?? "default";
140
+ const themes = getAvailableThemes(c.var.config);
141
+ const saved = c.req.query("saved") !== undefined;
142
+
143
+ return c.html(
144
+ <DashLayout
145
+ c={c}
146
+ title="Appearance"
147
+ siteName={siteName}
148
+ currentPath="/dash/appearance"
149
+ toast={saved ? { message: "Theme saved successfully." } : undefined}
150
+ >
151
+ <AppearanceContent themes={themes} currentThemeId={currentThemeId} />
152
+ </DashLayout>,
153
+ );
154
+ });
155
+
156
+ // Save theme
157
+ appearanceRoutes.post("/", async (c) => {
158
+ const body = await c.req.json<{ theme: string }>();
159
+ const { settings } = c.var.services;
160
+ const themes = getAvailableThemes(c.var.config);
161
+
162
+ // Validate theme ID
163
+ const validTheme = themes.find((t) => t.id === body.theme);
164
+ if (!validTheme) {
165
+ return sse(c, async (stream) => {
166
+ await stream.toast("Invalid theme selected.", "error");
167
+ });
168
+ }
169
+
170
+ if (validTheme.id === "default") {
171
+ await settings.remove(SETTINGS_KEYS.THEME);
172
+ } else {
173
+ await settings.set(SETTINGS_KEYS.THEME, validTheme.id);
174
+ }
175
+
176
+ // Full page reload to apply the new theme CSS
177
+ return sse(c, async (stream) => {
178
+ await stream.redirect("/dash/appearance?saved");
179
+ });
180
+ });
@@ -1,3 +1,4 @@
1
+ import { getSiteName } from "../../lib/config.js";
1
2
  /**
2
3
  * Dashboard Collections Routes
3
4
  */
@@ -363,7 +364,7 @@ function EditCollectionContent({ collection }: { collection: Collection }) {
363
364
 
364
365
  // List collections
365
366
  collectionsRoutes.get("/", async (c) => {
366
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
367
+ const siteName = await getSiteName(c);
367
368
  const collections = await c.var.services.collections.list();
368
369
 
369
370
  return c.html(
@@ -380,7 +381,7 @@ collectionsRoutes.get("/", async (c) => {
380
381
 
381
382
  // New collection form
382
383
  collectionsRoutes.get("/new", async (c) => {
383
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
384
+ const siteName = await getSiteName(c);
384
385
 
385
386
  return c.html(
386
387
  <DashLayout
@@ -422,7 +423,7 @@ collectionsRoutes.get("/:id", async (c) => {
422
423
  if (!collection) return c.notFound();
423
424
 
424
425
  const posts = await c.var.services.collections.getPosts(id);
425
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
426
+ const siteName = await getSiteName(c);
426
427
 
427
428
  return c.html(
428
429
  <DashLayout
@@ -444,7 +445,7 @@ collectionsRoutes.get("/:id/edit", async (c) => {
444
445
  const collection = await c.var.services.collections.getById(id);
445
446
  if (!collection) return c.notFound();
446
447
 
447
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
448
+ const siteName = await getSiteName(c);
448
449
 
449
450
  return c.html(
450
451
  <DashLayout
@@ -9,6 +9,7 @@ import { Trans, useLingui } from "@lingui/react/macro";
9
9
  import type { Bindings } from "../../types.js";
10
10
  import type { AppVariables } from "../../app.js";
11
11
  import { DashLayout } from "../../theme/layouts/index.js";
12
+ import { getSiteName } from "../../lib/config.js";
12
13
 
13
14
  type Env = { Bindings: Bindings; Variables: AppVariables };
14
15
 
@@ -86,7 +87,7 @@ function DashboardContent({
86
87
  }
87
88
 
88
89
  dashIndexRoutes.get("/", async (c) => {
89
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
90
+ const siteName = await getSiteName(c);
90
91
 
91
92
  // Get some stats
92
93
  const allPosts = await c.var.services.posts.list({ limit: 1000 });
@@ -1,3 +1,4 @@
1
+ import { getSiteName } from "../../lib/config.js";
1
2
  /**
2
3
  * Dashboard Media Routes
3
4
  *
@@ -544,7 +545,7 @@ function ViewMediaContent({
544
545
  // List media
545
546
  mediaRoutes.get("/", async (c) => {
546
547
  const mediaList = await c.var.services.media.list(100);
547
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
548
+ const siteName = await getSiteName(c);
548
549
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
549
550
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
550
551
 
@@ -570,7 +571,7 @@ mediaRoutes.get("/:id", async (c) => {
570
571
  const media = await c.var.services.media.getById(id);
571
572
  if (!media) return c.notFound();
572
573
 
573
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
574
+ const siteName = await getSiteName(c);
574
575
  const r2PublicUrl = c.env.R2_PUBLIC_URL;
575
576
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
576
577
 
@@ -1,3 +1,4 @@
1
+ import { getSiteName } from "../../lib/config.js";
1
2
  /**
2
3
  * Dashboard Pages Routes
3
4
  *
@@ -189,7 +190,7 @@ pagesRoutes.get("/", async (c) => {
189
190
  visibility: ["unlisted", "draft"],
190
191
  limit: 100,
191
192
  });
192
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
193
+ const siteName = await getSiteName(c);
193
194
 
194
195
  return c.html(
195
196
  <DashLayout
@@ -205,7 +206,7 @@ pagesRoutes.get("/", async (c) => {
205
206
 
206
207
  // New page form
207
208
  pagesRoutes.get("/new", async (c) => {
208
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
209
+ const siteName = await getSiteName(c);
209
210
 
210
211
  return c.html(
211
212
  <DashLayout
@@ -249,7 +250,7 @@ pagesRoutes.get("/:id", async (c) => {
249
250
  const page = await c.var.services.posts.getById(id);
250
251
  if (!page || page.type !== "page") return c.notFound();
251
252
 
252
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
253
+ const siteName = await getSiteName(c);
253
254
 
254
255
  return c.html(
255
256
  <DashLayout
@@ -271,7 +272,7 @@ pagesRoutes.get("/:id/edit", async (c) => {
271
272
  const page = await c.var.services.posts.getById(id);
272
273
  if (!page || page.type !== "page") return c.notFound();
273
274
 
274
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
275
+ const siteName = await getSiteName(c);
275
276
 
276
277
  return c.html(
277
278
  <DashLayout