@jant/core 0.2.17 → 0.2.19

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 (99) hide show
  1. package/dist/app.d.ts +1 -0
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +307 -137
  4. package/dist/client.js +1 -0
  5. package/dist/i18n/context.d.ts +2 -2
  6. package/dist/i18n/context.js +1 -1
  7. package/dist/i18n/i18n.d.ts +1 -1
  8. package/dist/i18n/i18n.js +1 -1
  9. package/dist/i18n/index.d.ts +1 -1
  10. package/dist/i18n/index.js +1 -1
  11. package/dist/i18n/locales/en.d.ts.map +1 -1
  12. package/dist/i18n/locales/en.js +1 -1
  13. package/dist/i18n/locales/zh-Hans.d.ts.map +1 -1
  14. package/dist/i18n/locales/zh-Hans.js +1 -1
  15. package/dist/i18n/locales/zh-Hant.d.ts.map +1 -1
  16. package/dist/i18n/locales/zh-Hant.js +1 -1
  17. package/dist/lib/config.d.ts +44 -10
  18. package/dist/lib/config.d.ts.map +1 -1
  19. package/dist/lib/config.js +69 -44
  20. package/dist/lib/constants.d.ts +2 -1
  21. package/dist/lib/constants.d.ts.map +1 -1
  22. package/dist/lib/constants.js +5 -2
  23. package/dist/lib/image-processor.js +0 -4
  24. package/dist/lib/media-upload.js +104 -0
  25. package/dist/lib/sse.d.ts +82 -13
  26. package/dist/lib/sse.d.ts.map +1 -1
  27. package/dist/lib/sse.js +115 -17
  28. package/dist/lib/theme.d.ts +44 -0
  29. package/dist/lib/theme.d.ts.map +1 -0
  30. package/dist/lib/theme.js +65 -0
  31. package/dist/routes/api/upload.js +16 -18
  32. package/dist/routes/dash/appearance.d.ts +13 -0
  33. package/dist/routes/dash/appearance.d.ts.map +1 -0
  34. package/dist/routes/dash/appearance.js +160 -0
  35. package/dist/routes/dash/collections.js +5 -13
  36. package/dist/routes/dash/media.js +17 -167
  37. package/dist/routes/dash/pages.js +4 -10
  38. package/dist/routes/dash/posts.js +4 -10
  39. package/dist/routes/dash/redirects.js +3 -7
  40. package/dist/routes/dash/settings.d.ts.map +1 -1
  41. package/dist/routes/dash/settings.js +52 -42
  42. package/dist/services/settings.d.ts +1 -0
  43. package/dist/services/settings.d.ts.map +1 -1
  44. package/dist/services/settings.js +3 -0
  45. package/dist/theme/color-themes.d.ts +30 -0
  46. package/dist/theme/color-themes.d.ts.map +1 -0
  47. package/dist/theme/color-themes.js +268 -0
  48. package/dist/theme/layouts/BaseLayout.d.ts +5 -0
  49. package/dist/theme/layouts/BaseLayout.d.ts.map +1 -1
  50. package/dist/theme/layouts/BaseLayout.js +70 -3
  51. package/dist/theme/layouts/DashLayout.d.ts +2 -0
  52. package/dist/theme/layouts/DashLayout.d.ts.map +1 -1
  53. package/dist/theme/layouts/DashLayout.js +11 -1
  54. package/dist/theme/layouts/index.d.ts +1 -1
  55. package/dist/theme/layouts/index.d.ts.map +1 -1
  56. package/dist/types.d.ts +53 -1
  57. package/dist/types.d.ts.map +1 -1
  58. package/dist/types.js +52 -0
  59. package/package.json +1 -1
  60. package/src/app.tsx +260 -81
  61. package/src/client.ts +1 -0
  62. package/src/db/migrations/{0000_solid_moon_knight.sql → 0000_square_wallflower.sql} +3 -3
  63. package/src/db/migrations/meta/0000_snapshot.json +9 -9
  64. package/src/db/migrations/meta/_journal.json +2 -30
  65. package/src/i18n/context.tsx +2 -2
  66. package/src/i18n/i18n.ts +1 -1
  67. package/src/i18n/index.ts +1 -1
  68. package/src/i18n/locales/en.po +328 -252
  69. package/src/i18n/locales/en.ts +1 -1
  70. package/src/i18n/locales/zh-Hans.po +315 -278
  71. package/src/i18n/locales/zh-Hans.ts +1 -1
  72. package/src/i18n/locales/zh-Hant.po +315 -278
  73. package/src/i18n/locales/zh-Hant.ts +1 -1
  74. package/src/lib/config.ts +73 -47
  75. package/src/lib/constants.ts +3 -0
  76. package/src/lib/image-processor.ts +0 -7
  77. package/src/lib/media-upload.ts +148 -0
  78. package/src/lib/sse.ts +156 -16
  79. package/src/lib/theme.ts +86 -0
  80. package/src/preset.css +9 -0
  81. package/src/routes/api/upload.ts +12 -18
  82. package/src/routes/dash/appearance.tsx +176 -0
  83. package/src/routes/dash/collections.tsx +5 -13
  84. package/src/routes/dash/media.tsx +16 -165
  85. package/src/routes/dash/pages.tsx +4 -10
  86. package/src/routes/dash/posts.tsx +4 -10
  87. package/src/routes/dash/redirects.tsx +3 -7
  88. package/src/routes/dash/settings.tsx +71 -55
  89. package/src/services/settings.ts +5 -0
  90. package/src/styles/components.css +93 -0
  91. package/src/theme/color-themes.ts +321 -0
  92. package/src/theme/layouts/BaseLayout.tsx +61 -1
  93. package/src/theme/layouts/DashLayout.tsx +14 -3
  94. package/src/theme/layouts/index.ts +5 -1
  95. package/src/types.ts +62 -1
  96. package/src/db/migrations/0001_add_search_fts.sql +0 -40
  97. package/src/db/migrations/0002_collection_path.sql +0 -2
  98. package/src/db/migrations/0003_collection_path_nullable.sql +0 -21
  99. 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/lib/config.ts CHANGED
