diff --git a/AGENTS.md b/AGENTS.md index 7401b32..a43de54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,4 +5,4 @@ - Before `git commit`: run `git branch --show-current`. If on `master`, do NOT commit — ask user for a branch name (suggest one based on the changes, e.g. `docs/simplify-branch-workflow`), create it, commit there. - General changes → `docs/CHANGELOG.md`; WebUI changes → `docs/CHANGELOG_webui.md` (files under `web/`). Higher versions on top. ANGELOG entry format: same entry has English line then Chinese line on consecutive lines (no blank line between them); different entries are separated by a blank line. -- Version control: When bumping a version, update both the changelog and the corresponding version constant: `pkg/constant/version.go` for backend. +- Version control: When bumping a version, update both the changelog and the corresponding version constant: `pkg/constant/version.go` for backend(docs/CHANGELOG.md), `web/version.js` for webui(docs/CHANGELOG_webui.md). diff --git a/docs/CHANGELOG_webui.md b/docs/CHANGELOG_webui.md index 619804f..7533182 100644 --- a/docs/CHANGELOG_webui.md +++ b/docs/CHANGELOG_webui.md @@ -6,6 +6,22 @@ All notable changes to this template should be documented in this file. The format loosely follows Keep a Changelog and can be adapted to the team's habits. 本文档参考了 Keep a Changelog 的思路,也可以根据团队习惯调整。 +## [0.1.1] - 2026-08-27 + +### Added / 新增 + +- Added internationalization (i18n): English + 简体中文, with a `locale` store, a `$t()` magic, and a language toggle persisted to localStorage. +- 新增国际化(i18n):支持 English 与简体中文,包含 `locale` store、`$t()` 魔法函数,以及持久化到 localStorage 的语言切换按钮。 + +- Added light/dark/system theme switching via a `theme` store that writes `data-theme` on ``; respects OS preference by default and persists the choice. +- 新增浅色/深色/跟随系统主题切换:通过 `theme` store 在 `` 上写入 `data-theme`,默认跟随系统并持久化用户选择。 + +- Added dark-theme token overrides and a no-flash inline bootstrap script on both pages. +- 为深色模式补充了设计令牌覆盖,并在两个页面加入防止主题闪烁的内联初始化脚本。 + +- Added a frontend version constant (`web/version.js`, exposed as `window.YDROPBX_VERSION`) shown in the app footer, kept in sync with this changelog. +- 新增前端版本常量(`web/version.js`,暴露为 `window.YDROPBX_VERSION`)并显示在应用页脚,与本文档版本保持一致。 + ## [0.1.0] - 2026-08-27 ### Changed / 变更 diff --git a/internal/server/server.go b/internal/server/server.go index b825a4d..296a86f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -72,6 +72,7 @@ func (s *Server) Handler() http.Handler { static := http.FileServerFS(web.FS) mux.Handle("GET /components/", static) mux.Handle("GET /styles/", static) + mux.Handle("GET /version.js", static) mux.Handle("GET /alpine.min.js", static) return mux diff --git a/web/components/fileSection.js b/web/components/fileSection.js index 6b687f9..7f1a24d 100644 --- a/web/components/fileSection.js +++ b/web/components/fileSection.js @@ -22,7 +22,7 @@ return this.files.length; }, get title() { - return dir === 'inbox' ? 'Inbox' : 'Outbox'; + return Alpine.store('locale').t(dir === 'inbox' ? 'inbox' : 'outbox'); }, get icon() { return dir === 'inbox' ? window.Icons.inbox : window.Icons.outbox; diff --git a/web/components/i18n.js b/web/components/i18n.js new file mode 100644 index 0000000..5ffac71 --- /dev/null +++ b/web/components/i18n.js @@ -0,0 +1,120 @@ +/** + * Internationalization (i18n). + * + * - `window.I18n` holds the message catalogs (currently English + 简体中文). + * - The `locale` store owns the active language, persists it to localStorage, + * and exposes `t(key)`. + * - `$t('key')` is registered as an Alpine magic so templates can translate + * inline (e.g. `x-text="$t('nav.logout')"`). + * + * Because `t()` reads the reactive `lang`, any string bound through it updates + * automatically when the language changes. + */ +window.I18n = { + en: { + 'app.name': 'yDropbox', + 'nav.logout': 'Logout', + 'inbox': 'Inbox', + 'outbox': 'Outbox', + 'dropzone.click': 'Click to upload', + 'dropzone.rest': 'or drag and drop', + 'empty.inbox': 'No files in inbox', + 'empty.outbox': 'No files in outbox', + 'action.download': 'Download', + 'action.share': 'Share', + 'action.delete': 'Delete', + 'toast.uploaded': 'File uploaded', + 'toast.deleted': 'File deleted', + 'toast.loadFailed': 'Failed to load files', + 'toast.shareCopied': 'Share link copied to clipboard', + 'confirm.deleteTitle': 'Delete file', + 'confirm.deleteMessage': 'This file will be permanently removed. Continue?', + 'confirm.cancel': 'Cancel', + 'confirm.delete': 'Delete', + 'confirm.confirm': 'Confirm', + 'theme.light': 'Light', + 'theme.dark': 'Dark', + 'theme.system': 'System', + 'lang.label': 'Language', + 'login.subtitle': 'Enter your access token to continue', + 'login.token': 'Access Token', + 'login.submit': 'Sign In', + 'login.error': 'Invalid token. Please try again.', + }, + zh: { + 'app.name': 'yDropbox', + 'nav.logout': '退出登录', + 'inbox': '收件箱', + 'outbox': '发件箱', + 'dropzone.click': '点击上传', + 'dropzone.rest': '或拖拽文件到此处', + 'empty.inbox': '收件箱暂无文件', + 'empty.outbox': '发件箱暂无文件', + 'action.download': '下载', + 'action.share': '分享', + 'action.delete': '删除', + 'toast.uploaded': '文件已上传', + 'toast.deleted': '文件已删除', + 'toast.loadFailed': '文件加载失败', + 'toast.shareCopied': '分享链接已复制到剪贴板', + 'confirm.deleteTitle': '删除文件', + 'confirm.deleteMessage': '该文件将被永久删除,是否继续?', + 'confirm.cancel': '取消', + 'confirm.delete': '删除', + 'confirm.confirm': '确认', + 'theme.light': '浅色', + 'theme.dark': '深色', + 'theme.system': '跟随系统', + 'lang.label': '语言', + 'login.subtitle': '请输入访问令牌以继续', + 'login.token': '访问令牌', + 'login.submit': '登录', + 'login.error': '令牌无效,请重试。', + }, +}; + +(function () { + function register() { + Alpine.store('locale', { + lang: 'en', + + init() { + let saved = null; + try { + saved = localStorage.getItem('yd.locale'); + } catch (e) {} + const browser = (navigator.language || 'en').toLowerCase().startsWith('zh') ? 'zh' : 'en'; + this.lang = saved || browser; + this._applyLangAttr(); + }, + + set(lang) { + this.lang = lang; + try { + localStorage.setItem('yd.locale', lang); + } catch (e) {} + this._applyLangAttr(); + }, + + toggle() { + this.set(this.lang === 'en' ? 'zh' : 'en'); + }, + + t(key) { + const dict = window.I18n[this.lang] || window.I18n.en; + if (dict && dict[key] != null) return dict[key]; + if (window.I18n.en[key] != null) return window.I18n.en[key]; + return key; + }, + + _applyLangAttr() { + document.documentElement.setAttribute('lang', this.lang === 'zh' ? 'zh-CN' : 'en'); + }, + }); + + Alpine.magic('t', () => (key) => Alpine.store('locale').t(key)); + } + + if (window.Alpine) register(); + else document.addEventListener('alpine:init', register); +})(); diff --git a/web/components/icons.js b/web/components/icons.js index d77d4b4..a81a54c 100644 --- a/web/components/icons.js +++ b/web/components/icons.js @@ -30,4 +30,16 @@ window.Icons = { empty: '', + + /* Theme */ + sun: + '', + moon: + '', + monitor: + '', + + /* Language */ + globe: + '', }; diff --git a/web/components/store.js b/web/components/store.js index ee18dbe..e5f3e01 100644 --- a/web/components/store.js +++ b/web/components/store.js @@ -24,12 +24,12 @@ this.files.inbox = inbox.files || []; this.files.outbox = outbox.files || []; } catch (e) { - this.showToast('Failed to load files'); + this.showToast('toast.loadFailed'); } }, showToast(message) { - this.toast.message = message; + this.toast.message = Alpine.store('locale').t(message); this.toast.show = true; if (this.toast._timer) clearTimeout(this.toast._timer); this.toast._timer = setTimeout(() => { @@ -45,7 +45,7 @@ form.append('dir', dir); await fetch('/api/upload', { method: 'POST', body: form }); } - this.showToast('File uploaded'); + this.showToast('toast.uploaded'); await this.loadFiles(); }, @@ -55,13 +55,13 @@ async deleteFile(id) { const confirmed = await Alpine.store('confirm').show({ - title: 'Delete file', - message: 'This file will be permanently removed. Continue?', + title: 'confirm.deleteTitle', + message: 'confirm.deleteMessage', danger: true, }); if (!confirmed) return; await fetch('/api/files/' + id, { method: 'DELETE' }); - this.showToast('File deleted'); + this.showToast('toast.deleted'); await this.loadFiles(); }, @@ -70,7 +70,7 @@ const data = await res.json(); try { await navigator.clipboard.writeText(data.url); - this.showToast('Share link copied to clipboard'); + this.showToast('toast.shareCopied'); } catch (_) { window.prompt('Share URL:', data.url); } diff --git a/web/components/theme.js b/web/components/theme.js new file mode 100644 index 0000000..55a1aba --- /dev/null +++ b/web/components/theme.js @@ -0,0 +1,64 @@ +/** + * Theme manager. + * + * Supports `light` / `dark` / `system`. The resolved theme is written to the + * `data-theme` attribute on , which the CSS token layer keys off of. + * The `system` mode tracks the OS preference live. The choice is persisted to + * localStorage. A tiny inline script in each page sets the initial attribute + * before paint to avoid a flash of the wrong theme. + */ +(function () { + function register() { + Alpine.store('theme', { + mode: 'system', // 'light' | 'dark' | 'system' + resolved: 'light', + + init() { + let saved = null; + try { + saved = localStorage.getItem('yd.theme'); + } catch (e) {} + if (saved === 'light' || saved === 'dark' || saved === 'system') { + this.mode = saved; + } + this.apply(); + + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const onChange = () => { + if (this.mode === 'system') this.apply(); + }; + if (mq.addEventListener) mq.addEventListener('change', onChange); + else if (mq.addListener) mq.addListener(onChange); + }, + + set(mode) { + this.mode = mode; + try { + localStorage.setItem('yd.theme', mode); + } catch (e) {} + this.apply(); + }, + + cycle() { + const order = ['light', 'dark', 'system']; + const next = order[(order.indexOf(this.mode) + 1) % order.length]; + this.set(next); + }, + + icon() { + if (this.mode === 'system') return 'monitor'; + return this.mode === 'dark' ? 'moon' : 'sun'; + }, + + apply() { + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const resolved = this.mode === 'system' ? (mq.matches ? 'dark' : 'light') : this.mode; + this.resolved = resolved; + document.documentElement.setAttribute('data-theme', resolved); + }, + }); + } + + if (window.Alpine) register(); + else document.addEventListener('alpine:init', register); +})(); diff --git a/web/index.html b/web/index.html index a254a0e..cfe5c1f 100644 --- a/web/index.html +++ b/web/index.html @@ -8,8 +8,22 @@ + + + + + + @@ -19,10 +33,22 @@
Enter your access token to continue
+