@jant/core 0.3.25 → 0.3.27
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.
- package/dist/app.js +70 -563
- package/dist/auth.js +3 -0
- package/dist/client.js +1 -0
- package/dist/i18n/locales/en.js +1 -1
- package/dist/i18n/locales/zh-Hans.js +1 -1
- package/dist/i18n/locales/zh-Hant.js +1 -1
- package/dist/lib/avatar-upload.js +134 -0
- package/dist/lib/config.js +39 -0
- package/dist/lib/constants.js +10 -10
- package/dist/lib/favicon.js +102 -0
- package/dist/lib/image.js +13 -17
- package/dist/lib/media-helpers.js +2 -2
- package/dist/lib/navigation.js +23 -3
- package/dist/lib/render.js +10 -1
- package/dist/lib/schemas.js +31 -0
- package/dist/lib/timezones.js +388 -0
- package/dist/lib/view.js +1 -1
- package/dist/routes/api/posts.js +1 -1
- package/dist/routes/api/upload.js +3 -3
- package/dist/routes/auth/reset.js +221 -0
- package/dist/routes/auth/setup.js +194 -0
- package/dist/routes/auth/signin.js +176 -0
- package/dist/routes/dash/collections.js +23 -415
- package/dist/routes/dash/media.js +12 -392
- package/dist/routes/dash/pages.js +7 -330
- package/dist/routes/dash/redirects.js +18 -12
- package/dist/routes/dash/settings.js +198 -577
- package/dist/routes/feed/rss.js +2 -1
- package/dist/routes/feed/sitemap.js +4 -2
- package/dist/routes/pages/featured.js +5 -1
- package/dist/routes/pages/home.js +26 -1
- package/dist/routes/pages/latest.js +45 -0
- package/dist/services/post.js +30 -50
- package/dist/types/bindings.js +3 -0
- package/dist/types/config.js +147 -0
- package/dist/types/constants.js +27 -0
- package/dist/types/entities.js +3 -0
- package/dist/types/operations.js +3 -0
- package/dist/types/props.js +3 -0
- package/dist/types/views.js +5 -0
- package/dist/types.js +8 -111
- package/dist/ui/color-themes.js +33 -33
- package/dist/ui/compose/ComposeDialog.js +36 -21
- package/dist/ui/dash/PageForm.js +21 -15
- package/dist/ui/dash/PostForm.js +22 -16
- package/dist/ui/dash/collections/CollectionForm.js +152 -0
- package/dist/ui/dash/collections/CollectionsListContent.js +68 -0
- package/dist/ui/dash/collections/ViewCollectionContent.js +96 -0
- package/dist/ui/dash/media/MediaListContent.js +166 -0
- package/dist/ui/dash/media/ViewMediaContent.js +212 -0
- package/dist/ui/dash/pages/LinkFormContent.js +130 -0
- package/dist/ui/dash/pages/UnifiedPagesContent.js +193 -0
- package/dist/ui/dash/settings/AccountContent.js +209 -0
- package/dist/ui/dash/settings/AppearanceContent.js +259 -0
- package/dist/ui/dash/settings/GeneralContent.js +536 -0
- package/dist/ui/dash/settings/SettingsNav.js +41 -0
- package/dist/ui/font-themes.js +36 -0
- package/dist/ui/layouts/BaseLayout.js +24 -2
- package/dist/ui/layouts/SiteLayout.js +47 -19
- package/package.json +1 -1
- package/src/app.tsx +95 -553
- package/src/auth.ts +4 -1
- package/src/client.ts +1 -0
- package/src/i18n/locales/en.po +240 -175
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +240 -175
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +240 -175
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/lib/__tests__/config.test.ts +192 -0
- package/src/lib/__tests__/favicon.test.ts +151 -0
- package/src/lib/__tests__/image.test.ts +2 -6
- package/src/lib/__tests__/timezones.test.ts +61 -0
- package/src/lib/__tests__/view.test.ts +2 -2
- package/src/lib/avatar-upload.ts +165 -0
- package/src/lib/config.ts +47 -0
- package/src/lib/constants.ts +19 -11
- package/src/lib/favicon.ts +115 -0
- package/src/lib/image.ts +13 -21
- package/src/lib/media-helpers.ts +2 -2
- package/src/lib/navigation.ts +33 -2
- package/src/lib/render.tsx +15 -1
- package/src/lib/schemas.ts +39 -0
- package/src/lib/timezones.ts +325 -0
- package/src/lib/view.ts +1 -1
- package/src/routes/api/posts.ts +1 -1
- package/src/routes/api/upload.ts +2 -3
- package/src/routes/auth/reset.tsx +239 -0
- package/src/routes/auth/setup.tsx +189 -0
- package/src/routes/auth/signin.tsx +163 -0
- package/src/routes/dash/__tests__/settings-avatar.test.ts +89 -0
- package/src/routes/dash/collections.tsx +17 -366
- package/src/routes/dash/media.tsx +12 -414
- package/src/routes/dash/pages.tsx +8 -348
- package/src/routes/dash/redirects.tsx +20 -14
- package/src/routes/dash/settings.tsx +243 -534
- package/src/routes/feed/__tests__/rss.test.ts +141 -0
- package/src/routes/feed/rss.ts +3 -1
- package/src/routes/feed/sitemap.ts +4 -2
- package/src/routes/pages/featured.tsx +7 -1
- package/src/routes/pages/home.tsx +25 -2
- package/src/routes/pages/latest.tsx +59 -0
- package/src/services/post.ts +34 -66
- package/src/styles/components.css +0 -65
- package/src/styles/tokens.css +1 -1
- package/src/styles/ui.css +24 -40
- package/src/types/bindings.ts +30 -0
- package/src/types/config.ts +183 -0
- package/src/types/constants.ts +26 -0
- package/src/types/entities.ts +109 -0
- package/src/types/operations.ts +88 -0
- package/src/types/props.ts +115 -0
- package/src/types/views.ts +172 -0
- package/src/types.ts +8 -644
- package/src/ui/__tests__/font-themes.test.ts +34 -0
- package/src/ui/color-themes.ts +34 -34
- package/src/ui/compose/ComposeDialog.tsx +40 -21
- package/src/ui/dash/PageForm.tsx +25 -19
- package/src/ui/dash/PostForm.tsx +26 -20
- package/src/ui/dash/collections/CollectionForm.tsx +153 -0
- package/src/ui/dash/collections/CollectionsListContent.tsx +85 -0
- package/src/ui/dash/collections/ViewCollectionContent.tsx +92 -0
- package/src/ui/dash/media/MediaListContent.tsx +201 -0
- package/src/ui/dash/media/ViewMediaContent.tsx +208 -0
- package/src/ui/dash/pages/LinkFormContent.tsx +119 -0
- package/src/ui/dash/pages/UnifiedPagesContent.tsx +203 -0
- package/src/ui/dash/settings/AccountContent.tsx +176 -0
- package/src/ui/dash/settings/AppearanceContent.tsx +254 -0
- package/src/ui/dash/settings/GeneralContent.tsx +533 -0
- package/src/ui/dash/settings/SettingsNav.tsx +56 -0
- package/src/ui/font-themes.ts +54 -0
- package/src/ui/layouts/BaseLayout.tsx +17 -0
- package/src/ui/layouts/SiteLayout.tsx +45 -31
|
@@ -1 +1 @@
|
|
|
1
|
-
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"+7Wr2a\":[\"Edit: \",[\"title\"]],\"+MACwa\":[\"尚未有任何收藏。\"],\"+MH6k9\":[\"Add to nav\"],\"+bHzpy\":[\"顯示連結的文字\"],\"+owNNn\":[\"文章\"],\"+zy2Nq\":[\"類型\"],\"/0D1Xp\":[\"編輯收藏集\"],\"/Rj5P4\":[\"您的姓名\"],\"/rkqRV\":[\"All pages are in your navigation.\"],\"07Epll\":[\"這將為您的網站和儀表板設置主題。所有顏色主題都支持深色模式。\"],\"0JkyS7\":[\"建立您的第一個頁面\"],\"0a6MpL\":[\"新重定向\"],\"0yIy82\":[\"No featured posts yet.\"],\"1CU1Td\":[\"網址安全識別碼(小寫、數字、連字符)\"],\"1DBGsz\":[\"筆記\"],\"1o+wgo\":[\"例如:The Verge,約翰·多伊\"],\"2N0qpv\":[\"文章標題...\"],\"2fUwEY\":[\"選擇媒體\"],\"2q/Q7x\":[\"可見性\"],\"2rJGtU\":[\"頁面標題...\"],\"3Yvsaz\":[\"302(臨時)\"],\"4/SFQS\":[\"查看網站\"],\"40TVQj\":[\"自訂路徑(選填)\"],\"4KzVT6\":[\"刪除頁面\"],\"4b3oEV\":[\"內容\"],\"4mDPGp\":[\"此頁面的 URL 路徑。使用小寫字母、數字和連字符。\"],\"6WdDG7\":[\"頁面\"],\"6YtxFj\":[\"名稱\"],\"7G4SBz\":[\"頁面內容(支持Markdown)...\"],\"7Mk+/h\":[\"更新收藏集\"],\"7Q1KKN\":[\"來源路徑\"],\"7aECQB\":[\"無效或已過期的連結\"],\"7nGhhM\":[\"你在想什麼?\"],\"7p5kLi\":[\"儀表板\"],\"7vhWI8\":[\"新密碼\"],\"87a/t/\":[\"標籤\"],\"8ZsakT\":[\"密碼\"],\"9+vGLh\":[\"Custom CSS\"],\"90Luob\":[[\"count\"],\" 條回覆\"],\"A1taO8\":[\"搜尋\"],\"AeXO77\":[\"帳戶\"],\"AyHO4m\":[\"這個收藏是關於什麼的?\"],\"B373X+\":[\"編輯文章\"],\"B495Gs\":[\"檔案館\"],\"BjF0Jv\":[\"僅限小寫字母、數字和連字符\"],\"D9Oea+\":[\"永久鏈接\"],\"DCKkhU\":[\"當前密碼\"],\"DHhJ7s\":[\"上一頁\"],\"DPfwMq\":[\"完成\"],\"DoJzLz\":[\"收藏夾\"],\"E80cJw\":[\"刪除此媒體將永久從存儲中移除它。\"],\"EEYbdt\":[\"發佈\"],\"EGwzOK\":[\"完成設置\"],\"EdQY6l\":[\"None\"],\"EkH9pt\":[\"更新\"],\"FGrimz\":[\"新帖子\"],\"FkMol5\":[\"精選\"],\"Fxf4jq\":[\"描述(可選)\"],\"GA5A5H\":[\"刪除收藏夾\"],\"GHg6h/\":[\"post\"],\"GTPbOX\":[\"Your site navigation\"],\"GX2VMa\":[\"建立您的管理員帳戶。\"],\"GbVAnd\":[\"此密碼重設連結無效或已過期。請生成一個新的連結。\"],\"GorKul\":[\"歡迎來到 Jant\"],\"GrZ6fH\":[\"新頁面\"],\"GxkJXS\":[\"上傳中...\"],\"HfyyXl\":[\"My Blog\"],\"HiETwV\":[\"安靜(正常)\"],\"Hzi9AA\":[\"未找到任何帖子。\"],\"I6gXOa\":[\"路徑\"],\"I8hDlV\":[\"至少需要 1 張圖片才能發佈圖片帖子。\"],\"IUwGEM\":[\"保存更改\"],\"IagCbF\":[\"網址\"],\"J4FNfC\":[\"此集合中沒有帖子。\"],\"JIBC/T\":[\"Supported formats: JPEG, PNG, GIF, WebP, SVG. Max size: 10MB.\"],\"Jed1wB\":[\"需要幫助嗎?請訪問<0>文檔</0>。\"],\"JiP4aa\":[\"已發佈的頁面可以通過其路徑訪問。草稿不可見。\"],\"K0r7TC\":[\"What's new?\"],\"K9NcLu\":[\"使用此 URL 將媒體嵌入到您的帖子中。\"],\"KbS2K9\":[\"重設密碼\"],\"KiJn9B\":[\"備註\"],\"KmGXnO\":[\"Are you sure you want to delete this post? This cannot be undone.\"],\"L85WcV\":[\"縮略名\"],\"LdyooL\":[\"link\"],\"LkvLQe\":[\"尚未有頁面。\"],\"M1RvTd\":[\"點擊圖片以查看完整大小\"],\"M8kJqa\":[\"草稿\"],\"M9xgHy\":[\"重定向\"],\"MHrjPM\":[\"標題\"],\"MLSRl9\":[\"Quote Text\"],\"MWBOxm\":[\"收藏(可選)\"],\"MZbQHL\":[\"未找到結果。\"],\"Mhf/H/\":[\"建立重定向\"],\"MnbH31\":[\"page\"],\"MqghUt\":[\"搜尋帖子...\"],\"N40H+G\":[\"所有\"],\"NU2Fqi\":[\"Save CSS\"],\"O3oNi5\":[\"電子郵件\"],\"OCNZaU\":[\"重定向來源的路徑\"],\"ODiSoW\":[\"尚未有帖子。\"],\"ONWvwQ\":[\"上傳\"],\"Pbm2/N\":[\"創建收藏夾\"],\"QEbNBb\":[\"路徑(例如 /archive)或完整網址(例如 https://example.com)\"],\"QLkhbH\":[\"The text being quoted...\"],\"RDjuBN\":[\"Setup\"],\"Rj01Fz\":[\"連結\"],\"RwGhWy\":[\"包含 \",[\"count\"],\" 則帖子的主題\"],\"SJmfuf\":[\"網站名稱\"],\"ST+lN2\":[\"尚未上傳任何媒體。\"],\"Tt5T6+\":[\"文章\"],\"TxE+Mj\":[\"1 條回覆\"],\"Tz0i8g\":[\"設定\"],\"U5v6Gh\":[\"編輯頁面\"],\"UDMjsP\":[\"快速操作\"],\"UGT5vp\":[\"保存設定\"],\"UxKoFf\":[\"導航\"],\"V4WsyL\":[\"Add Link\"],\"VUSy8D\":[\"Search failed. Please try again.\"],\"VhMDMg\":[\"更改密碼\"],\"WDcQq9\":[\"不公開\"],\"Weq9zb\":[\"一般設定\"],\"WmZ/rP\":[\"到路徑\"],\"Y+7JGK\":[\"創建頁面\"],\"Y75ho6\":[\"Other pages\"],\"Z3FXyt\":[\"載入中...\"],\"ZQKLI1\":[\"危險區域\"],\"ZhhOwV\":[\"引用\"],\"aAIQg2\":[\"外觀\"],\"aaGV/9\":[\"新連結\"],\"an5hVd\":[\"圖片\"],\"b+/jO6\":[\"301(永久)\"],\"bHYIks\":[\"登出\"],\"biOepV\":[\"← Back to home\"],\"cnGeoo\":[\"刪除\"],\"dEgA5A\":[\"取消\"],\"e6Jr7Q\":[\"← 返回收藏夾\"],\"ePK91l\":[\"編輯\"],\"eWLklq\":[\"引用\"],\"eneWvv\":[\"草稿\"],\"er8+x7\":[\"示範帳戶已預填。只需點擊登入。\"],\"f6e0Ry\":[\"文章\"],\"fG7BxZ\":[\"Upload images via the API: POST /api/upload with a file form field.\"],\"fttd2R\":[\"我的收藏\"],\"g3mKmM\":[\"Un-nav\"],\"gDx5MG\":[\"編輯連結\"],\"hG89Ed\":[\"圖片\"],\"hWOZIv\":[\"請輸入您的新密碼。\"],\"hXzOVo\":[\"下一頁\"],\"he3ygx\":[\"複製\"],\"iBc+/N\":[\"Custom URL path. Leave empty to use default /p/ID format.\"],\"iDAqU6\":[\"URL (optional)\"],\"iH8pgl\":[\"返回\"],\"iPHeYN\":[\"Posting...\"],\"ig4hg2\":[\"Let's set up your site.\"],\"jSRrXo\":[\"Published pages are accessible via their slug. Drafts are not visible.\"],\"jpctdh\":[\"查看\"],\"jt/Ow/\":[\"posts\"],\"k1ifdL\":[\"處理中...\"],\"kI1qVD\":[\"Format\"],\"kNiQp6\":[\"Pinned\"],\"kd7eBB\":[\"建立連結\"],\"mTOYla\":[\"View all posts →\"],\"mnkknn\":[\"Collection (optional)\"],\"n1ekoW\":[\"登入\"],\"oJFOZk\":[\"來源名稱(選填)\"],\"oYPBa0\":[\"更新頁面\"],\"p2/GCq\":[\"確認密碼\"],\"pRhYH2\":[\"收藏中的帖子 (\",[\"count\"],\")\"],\"pZq3aX\":[\"上傳失敗。請再試一次。\"],\"q+hNag\":[\"Collection\"],\"qMyM2u\":[\"來源網址(選填)\"],\"qiXmlF\":[\"添加媒體\"],\"quFPTj\":[\"Custom Slug (optional)\"],\"r1MpXi\":[\"安靜\"],\"rFmBG3\":[\"顏色主題\"],\"rdUucN\":[\"預覽\"],\"rzNUSl\":[\"包含 1 則貼文的主題\"],\"sGajR7\":[\"線程開始\"],\"smzF8S\":[\"顯示 \",[\"remainingCount\"],\" 個更多 \",[\"0\"]],\"ssqvZi\":[\"保存個人資料\"],\"t/YqKh\":[\"移除\"],\"tfrt7B\":[\"未配置任何重定向。\"],\"tiq7kl\":[\"頁面 \",[\"page\"]],\"u2f7vd\":[\"網站描述\"],\"u3wRF+\":[\"已發佈\"],\"u6Hp4N\":[\"Markdown\"],\"uAQUqI\":[\"狀態\"],\"vERlcd\":[\"個人資料\"],\"vXIe7J\":[\"語言\"],\"vh0C9b\":[\"No navigation links yet. Add pages to navigation or create links.\"],\"vmQmHx\":[\"Add custom CSS to override any styles. Use data attributes like [data-page], [data-post], [data-format] to target specific elements.\"],\"vzU4k9\":[\"新收藏集\"],\"wEF6Ix\":[\"目的地路徑或 URL\"],\"wK4OTM\":[\"標題(選填)\"],\"wL3cK8\":[\"Latest\"],\"wM5UXj\":[\"刪除媒體\"],\"wRR604\":[\"頁面\"],\"wc+17X\":[\"/* Your custom CSS here */\"],\"wdGjkd\":[\"未配置導航連結。\"],\"wja8aL\":[\"無標題\"],\"x+doid\":[\"圖片會自動優化:調整大小至最大 1920 像素,轉換為 WebP 格式,並去除元數據。\"],\"x0mzE0\":[\"創建你的第一篇帖子\"],\"x4RuFo\":[\"Back to home\"],\"xYilR2\":[\"媒體\"],\"y28hnO\":[\"文章\"],\"yQ2kGp\":[\"載入更多\"],\"yjkELF\":[\"確認新密碼\"],\"yzF66j\":[\"連結\"],\"z1U/Fh\":[\"Rating\"],\"z8ajIE\":[\"找到 1 個結果\"],\"zH6KqE\":[\"找到 \",[\"count\"],\" 個結果\"]}")as Messages;
|
|
1
|
+
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"+7Wr2a\":[\"Edit: \",[\"title\"]],\"+MACwa\":[\"尚未有任何收藏。\"],\"+MH6k9\":[\"Add to nav\"],\"+bHzpy\":[\"顯示連結的文字\"],\"+owNNn\":[\"文章\"],\"+zy2Nq\":[\"類型\"],\"/0D1Xp\":[\"編輯收藏集\"],\"/Rj5P4\":[\"您的姓名\"],\"/rkqRV\":[\"All pages are in your navigation.\"],\"07Epll\":[\"這將為您的網站和儀表板設置主題。所有顏色主題都支持深色模式。\"],\"0JkyS7\":[\"建立您的第一個頁面\"],\"0a6MpL\":[\"新重定向\"],\"0yIy82\":[\"No featured posts yet.\"],\"1CU1Td\":[\"網址安全識別碼(小寫、數字、連字符)\"],\"1DBGsz\":[\"筆記\"],\"1o+wgo\":[\"例如:The Verge,約翰·多伊\"],\"2N0qpv\":[\"文章標題...\"],\"2cFU6q\":[\"Site Footer\"],\"2fUwEY\":[\"選擇媒體\"],\"2q/Q7x\":[\"可見性\"],\"2rJGtU\":[\"頁面標題...\"],\"3SAro+\":[\"Choose a font for your site. All options use system fonts for fast loading.\"],\"3Yvsaz\":[\"302(臨時)\"],\"4/SFQS\":[\"查看網站\"],\"40TVQj\":[\"自訂路徑(選填)\"],\"4KzVT6\":[\"刪除頁面\"],\"4Ml90q\":[\"SEO\"],\"4b3oEV\":[\"內容\"],\"4mDPGp\":[\"此頁面的 URL 路徑。使用小寫字母、數字和連字符。\"],\"6WdDG7\":[\"頁面\"],\"6YtxFj\":[\"名稱\"],\"7G4SBz\":[\"頁面內容(支持Markdown)...\"],\"7Mk+/h\":[\"更新收藏集\"],\"7Q1KKN\":[\"來源路徑\"],\"7aECQB\":[\"無效或已過期的連結\"],\"7nGhhM\":[\"你在想什麼?\"],\"7p5kLi\":[\"儀表板\"],\"7vhWI8\":[\"新密碼\"],\"87a/t/\":[\"標籤\"],\"8ZsakT\":[\"密碼\"],\"9+vGLh\":[\"Custom CSS\"],\"90Luob\":[[\"count\"],\" 條回覆\"],\"A1taO8\":[\"搜尋\"],\"AeXO77\":[\"帳戶\"],\"AyHO4m\":[\"這個收藏是關於什麼的?\"],\"B373X+\":[\"編輯文章\"],\"B495Gs\":[\"檔案館\"],\"BjF0Jv\":[\"僅限小寫字母、數字和連字符\"],\"D9Oea+\":[\"永久鏈接\"],\"DCKkhU\":[\"當前密碼\"],\"DHhJ7s\":[\"上一頁\"],\"DPfwMq\":[\"完成\"],\"DoJzLz\":[\"收藏夾\"],\"E80cJw\":[\"刪除此媒體將永久從存儲中移除它。\"],\"EEYbdt\":[\"發佈\"],\"EGwzOK\":[\"完成設置\"],\"EdQY6l\":[\"None\"],\"EkH9pt\":[\"更新\"],\"FGrimz\":[\"新帖子\"],\"FkMol5\":[\"精選\"],\"Fxf4jq\":[\"描述(可選)\"],\"GA5A5H\":[\"刪除收藏夾\"],\"GHg6h/\":[\"post\"],\"GTPbOX\":[\"Your site navigation\"],\"GX2VMa\":[\"建立您的管理員帳戶。\"],\"GbVAnd\":[\"此密碼重設連結無效或已過期。請生成一個新的連結。\"],\"GorKul\":[\"歡迎來到 Jant\"],\"GrZ6fH\":[\"新頁面\"],\"GxkJXS\":[\"上傳中...\"],\"HfyyXl\":[\"My Blog\"],\"HiETwV\":[\"安靜(正常)\"],\"Hzi9AA\":[\"未找到任何帖子。\"],\"I6gXOa\":[\"路徑\"],\"I8hDlV\":[\"至少需要 1 張圖片才能發佈圖片帖子。\"],\"IUwGEM\":[\"保存更改\"],\"IagCbF\":[\"網址\"],\"J4FNfC\":[\"此集合中沒有帖子。\"],\"JIBC/T\":[\"Supported formats: JPEG, PNG, GIF, WebP, SVG. Max size: 10MB.\"],\"Jed1wB\":[\"需要幫助嗎?請訪問<0>文檔</0>。\"],\"JiP4aa\":[\"已發佈的頁面可以通過其路徑訪問。草稿不可見。\"],\"K0r7TC\":[\"What's new?\"],\"K9NcLu\":[\"使用此 URL 將媒體嵌入到您的帖子中。\"],\"KbS2K9\":[\"重設密碼\"],\"KiJn9B\":[\"備註\"],\"KmGXnO\":[\"Are you sure you want to delete this post? This cannot be undone.\"],\"L85WcV\":[\"縮略名\"],\"LdyooL\":[\"link\"],\"LkvLQe\":[\"尚未有頁面。\"],\"M1RvTd\":[\"點擊圖片以查看完整大小\"],\"M2kIWU\":[\"Font theme\"],\"M8kJqa\":[\"草稿\"],\"M9xgHy\":[\"重定向\"],\"MHrjPM\":[\"標題\"],\"MLSRl9\":[\"Quote Text\"],\"MWBOxm\":[\"收藏(可選)\"],\"MZbQHL\":[\"未找到結果。\"],\"Mhf/H/\":[\"建立重定向\"],\"MnbH31\":[\"page\"],\"MqghUt\":[\"搜尋帖子...\"],\"N0APCr\":[\"This is displayed above your blog posts on your default home page. This is also used for the meta description on your home page.\"],\"N40H+G\":[\"所有\"],\"NU2Fqi\":[\"Save CSS\"],\"O3oNi5\":[\"電子郵件\"],\"OCNZaU\":[\"重定向來源的路徑\"],\"ODiSoW\":[\"尚未有帖子。\"],\"ONWvwQ\":[\"上傳\"],\"P/XNX0\":[\"This is used for your favicon.\"],\"PZ7HJ8\":[\"Blog Avatar\"],\"Pbm2/N\":[\"創建收藏夾\"],\"QEbNBb\":[\"路徑(例如 /archive)或完整網址(例如 https://example.com)\"],\"QLkhbH\":[\"The text being quoted...\"],\"RDjuBN\":[\"Setup\"],\"Rj01Fz\":[\"連結\"],\"RwGhWy\":[\"包含 \",[\"count\"],\" 則帖子的主題\"],\"RxsRD6\":[\"Time Zone\"],\"SJmfuf\":[\"網站名稱\"],\"ST+lN2\":[\"尚未上傳任何媒體。\"],\"TNFigk\":[\"Default Homepage View\"],\"Tt5T6+\":[\"文章\"],\"TxE+Mj\":[\"1 條回覆\"],\"Tz0i8g\":[\"設定\"],\"U5v6Gh\":[\"編輯頁面\"],\"UDMjsP\":[\"快速操作\"],\"UGT5vp\":[\"保存設定\"],\"Ui5/i3\":[\"It's OK for search engines to index my site\"],\"Uj/btJ\":[\"Display avatar in my site header\"],\"UxKoFf\":[\"導航\"],\"V4WsyL\":[\"Add Link\"],\"VUSy8D\":[\"Search failed. Please try again.\"],\"VhMDMg\":[\"更改密碼\"],\"WDcQq9\":[\"不公開\"],\"Weq9zb\":[\"一般設定\"],\"WmZ/rP\":[\"到路徑\"],\"Y+7JGK\":[\"創建頁面\"],\"Y75ho6\":[\"Other pages\"],\"Z3FXyt\":[\"載入中...\"],\"ZQKLI1\":[\"危險區域\"],\"ZUpE9/\":[\"This is displayed at the bottom of all of your posts and pages. Markdown is supported.\"],\"ZhhOwV\":[\"引用\"],\"aAIQg2\":[\"外觀\"],\"aaGV/9\":[\"新連結\"],\"an5hVd\":[\"圖片\"],\"anibOb\":[\"About this blog\"],\"b+/jO6\":[\"301(永久)\"],\"bHYIks\":[\"登出\"],\"biOepV\":[\"← Back to home\"],\"cnGeoo\":[\"刪除\"],\"dEgA5A\":[\"取消\"],\"dmCcPs\":[\"This is used for your favicon and apple-touch-icon. For best results, upload a square image at least 180x180 pixels.\"],\"e6Jr7Q\":[\"← 返回收藏夾\"],\"ePK91l\":[\"編輯\"],\"eWLklq\":[\"引用\"],\"eneWvv\":[\"草稿\"],\"er8+x7\":[\"示範帳戶已預填。只需點擊登入。\"],\"f6e0Ry\":[\"文章\"],\"fG7BxZ\":[\"Upload images via the API: POST /api/upload with a file form field.\"],\"fttd2R\":[\"我的收藏\"],\"g3mKmM\":[\"Un-nav\"],\"gDx5MG\":[\"編輯連結\"],\"hG89Ed\":[\"圖片\"],\"hWOZIv\":[\"請輸入您的新密碼。\"],\"hXzOVo\":[\"下一頁\"],\"he3ygx\":[\"複製\"],\"iBc+/N\":[\"Custom URL path. Leave empty to use default /p/ID format.\"],\"iDAqU6\":[\"URL (optional)\"],\"iH8pgl\":[\"返回\"],\"iPHeYN\":[\"Posting...\"],\"ig4hg2\":[\"Let's set up your site.\"],\"jSRrXo\":[\"Published pages are accessible via their slug. Drafts are not visible.\"],\"jUV7CU\":[\"Upload Avatar\"],\"jVUmOK\":[\"Markdown supported\"],\"jpctdh\":[\"查看\"],\"jt/Ow/\":[\"posts\"],\"k1ifdL\":[\"處理中...\"],\"kI1qVD\":[\"Format\"],\"kNiQp6\":[\"Pinned\"],\"kd7eBB\":[\"建立連結\"],\"mTOYla\":[\"View all posts →\"],\"mnkknn\":[\"Collection (optional)\"],\"n1ekoW\":[\"登入\"],\"oJFOZk\":[\"來源名稱(選填)\"],\"oYPBa0\":[\"更新頁面\"],\"p2/GCq\":[\"確認密碼\"],\"pRhYH2\":[\"收藏中的帖子 (\",[\"count\"],\")\"],\"pZq3aX\":[\"上傳失敗。請再試一次。\"],\"q+hNag\":[\"Collection\"],\"qMyM2u\":[\"來源網址(選填)\"],\"qiXmlF\":[\"添加媒體\"],\"quFPTj\":[\"Custom Slug (optional)\"],\"r1MpXi\":[\"安靜\"],\"rFmBG3\":[\"顏色主題\"],\"rdUucN\":[\"預覽\"],\"rzNUSl\":[\"包含 1 則貼文的主題\"],\"sGajR7\":[\"線程開始\"],\"smzF8S\":[\"顯示 \",[\"remainingCount\"],\" 個更多 \",[\"0\"]],\"ssqvZi\":[\"保存個人資料\"],\"t/YqKh\":[\"移除\"],\"tfDRzk\":[\"Save\"],\"tfrt7B\":[\"未配置任何重定向。\"],\"tiq7kl\":[\"頁面 \",[\"page\"]],\"u2f7vd\":[\"網站描述\"],\"u3wRF+\":[\"已發佈\"],\"u6Hp4N\":[\"Markdown\"],\"uAQUqI\":[\"狀態\"],\"vERlcd\":[\"個人資料\"],\"vXIe7J\":[\"語言\"],\"vh0C9b\":[\"No navigation links yet. Add pages to navigation or create links.\"],\"vmQmHx\":[\"Add custom CSS to override any styles. Use data attributes like [data-page], [data-post], [data-format] to target specific elements.\"],\"vzU4k9\":[\"新收藏集\"],\"wEF6Ix\":[\"目的地路徑或 URL\"],\"wK4OTM\":[\"標題(選填)\"],\"wL3cK8\":[\"Latest\"],\"wM5UXj\":[\"刪除媒體\"],\"wRR604\":[\"頁面\"],\"wc+17X\":[\"/* Your custom CSS here */\"],\"wdGjkd\":[\"未配置導航連結。\"],\"wja8aL\":[\"無標題\"],\"x+doid\":[\"圖片會自動優化:調整大小至最大 1920 像素,轉換為 WebP 格式,並去除元數據。\"],\"x0mzE0\":[\"創建你的第一篇帖子\"],\"x4RuFo\":[\"Back to home\"],\"xYilR2\":[\"媒體\"],\"y28hnO\":[\"文章\"],\"yQ2kGp\":[\"載入更多\"],\"yjkELF\":[\"確認新密碼\"],\"yzF66j\":[\"連結\"],\"z1U/Fh\":[\"Rating\"],\"z8ajIE\":[\"找到 1 個結果\"],\"zH6KqE\":[\"找到 \",[\"count\"],\" 個結果\"]}")as Messages;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { createTestDatabase } from "../../__tests__/helpers/db.js";
|
|
3
|
+
import { createSettingsService } from "../../services/settings.js";
|
|
4
|
+
import type { Database } from "../../db/index.js";
|
|
5
|
+
import {
|
|
6
|
+
getConfig,
|
|
7
|
+
getHomeDefaultView,
|
|
8
|
+
getConfigFallback,
|
|
9
|
+
getTimeZone,
|
|
10
|
+
getSiteFooter,
|
|
11
|
+
isNoIndex,
|
|
12
|
+
} from "../config.js";
|
|
13
|
+
import type { Context } from "hono";
|
|
14
|
+
|
|
15
|
+
function createMockContext(
|
|
16
|
+
services: { settings: ReturnType<typeof createSettingsService> },
|
|
17
|
+
env: Record<string, string> = {},
|
|
18
|
+
): Context {
|
|
19
|
+
return {
|
|
20
|
+
env,
|
|
21
|
+
var: { services },
|
|
22
|
+
} as unknown as Context;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("getConfig", () => {
|
|
26
|
+
let db: Database;
|
|
27
|
+
let settingsService: ReturnType<typeof createSettingsService>;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
const testDb = createTestDatabase();
|
|
31
|
+
db = testDb.db as unknown as Database;
|
|
32
|
+
settingsService = createSettingsService(db);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns default value when no DB or ENV value exists", async () => {
|
|
36
|
+
const c = createMockContext({ settings: settingsService });
|
|
37
|
+
const result = await getConfig(c, "HOME_DEFAULT_VIEW");
|
|
38
|
+
expect(result).toBe("latest");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns DB value when set", async () => {
|
|
42
|
+
await settingsService.set("HOME_DEFAULT_VIEW", "featured");
|
|
43
|
+
const c = createMockContext({ settings: settingsService });
|
|
44
|
+
const result = await getConfig(c, "HOME_DEFAULT_VIEW");
|
|
45
|
+
expect(result).toBe("featured");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns env value when DB is empty", async () => {
|
|
49
|
+
const c = createMockContext(
|
|
50
|
+
{ settings: settingsService },
|
|
51
|
+
{
|
|
52
|
+
HOME_DEFAULT_VIEW: "featured",
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
const result = await getConfig(c, "HOME_DEFAULT_VIEW");
|
|
56
|
+
expect(result).toBe("featured");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("DB value takes precedence over env value", async () => {
|
|
60
|
+
await settingsService.set("HOME_DEFAULT_VIEW", "featured");
|
|
61
|
+
const c = createMockContext(
|
|
62
|
+
{ settings: settingsService },
|
|
63
|
+
{
|
|
64
|
+
HOME_DEFAULT_VIEW: "latest",
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
const result = await getConfig(c, "HOME_DEFAULT_VIEW");
|
|
68
|
+
expect(result).toBe("featured");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("getHomeDefaultView", () => {
|
|
73
|
+
let db: Database;
|
|
74
|
+
let settingsService: ReturnType<typeof createSettingsService>;
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
const testDb = createTestDatabase();
|
|
78
|
+
db = testDb.db as unknown as Database;
|
|
79
|
+
settingsService = createSettingsService(db);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns 'latest' by default", async () => {
|
|
83
|
+
const c = createMockContext({ settings: settingsService });
|
|
84
|
+
const result = await getHomeDefaultView(c);
|
|
85
|
+
expect(result).toBe("latest");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns 'featured' when set in DB", async () => {
|
|
89
|
+
await settingsService.set("HOME_DEFAULT_VIEW", "featured");
|
|
90
|
+
const c = createMockContext({ settings: settingsService });
|
|
91
|
+
const result = await getHomeDefaultView(c);
|
|
92
|
+
expect(result).toBe("featured");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("getTimeZone", () => {
|
|
97
|
+
let db: Database;
|
|
98
|
+
let settingsService: ReturnType<typeof createSettingsService>;
|
|
99
|
+
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
const testDb = createTestDatabase();
|
|
102
|
+
db = testDb.db as unknown as Database;
|
|
103
|
+
settingsService = createSettingsService(db);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("returns 'UTC' by default", async () => {
|
|
107
|
+
const c = createMockContext({ settings: settingsService });
|
|
108
|
+
const result = await getTimeZone(c);
|
|
109
|
+
expect(result).toBe("UTC");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns DB value when set", async () => {
|
|
113
|
+
await settingsService.set("TIME_ZONE", "Beijing");
|
|
114
|
+
const c = createMockContext({ settings: settingsService });
|
|
115
|
+
const result = await getTimeZone(c);
|
|
116
|
+
expect(result).toBe("Beijing");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("getSiteFooter", () => {
|
|
121
|
+
let db: Database;
|
|
122
|
+
let settingsService: ReturnType<typeof createSettingsService>;
|
|
123
|
+
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
const testDb = createTestDatabase();
|
|
126
|
+
db = testDb.db as unknown as Database;
|
|
127
|
+
settingsService = createSettingsService(db);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns empty string by default", async () => {
|
|
131
|
+
const c = createMockContext({ settings: settingsService });
|
|
132
|
+
const result = await getSiteFooter(c);
|
|
133
|
+
expect(result).toBe("");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("returns DB value when set", async () => {
|
|
137
|
+
await settingsService.set("SITE_FOOTER", "**Footer text**");
|
|
138
|
+
const c = createMockContext({ settings: settingsService });
|
|
139
|
+
const result = await getSiteFooter(c);
|
|
140
|
+
expect(result).toBe("**Footer text**");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("isNoIndex", () => {
|
|
145
|
+
let db: Database;
|
|
146
|
+
let settingsService: ReturnType<typeof createSettingsService>;
|
|
147
|
+
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
const testDb = createTestDatabase();
|
|
150
|
+
db = testDb.db as unknown as Database;
|
|
151
|
+
settingsService = createSettingsService(db);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("returns false by default", async () => {
|
|
155
|
+
const c = createMockContext({ settings: settingsService });
|
|
156
|
+
const result = await isNoIndex(c);
|
|
157
|
+
expect(result).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns true when NOINDEX is set to 'true'", async () => {
|
|
161
|
+
await settingsService.set("NOINDEX", "true");
|
|
162
|
+
const c = createMockContext({ settings: settingsService });
|
|
163
|
+
const result = await isNoIndex(c);
|
|
164
|
+
expect(result).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("returns false when NOINDEX is set to other value", async () => {
|
|
168
|
+
await settingsService.set("NOINDEX", "false");
|
|
169
|
+
const c = createMockContext({ settings: settingsService });
|
|
170
|
+
const result = await isNoIndex(c);
|
|
171
|
+
expect(result).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("getConfigFallback", () => {
|
|
176
|
+
it("returns default when no env value", () => {
|
|
177
|
+
const c = createMockContext({
|
|
178
|
+
settings: {} as ReturnType<typeof createSettingsService>,
|
|
179
|
+
});
|
|
180
|
+
const result = getConfigFallback(c, "HOME_DEFAULT_VIEW");
|
|
181
|
+
expect(result).toBe("latest");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("returns env value when set", () => {
|
|
185
|
+
const c = createMockContext(
|
|
186
|
+
{ settings: {} as ReturnType<typeof createSettingsService> },
|
|
187
|
+
{ HOME_DEFAULT_VIEW: "featured" },
|
|
188
|
+
);
|
|
189
|
+
const result = getConfigFallback(c, "HOME_DEFAULT_VIEW");
|
|
190
|
+
expect(result).toBe("featured");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
FAVICON_SIZES,
|
|
4
|
+
encodeIco,
|
|
5
|
+
arrayBufferToBase64,
|
|
6
|
+
base64ToUint8Array,
|
|
7
|
+
} from "../favicon.js";
|
|
8
|
+
|
|
9
|
+
describe("FAVICON_SIZES", () => {
|
|
10
|
+
it("has correct ICO sizes", () => {
|
|
11
|
+
expect(FAVICON_SIZES.ICO_16).toBe(16);
|
|
12
|
+
expect(FAVICON_SIZES.ICO_32).toBe(32);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("has correct apple-touch-icon size", () => {
|
|
16
|
+
expect(FAVICON_SIZES.APPLE_TOUCH).toBe(180);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("encodeIco", () => {
|
|
21
|
+
it("produces a valid ICO blob with correct type", () => {
|
|
22
|
+
const png = new ArrayBuffer(8);
|
|
23
|
+
const result = encodeIco([{ size: 32, png }]);
|
|
24
|
+
|
|
25
|
+
expect(result).toBeInstanceOf(Blob);
|
|
26
|
+
expect(result.type).toBe("image/x-icon");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("produces correct ICO header for single entry", async () => {
|
|
30
|
+
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer;
|
|
31
|
+
const result = encodeIco([{ size: 32, png }]);
|
|
32
|
+
|
|
33
|
+
const buffer = await result.arrayBuffer();
|
|
34
|
+
const view = new DataView(buffer);
|
|
35
|
+
|
|
36
|
+
// ICO header
|
|
37
|
+
expect(view.getUint16(0, true)).toBe(0); // reserved
|
|
38
|
+
expect(view.getUint16(2, true)).toBe(1); // type = icon
|
|
39
|
+
expect(view.getUint16(4, true)).toBe(1); // count = 1
|
|
40
|
+
|
|
41
|
+
// Directory entry
|
|
42
|
+
expect(view.getUint8(6)).toBe(32); // width
|
|
43
|
+
expect(view.getUint8(7)).toBe(32); // height
|
|
44
|
+
expect(view.getUint8(8)).toBe(0); // color count
|
|
45
|
+
expect(view.getUint8(9)).toBe(0); // reserved
|
|
46
|
+
expect(view.getUint16(10, true)).toBe(1); // planes
|
|
47
|
+
expect(view.getUint16(12, true)).toBe(32); // bits per pixel
|
|
48
|
+
expect(view.getUint32(14, true)).toBe(4); // image data size
|
|
49
|
+
expect(view.getUint32(18, true)).toBe(22); // offset (6 header + 16 dir entry)
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("produces correct ICO with multiple entries", async () => {
|
|
53
|
+
const png16 = new Uint8Array([1, 2, 3]).buffer;
|
|
54
|
+
const png32 = new Uint8Array([4, 5, 6, 7]).buffer;
|
|
55
|
+
|
|
56
|
+
const result = encodeIco([
|
|
57
|
+
{ size: 16, png: png16 },
|
|
58
|
+
{ size: 32, png: png32 },
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const buffer = await result.arrayBuffer();
|
|
62
|
+
const view = new DataView(buffer);
|
|
63
|
+
|
|
64
|
+
// Header
|
|
65
|
+
expect(view.getUint16(4, true)).toBe(2); // count = 2
|
|
66
|
+
|
|
67
|
+
// First entry (16x16)
|
|
68
|
+
expect(view.getUint8(6)).toBe(16); // width
|
|
69
|
+
expect(view.getUint8(7)).toBe(16); // height
|
|
70
|
+
expect(view.getUint32(14, true)).toBe(3); // data size
|
|
71
|
+
expect(view.getUint32(18, true)).toBe(38); // offset (6 + 2*16 = 38)
|
|
72
|
+
|
|
73
|
+
// Second entry (32x32)
|
|
74
|
+
expect(view.getUint8(22)).toBe(32); // width
|
|
75
|
+
expect(view.getUint8(23)).toBe(32); // height
|
|
76
|
+
expect(view.getUint32(30, true)).toBe(4); // data size
|
|
77
|
+
expect(view.getUint32(34, true)).toBe(41); // offset (38 + 3 = 41)
|
|
78
|
+
|
|
79
|
+
// Verify PNG data is embedded
|
|
80
|
+
const data = new Uint8Array(buffer);
|
|
81
|
+
expect(data[38]).toBe(1);
|
|
82
|
+
expect(data[39]).toBe(2);
|
|
83
|
+
expect(data[40]).toBe(3);
|
|
84
|
+
expect(data[41]).toBe(4);
|
|
85
|
+
expect(data[42]).toBe(5);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("handles 256px size by setting width/height to 0", async () => {
|
|
89
|
+
const png = new ArrayBuffer(4);
|
|
90
|
+
const result = encodeIco([{ size: 256, png }]);
|
|
91
|
+
|
|
92
|
+
const buffer = await result.arrayBuffer();
|
|
93
|
+
const view = new DataView(buffer);
|
|
94
|
+
|
|
95
|
+
expect(view.getUint8(6)).toBe(0); // 0 means 256
|
|
96
|
+
expect(view.getUint8(7)).toBe(0); // 0 means 256
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("total blob size matches header + directory + data", async () => {
|
|
100
|
+
const png1 = new ArrayBuffer(100);
|
|
101
|
+
const png2 = new ArrayBuffer(200);
|
|
102
|
+
|
|
103
|
+
const result = encodeIco([
|
|
104
|
+
{ size: 16, png: png1 },
|
|
105
|
+
{ size: 32, png: png2 },
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
const buffer = await result.arrayBuffer();
|
|
109
|
+
// 6 (header) + 2*16 (directory) + 100 + 200 (data) = 338
|
|
110
|
+
expect(buffer.byteLength).toBe(338);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("arrayBufferToBase64", () => {
|
|
115
|
+
it("encodes an empty buffer", () => {
|
|
116
|
+
expect(arrayBufferToBase64(new ArrayBuffer(0))).toBe("");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("encodes simple bytes", () => {
|
|
120
|
+
const buf = new Uint8Array([72, 101, 108, 108, 111]).buffer; // "Hello"
|
|
121
|
+
expect(arrayBufferToBase64(buf)).toBe("SGVsbG8=");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("encodes binary data correctly", () => {
|
|
125
|
+
const buf = new Uint8Array([0, 255, 128, 64]).buffer;
|
|
126
|
+
const b64 = arrayBufferToBase64(buf);
|
|
127
|
+
// Verify round-trip
|
|
128
|
+
const decoded = base64ToUint8Array(b64);
|
|
129
|
+
expect(Array.from(decoded)).toEqual([0, 255, 128, 64]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("base64ToUint8Array", () => {
|
|
134
|
+
it("decodes an empty string", () => {
|
|
135
|
+
expect(base64ToUint8Array("").length).toBe(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("decodes simple base64", () => {
|
|
139
|
+
const result = base64ToUint8Array("SGVsbG8=");
|
|
140
|
+
expect(Array.from(result)).toEqual([72, 101, 108, 108, 111]); // "Hello"
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("round-trips with arrayBufferToBase64", () => {
|
|
144
|
+
const original = new Uint8Array(256);
|
|
145
|
+
for (let i = 0; i < 256; i++) original[i] = i;
|
|
146
|
+
|
|
147
|
+
const b64 = arrayBufferToBase64(original.buffer);
|
|
148
|
+
const decoded = base64ToUint8Array(b64);
|
|
149
|
+
expect(Array.from(decoded)).toEqual(Array.from(original));
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -50,16 +50,12 @@ describe("getPublicUrlForProvider", () => {
|
|
|
50
50
|
|
|
51
51
|
describe("getMediaUrl", () => {
|
|
52
52
|
it("returns local proxy URL when no publicUrl provided", () => {
|
|
53
|
-
const result = getMediaUrl(
|
|
54
|
-
|
|
55
|
-
"media/2025/01/01902a9f-1a2b-7c3d.webp",
|
|
56
|
-
);
|
|
57
|
-
expect(result).toBe("/media/01902a9f-1a2b-7c3d.webp");
|
|
53
|
+
const result = getMediaUrl("media/2025/01/01902a9f-1a2b-7c3d.webp");
|
|
54
|
+
expect(result).toBe("/media/2025/01/01902a9f-1a2b-7c3d.webp");
|
|
58
55
|
});
|
|
59
56
|
|
|
60
57
|
it("returns CDN URL when publicUrl is provided", () => {
|
|
61
58
|
const result = getMediaUrl(
|
|
62
|
-
"01902a9f-1a2b-7c3d",
|
|
63
59
|
"media/2025/01/01902a9f-1a2b-7c3d.webp",
|
|
64
60
|
"https://cdn.example.com",
|
|
65
61
|
);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { TIMEZONES, mapIanaToTimezone } from "../timezones.js";
|
|
3
|
+
|
|
4
|
+
describe("TIMEZONES", () => {
|
|
5
|
+
it("contains expected timezone entries", () => {
|
|
6
|
+
expect(TIMEZONES.length).toBeGreaterThan(30);
|
|
7
|
+
const utc = TIMEZONES.find((tz) => tz.value === "UTC");
|
|
8
|
+
expect(utc).toBeDefined();
|
|
9
|
+
expect(utc!.offset).toBe("+00:00");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("each entry has required fields", () => {
|
|
13
|
+
for (const tz of TIMEZONES) {
|
|
14
|
+
expect(tz.value).toBeTruthy();
|
|
15
|
+
expect(tz.label).toBeTruthy();
|
|
16
|
+
expect(tz.offset).toBeTruthy();
|
|
17
|
+
expect(tz.iana.length).toBeGreaterThan(0);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("has no duplicate values", () => {
|
|
22
|
+
const values = TIMEZONES.map((tz) => tz.value);
|
|
23
|
+
expect(new Set(values).size).toBe(values.length);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("mapIanaToTimezone", () => {
|
|
28
|
+
it("maps Asia/Shanghai to Beijing", () => {
|
|
29
|
+
expect(mapIanaToTimezone("Asia/Shanghai")).toBe("Beijing");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("maps America/New_York to Eastern Time", () => {
|
|
33
|
+
expect(mapIanaToTimezone("America/New_York")).toBe(
|
|
34
|
+
"Eastern Time (US & Canada)",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("maps Europe/London to London", () => {
|
|
39
|
+
expect(mapIanaToTimezone("Europe/London")).toBe("London");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("maps Asia/Tokyo to Tokyo", () => {
|
|
43
|
+
expect(mapIanaToTimezone("Asia/Tokyo")).toBe("Tokyo");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns UTC for unknown timezone", () => {
|
|
47
|
+
expect(mapIanaToTimezone("Unknown/Zone")).toBe("UTC");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns UTC for empty string", () => {
|
|
51
|
+
expect(mapIanaToTimezone("")).toBe("UTC");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("maps Pacific/Honolulu to Hawaii", () => {
|
|
55
|
+
expect(mapIanaToTimezone("Pacific/Honolulu")).toBe("Hawaii");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("maps Australia/Sydney to Sydney", () => {
|
|
59
|
+
expect(mapIanaToTimezone("Australia/Sydney")).toBe("Sydney");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -328,8 +328,8 @@ describe("toMediaView", () => {
|
|
|
328
328
|
it("generates local proxy URL without public URL", () => {
|
|
329
329
|
const media = makeMedia();
|
|
330
330
|
const view = toMediaView(media, EMPTY_CTX);
|
|
331
|
-
expect(view.url).toBe("/media/01902a9f-1a2b-7c3d.webp");
|
|
332
|
-
expect(view.thumbnailUrl).toBe("/media/01902a9f-1a2b-7c3d.webp");
|
|
331
|
+
expect(view.url).toBe("/media/2025/01/01902a9f-1a2b-7c3d.webp");
|
|
332
|
+
expect(view.thumbnailUrl).toBe("/media/2025/01/01902a9f-1a2b-7c3d.webp");
|
|
333
333
|
});
|
|
334
334
|
|
|
335
335
|
it("generates CDN URL with public URL", () => {
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side Avatar Upload Handler
|
|
3
|
+
*
|
|
4
|
+
* Intercepts avatar file selection to generate favicon variants
|
|
5
|
+
* before uploading. Generates:
|
|
6
|
+
* - favicon.ico (ICO containing 16x16 + 32x32 PNGs)
|
|
7
|
+
* - apple-touch-icon.png (180x180 PNG)
|
|
8
|
+
*
|
|
9
|
+
* Uses the `[data-avatar-upload]` attribute on file inputs.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { encodeIco } from "./favicon.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load an image from a File object
|
|
16
|
+
*/
|
|
17
|
+
function loadImage(file: File): Promise<HTMLImageElement> {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const img = new Image();
|
|
20
|
+
img.onload = () => {
|
|
21
|
+
URL.revokeObjectURL(img.src);
|
|
22
|
+
resolve(img);
|
|
23
|
+
};
|
|
24
|
+
img.onerror = () => reject(new Error("Failed to load image"));
|
|
25
|
+
img.src = URL.createObjectURL(file);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resize image to a square PNG using center crop.
|
|
31
|
+
*
|
|
32
|
+
* @param img - Source HTMLImageElement
|
|
33
|
+
* @param size - Target width and height in pixels
|
|
34
|
+
* @returns PNG Blob at the target size
|
|
35
|
+
*/
|
|
36
|
+
function resizeToSquarePng(img: HTMLImageElement, size: number): Promise<Blob> {
|
|
37
|
+
const canvas = document.createElement("canvas");
|
|
38
|
+
canvas.width = size;
|
|
39
|
+
canvas.height = size;
|
|
40
|
+
|
|
41
|
+
const ctx = canvas.getContext("2d");
|
|
42
|
+
if (!ctx) throw new Error("Failed to get canvas context");
|
|
43
|
+
|
|
44
|
+
// Cover crop: scale to fill square, crop center
|
|
45
|
+
const scale = Math.max(size / img.width, size / img.height);
|
|
46
|
+
const sw = size / scale;
|
|
47
|
+
const sh = size / scale;
|
|
48
|
+
const sx = (img.width - sw) / 2;
|
|
49
|
+
const sy = (img.height - sh) / 2;
|
|
50
|
+
|
|
51
|
+
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, size, size);
|
|
52
|
+
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
canvas.toBlob(
|
|
55
|
+
(blob) => {
|
|
56
|
+
if (blob) resolve(blob);
|
|
57
|
+
else reject(new Error("Failed to create PNG blob"));
|
|
58
|
+
},
|
|
59
|
+
"image/png",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Process avatar file and upload with favicon variants.
|
|
66
|
+
*
|
|
67
|
+
* @param input - The file input element with `data-avatar-upload` attribute
|
|
68
|
+
* @param file - The selected file
|
|
69
|
+
*/
|
|
70
|
+
async function handleAvatarUpload(
|
|
71
|
+
input: HTMLInputElement,
|
|
72
|
+
file: File,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
// Find the parent form for the loading button
|
|
75
|
+
const form = input.closest("form");
|
|
76
|
+
const label = form?.querySelector("label");
|
|
77
|
+
const originalText = label?.textContent ?? "";
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
// Show processing state
|
|
81
|
+
if (label)
|
|
82
|
+
label.textContent = input.dataset.textProcessing || "Processing...";
|
|
83
|
+
|
|
84
|
+
// Load the image
|
|
85
|
+
const img = await loadImage(file);
|
|
86
|
+
|
|
87
|
+
// Generate variants in parallel
|
|
88
|
+
const [png16, png32, png180] = await Promise.all([
|
|
89
|
+
resizeToSquarePng(img, 16),
|
|
90
|
+
resizeToSquarePng(img, 32),
|
|
91
|
+
resizeToSquarePng(img, 180),
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
// Encode ICO with 16x16 and 32x32
|
|
95
|
+
const [png16Buf, png32Buf] = await Promise.all([
|
|
96
|
+
png16.arrayBuffer(),
|
|
97
|
+
png32.arrayBuffer(),
|
|
98
|
+
]);
|
|
99
|
+
const icoBlob = encodeIco([
|
|
100
|
+
{ size: 16, png: png16Buf },
|
|
101
|
+
{ size: 32, png: png32Buf },
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
// Show uploading state
|
|
105
|
+
if (label)
|
|
106
|
+
label.textContent = input.dataset.textUploading || "Uploading...";
|
|
107
|
+
|
|
108
|
+
// Build FormData with original + variants
|
|
109
|
+
const formData = new FormData();
|
|
110
|
+
formData.append("file", file);
|
|
111
|
+
formData.append("favicon", icoBlob, "favicon.ico");
|
|
112
|
+
formData.append("appleTouch", png180, "apple-touch-icon.png");
|
|
113
|
+
|
|
114
|
+
// Upload
|
|
115
|
+
const response = await fetch("/dash/settings/avatar", {
|
|
116
|
+
method: "POST",
|
|
117
|
+
body: formData,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
throw new Error("Upload failed");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Redirect on success
|
|
125
|
+
window.location.href = "/dash/settings?saved";
|
|
126
|
+
} catch {
|
|
127
|
+
// Restore button text on error
|
|
128
|
+
if (label) label.textContent = originalText;
|
|
129
|
+
// Show error toast
|
|
130
|
+
const errorMsg =
|
|
131
|
+
input.dataset.textError || "Upload failed. Please try again.";
|
|
132
|
+
const container = document.getElementById("toast-container");
|
|
133
|
+
if (container) {
|
|
134
|
+
const toast = document.createElement("div");
|
|
135
|
+
toast.className = "toast toast-error";
|
|
136
|
+
toast.innerHTML = `<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><span>${errorMsg}</span>`;
|
|
137
|
+
container.appendChild(toast);
|
|
138
|
+
setTimeout(() => {
|
|
139
|
+
toast.classList.add("toast-out");
|
|
140
|
+
toast.addEventListener("animationend", () => toast.remove());
|
|
141
|
+
}, 3000);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Reset file input so the same file can be re-selected
|
|
146
|
+
input.value = "";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Initialize avatar upload via event delegation
|
|
151
|
+
*/
|
|
152
|
+
function initAvatarUpload(): void {
|
|
153
|
+
document.addEventListener("change", (e) => {
|
|
154
|
+
const input = (e.target as HTMLElement).closest(
|
|
155
|
+
"[data-avatar-upload]",
|
|
156
|
+
) as HTMLInputElement | null;
|
|
157
|
+
if (!input?.files?.[0]) return;
|
|
158
|
+
|
|
159
|
+
// Prevent default form submission (Datastar data-on:change)
|
|
160
|
+
e.stopPropagation();
|
|
161
|
+
handleAvatarUpload(input, input.files[0]);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
initAvatarUpload();
|