@@ -1,44 +1,94 @@
1
1
  /**
2
- * Unified Configuration Helpers
2
+ * Unified Configuration System
3
3
  *
4
- * Configuration priority: Environment Variables > Database > Default Values
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
5
7
  *
6
- * This follows the 12-factor app methodology where configuration is stored
7
- * in environment variables, while allowing runtime overrides via database.
8
+ * All configuration fields are defined in CONFIG_FIELDS (types.ts).
8
9
  */
9
10
 
10
11
  import type { Context } from "hono";
12
+ import { CONFIG_FIELDS, type ConfigKey } from "../types.js";
11
13
 
12
14
  /**
13
- * Get site name with fallback chain: ENV > DB > Default
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.
14
17
  *
15
18
  * @param c - Hono context
16
- * @returns Site name
19
+ * @param key - Configuration key from CONFIG_FIELDS
20
+ * @returns Fallback value from environment or default
17
21
  *
18
22
  * @example
19
23
  * ```typescript
20
- * const siteName = await getSiteName(c);
21
- * // Returns: c.env.SITE_NAME ?? (DB: SITE_NAME) ?? "Jant"
24
+ * const placeholder = getConfigFallback(c, "SITE_NAME");
25
+ * // Returns: c.env.SITE_NAME ?? "Jant"
22
26
  * ```
23
27
  */
24
- export async function getSiteName(c: Context): Promise<string> {
25
- // 1. Check environment variable
26
- if (c.env.SITE_NAME) {
27
- return c.env.SITE_NAME;
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
+ }
28
61
  }
29
62
 
30
- // 2. Check database setting
31
- const dbValue = await c.var.services.settings.get("SITE_NAME");
32
- if (dbValue) {
33
- return dbValue;
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;
34
68
  }
35
69
 
36
70
  // 3. Default value
37
- return "Jant";
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");
38
88
  }
39
89
 
40
90
  /**
41
- * Get site description with fallback chain: ENV > DB > Default
91
+ * Get site description with fallback chain: DB > ENV > Default
42
92
  *
43
93
  * @param c - Hono context
44
94
  * @returns Site description
@@ -46,27 +96,15 @@ export async function getSiteName(c: Context): Promise<string> {
46
96
  * @example
47
97
  * ```typescript
48
98
  * const description = await getSiteDescription(c);
49
- * // Returns: c.env.SITE_DESCRIPTION ?? (DB: SITE_DESCRIPTION) ?? "A microblog powered by Jant"
99
+ * // Returns: (DB: SITE_DESCRIPTION) ?? c.env.SITE_DESCRIPTION ?? "A microblog powered by Jant"
50
100
  * ```
51
101
  */
52
102
  export async function getSiteDescription(c: Context): Promise<string> {
53
- // 1. Check environment variable
54
- if (c.env.SITE_DESCRIPTION) {
55
- return c.env.SITE_DESCRIPTION;
56
- }
57
-
58
- // 2. Check database setting
59
- const dbValue = await c.var.services.settings.get("SITE_DESCRIPTION");
60
- if (dbValue) {
61
- return dbValue;
62
- }
63
-
64
- // 3. Default value
65
- return "A microblog powered by Jant";
103
+ return getConfig(c, "SITE_DESCRIPTION");
66
104
  }
67
105
 
68
106
  /**
69
- * Get site language with fallback chain: ENV > DB > Default
107
+ * Get site language with fallback chain: DB > ENV > Default
70
108
  *
71
109
  * @param c - Hono context
72
110
  * @returns Site language code
@@ -74,21 +112,9 @@ export async function getSiteDescription(c: Context): Promise<string> {
74
112
  * @example
75
113
  * ```typescript
76
114
  * const lang = await getSiteLanguage(c);
77
- * // Returns: c.env.SITE_LANGUAGE ?? (DB: SITE_LANGUAGE) ?? "en"
115
+ * // Returns: (DB: SITE_LANGUAGE) ?? c.env.SITE_LANGUAGE ?? "en"
78
116
  * ```
79
117
  */
80
118
  export async function getSiteLanguage(c: Context): Promise<string> {
81
- // 1. Check environment variable
82
- if (c.env.SITE_LANGUAGE) {
83
- return c.env.SITE_LANGUAGE;
84
- }
85
-
86
- // 2. Check database setting
87
- const dbValue = await c.var.services.settings.get("SITE_LANGUAGE");
88
- if (dbValue) {
89
- return dbValue;
90
- }
91
-
92
- // 3. Default value
93
- return "en";
119
+ return getConfig(c, "SITE_LANGUAGE");
94
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];
@@ -217,10 +217,3 @@ async function processToFile(
217
217
  }
218
218
 
219
219
  export const ImageProcessor = { process, processToFile };
220
-
221
- // Expose globally for inline scripts
222
- if (typeof window !== "undefined") {
223
- (
224
- window as unknown as { ImageProcessor: typeof ImageProcessor }
225
- ).ImageProcessor = ImageProcessor;
226
- }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Client-side Media Upload Handler
3
+ *
4
+ * Handles file upload flow:
5
+ * 1. User selects file via [data-media-upload] input
6
+ * 2. Creates placeholder in grid with spinner
7
+ * 3. Processes image via ImageProcessor (resize/convert to WebP)
8
+ * 4. Sets processed file on hidden Datastar form via DataTransfer API
9
+ * 5. Triggers form.requestSubmit() — Datastar handles upload + SSE response
10
+ */
11
+
12
+ import { ImageProcessor } from "./image-processor.js";
13
+
14
+ /**
15
+ * Format file size for display
16
+ */
17
+ function formatFileSize(bytes: number): string {
18
+ if (bytes < 1024) return `${bytes} B`;
19
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
20
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
21
+ }
22
+
23
+ /**
24
+ * Ensure the media grid exists, removing empty state if needed
25
+ */
26
+ function ensureGridExists(): HTMLElement {
27
+ let grid = document.getElementById("media-grid");
28
+ if (grid) return grid;
29
+
30
+ document.getElementById("empty-state")?.remove();
31
+
32
+ grid = document.createElement("div");
33
+ grid.id = "media-grid";
34
+ grid.className =
35
+ "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4";
36
+ document.getElementById("media-content")?.appendChild(grid);
37
+ return grid;
38
+ }
39
+
40
+ /**
41
+ * Create a placeholder card with spinner in the media grid
42
+ */
43
+ function createPlaceholder(
44
+ fileName: string,
45
+ fileSize: number,
46
+ statusText: string,
47
+ ): HTMLElement {
48
+ const placeholder = document.createElement("div");
49
+ placeholder.id = "upload-placeholder";
50
+ placeholder.className = "group relative";
51
+ placeholder.innerHTML = `
52
+ <div class="aspect-square bg-muted rounded-lg overflow-hidden border flex items-center justify-center">
53
+ <div class="text-center px-2">
54
+ <svg class="animate-spin h-6 w-6 text-muted-foreground mx-auto mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
55
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
56
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
57
+ </svg>
58
+ <span id="upload-status" class="text-xs text-muted-foreground">${statusText}</span>
59
+ </div>
60
+ </div>
61
+ <div class="mt-2 text-xs truncate" title="${fileName}">${fileName}</div>
62
+ <div class="text-xs text-muted-foreground">${formatFileSize(fileSize)}</div>
63
+ `;
64
+ return placeholder;
65
+ }
66
+
67
+ /**
68
+ * Replace placeholder content with an error message
69
+ */
70
+ function showPlaceholderError(
71
+ placeholder: HTMLElement,
72
+ fileName: string,
73
+ errorMessage: string,
74
+ ): void {
75
+ placeholder.innerHTML = `
76
+ <div class="aspect-square bg-destructive/10 rounded-lg overflow-hidden border border-destructive flex items-center justify-center">
77
+ <div class="text-center px-2">
78
+ <span class="text-xs text-destructive">${errorMessage}</span>
79
+ </div>
80
+ </div>
81
+ <div class="mt-2 text-xs truncate text-destructive">${fileName}</div>
82
+ <button type="button" class="text-xs text-muted-foreground hover:underline" onclick="this.closest('.group').remove()">Remove</button>
83
+ `;
84
+ }
85
+
86
+ /**
87
+ * Handle the upload flow for a selected file
88
+ */
89
+ async function handleUpload(
90
+ input: HTMLInputElement,
91
+ file: File,
92
+ ): Promise<void> {
93
+ const processingText = input.dataset.textProcessing || "Processing...";
94
+ const uploadingText = input.dataset.textUploading || "Uploading...";
95
+ const errorText =
96
+ input.dataset.textError || "Upload failed. Please try again.";
97
+
98
+ const grid = ensureGridExists();
99
+ const placeholder = createPlaceholder(file.name, file.size, processingText);
100
+ grid.prepend(placeholder);
101
+
102
+ try {
103
+ // Process image client-side (resize, convert to WebP)
104
+ const processed = await ImageProcessor.processToFile(file);
105
+
106
+ // Update status
107
+ const statusEl = document.getElementById("upload-status");
108
+ if (statusEl) statusEl.textContent = uploadingText;
109
+
110
+ // Set processed file on hidden form input via DataTransfer API
111
+ const formInput = document.getElementById(
112
+ "upload-file-input",
113
+ ) as HTMLInputElement | null;
114
+ const form = document.getElementById(
115
+ "upload-form",
116
+ ) as HTMLFormElement | null;
117
+ if (!formInput || !form) throw new Error("Upload form not found");
118
+
119
+ const dt = new DataTransfer();
120
+ dt.items.add(processed);
121
+ formInput.files = dt.files;
122
+
123
+ // Trigger Datastar-intercepted form submission
124
+ form.requestSubmit();
125
+ } catch (err) {
126
+ const message = err instanceof Error ? err.message : errorText;
127
+ showPlaceholderError(placeholder, file.name, message);
128
+ }
129
+
130
+ // Reset file input so the same file can be re-selected
131
+ input.value = "";
132
+ }
133
+
134
+ /**
135
+ * Initialize media upload via event delegation
136
+ */
137
+ function initMediaUpload(): void {
138
+ document.addEventListener("change", (e) => {
139
+ const input = (e.target as HTMLElement).closest(
140
+ "[data-media-upload]",
141
+ ) as HTMLInputElement | null;
142
+ if (!input?.files?.[0]) return;
143
+
144
+ handleUpload(input, input.files[0]);
145
+ });
146
+ }
147
+
148
+ initMediaUpload();
package/src/lib/sse.ts CHANGED
@@ -1,20 +1,21 @@
1
1
  /**
2
- * Server-Sent Events (SSE) utilities for Datastar v1.0.0-RC.7
2
+ * Datastar response utilities for v1.0.0-RC.7
3
3
  *
4
- * Generates SSE events compatible with the Datastar client's expected format.
4
+ * Provides both SSE (multi-event) and plain HTTP (single-event) response helpers.
5
5
  *
6
- * @see https://data-star.dev/
6
+ * **Non-SSE helpers** (preferred for single operations):
7
+ * - `dsRedirect(url)` — redirect via text/html
8
+ * - `dsToast(message, type)` — toast notification via text/html
9
+ * - `dsSignals(signals)` — signal patch via application/json
7
10
  *
8
- * @example
9
- * ```ts
10
- * app.post("/api/example", (c) => {
11
- * return sse(c, async (stream) => {
12
- * await stream.patchSignals({ loading: false });
13
- * await stream.patchElements('<div id="result">Done!</div>');
14
- * await stream.redirect("/success");
15
- * });
16
- * });
17
- * ```
11
+ * **SSE** (for multiple operations in one response):
12
+ * - `sse(c, handler)` — streaming SSE with full stream API
13
+ *
14
+ * Datastar auto-detects response type by Content-Type:
15
+ * - `text/html` dispatches as `datastar-patch-elements`
16
+ * - `application/json` → dispatches as `datastar-patch-signals`
17
+ *
18
+ * @see https://data-star.dev/
18
19
  */
19
20
 
20
21
  import type { Context } from "hono";
@@ -106,8 +107,53 @@ export interface SSEStream {
106
107
  * ```
107
108
  */
108
109
  remove(selector: string): void;
110
+
111
+ /**
112
+ * Show a toast notification
113
+ *
114
+ * Appends a toast element to `#toast-container` with auto-dismiss after 3s.
115
+ *
116
+ * @param message - The message to display
117
+ * @param type - Toast type: "success" (default) or "error"
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * await stream.toast("Settings saved successfully.");
122
+ * await stream.toast("Something went wrong.", "error");
123
+ * ```
124
+ */
125
+ toast(message: string, type?: "success" | "error"): void;
109
126
  }
110
127
 
128
+ // ---------------------------------------------------------------------------
129
+ // Shared internal helpers (used by both SSE and non-SSE response builders)
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /** Build the redirect script tag for Datastar patch-elements */
133
+ function buildRedirectScript(url: string): string {
134
+ const escapedUrl = url.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
135
+ return `<script data-effect="el.remove()">window.location.href='${escapedUrl}'</script>`;
136
+ }
137
+
138
+ /** Build a toast notification HTML element */
139
+ function buildToastHtml(message: string, type: "success" | "error"): string {
140
+ const cls = type === "error" ? "toast-error" : "toast-success";
141
+ const icon =
142
+ type === "error"
143
+ ? '<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>'
144
+ : '<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>';
145
+ 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>`;
146
+ const escapedMessage = message
147
+ .replace(/&/g, "&amp;")
148
+ .replace(/</g, "&lt;")
149
+ .replace(/>/g, "&gt;");
150
+ return `<div class="toast ${cls}" data-init="setTimeout(() => { el.classList.add('toast-out'); el.addEventListener('animationend', () => el.remove()) }, 3000)">${icon}<span>${escapedMessage}</span>${closeBtn}</div>`;
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // SSE helpers
155
+ // ---------------------------------------------------------------------------
156
+
111
157
  /**
112
158
  * Format a single SSE event string
113
159
  *
@@ -193,10 +239,8 @@ export function sse(
193
239
  },
194
240
 
195
241
  redirect(url) {
196
- const escapedUrl = url.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
197
- const script = `<script data-effect="el.remove()">window.location.href='${escapedUrl}'</script>`;
198
242
  const dataLines: string[] = [
199
- `elements ${script}`,
243
+ `elements ${buildRedirectScript(url)}`,
200
244
  "mode append",
201
245
  "selector body",
202
246
  ];
@@ -216,6 +260,17 @@ export function sse(
216
260
  ),
217
261
  );
218
262
  },
263
+
264
+ toast(message, type = "success") {
265
+ const dataLines: string[] = [
266
+ `elements ${buildToastHtml(message, type)}`,
267
+ "mode append",
268
+ "selector #toast-container",
269
+ ];
270
+ controller.enqueue(
271
+ encoder.encode(formatEvent("datastar-patch-elements", dataLines)),
272
+ );
273
+ },
219
274
  };
220
275
 
221
276
  await handler(stream);
@@ -232,3 +287,88 @@ export function sse(
232
287
 
233
288
  return new Response(body, { headers });
234
289
  }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Non-SSE Datastar helpers (for single-operation responses)
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /**
296
+ * Datastar redirect via text/html
297
+ *
298
+ * Returns a plain HTML response that Datastar dispatches as `datastar-patch-elements`.
299
+ * Use instead of `sse()` when the only action is a redirect.
300
+ *
301
+ * @param url - The URL to redirect to
302
+ * @param options - Optional extra headers (e.g. Set-Cookie for auth)
303
+ * @returns Response with text/html content-type
304
+ *
305
+ * @example
306
+ * ```ts
307
+ * return dsRedirect("/dash/posts");
308
+ *
309
+ * // With cookie forwarding (for auth)
310
+ * return dsRedirect("/dash", { headers: { "Set-Cookie": cookie } });
311
+ * ```
312
+ */
313
+ export function dsRedirect(
314
+ url: string,
315
+ options?: { headers?: Record<string, string> },
316
+ ): Response {
317
+ return new Response(buildRedirectScript(url), {
318
+ headers: {
319
+ "Content-Type": "text/html",
320
+ "Datastar-Mode": "append",
321
+ "Datastar-Selector": "body",
322
+ ...options?.headers,
323
+ },
324
+ });
325
+ }
326
+
327
+ /**
328
+ * Datastar toast notification via text/html
329
+ *
330
+ * Returns a plain HTML response that Datastar dispatches as `datastar-patch-elements`.
331
+ * Use instead of `sse()` when the only action is showing a toast.
332
+ *
333
+ * @param message - The message to display
334
+ * @param type - Toast type: "success" (default) or "error"
335
+ * @returns Response with text/html content-type
336
+ *
337
+ * @example
338
+ * ```ts
339
+ * return dsToast("Settings saved successfully.");
340
+ * return dsToast("Something went wrong.", "error");
341
+ * ```
342
+ */
343
+ export function dsToast(
344
+ message: string,
345
+ type: "success" | "error" = "success",
346
+ ): Response {
347
+ return new Response(buildToastHtml(message, type), {
348
+ headers: {
349
+ "Content-Type": "text/html",
350
+ "Datastar-Mode": "append",
351
+ "Datastar-Selector": "#toast-container",
352
+ },
353
+ });
354
+ }
355
+
356
+ /**
357
+ * Datastar signal patch via application/json
358
+ *
359
+ * Returns a JSON response that Datastar dispatches as `datastar-patch-signals`.
360
+ * Use instead of `sse()` when the only action is updating signals.
361
+ *
362
+ * @param signals - Object containing signal values to update
363
+ * @returns Response with application/json content-type
364
+ *
365
+ * @example
366
+ * ```ts
367
+ * return dsSignals({ _uploadError: "File too large" });
368
+ * ```
369
+ */
370
+ export function dsSignals(signals: Record<string, unknown>): Response {
371
+ return new Response(JSON.stringify(signals), {
372
+ headers: { "Content-Type": "application/json" },
373
+ });
374
+ }