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 @@

yDropbox

- +
+ + + +
@@ -43,13 +69,13 @@ @drop.prevent="dragover = false; onDrop($event)">
-
Click to upload or drag and drop
+
@@ -61,13 +87,13 @@
- - -
@@ -93,13 +119,13 @@ @drop.prevent="dragover = false; onDrop($event)">
-
Click to upload or drag and drop
+
@@ -111,10 +137,10 @@
- -
@@ -141,20 +167,25 @@ @click.stop role="dialog" aria-modal="true" - :aria-label="title"> - - + :aria-label="$t(title)"> + + + + diff --git a/web/login.html b/web/login.html index c5e64da..0b9965d 100644 --- a/web/login.html +++ b/web/login.html @@ -7,16 +7,44 @@ + + + + + + + - + +
+ + +
+

yDropbox

-

Enter your access token to continue

+

- - - + + +
diff --git a/web/styles/components.css b/web/styles/components.css index 9d44d23..1284320 100644 --- a/web/styles/components.css +++ b/web/styles/components.css @@ -23,6 +23,12 @@ margin-bottom: var(--space-8); } +.header-actions { + display: flex; + align-items: center; + gap: var(--space-2); +} + .brand { font-size: var(--text-2xl); font-weight: 700; @@ -209,7 +215,7 @@ align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-5); - border-bottom: 1px solid #f3f4f6; + border-bottom: 1px solid var(--color-divider); transition: background var(--transition-fast); } .file-row:last-child { @@ -284,8 +290,8 @@ padding: var(--space-3) var(--space-5); font-size: var(--text-sm); font-weight: 500; - color: #fff; - background: var(--color-text); + color: var(--color-toast-fg); + background: var(--color-toast-bg); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); opacity: 0; @@ -304,6 +310,24 @@ } } +/* ----- App footer (version) ----- */ +.app-footer { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + margin-top: var(--space-8); + font-size: var(--text-xs); + color: var(--color-text-tertiary); +} +.app-footer-version { + font-variant-numeric: tabular-nums; + padding: 2px 8px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-full); +} + /* ----- Modal (confirmation dialog) ----- */ .modal-overlay { position: fixed; @@ -346,6 +370,15 @@ } /* ----- Login ----- */ +.page-topbar { + position: fixed; + top: var(--space-4); + right: var(--space-4); + display: flex; + gap: var(--space-2); + z-index: var(--z-toast); +} + .login-wrap { display: flex; align-items: center; diff --git a/web/styles/tokens.css b/web/styles/tokens.css index 1a92034..f69fba3 100644 --- a/web/styles/tokens.css +++ b/web/styles/tokens.css @@ -21,6 +21,13 @@ --color-success: #10b981; + /* Structural tokens */ + --color-divider: #f3f4f6; + --color-toast-bg: #1a1d23; + --color-toast-fg: #ffffff; + + color-scheme: light; + /* ===== Spacing (4px base) ===== */ --space-1: 0.25rem; --space-2: 0.5rem; @@ -61,3 +68,31 @@ --z-toast: 1000; --z-modal: 1100; } + +/* ===== Dark theme ===== */ +[data-theme="dark"] { + --color-bg: #0f1115; + --color-bg-gradient: radial-gradient(1200px 600px at 50% -10%, #1a2030 0%, var(--color-bg) 55%); + --color-surface: #171a21; + --color-surface-hover: #1e222b; + --color-border: #262b35; + --color-border-strong: #323844; + + --color-text: #e6e8ec; + --color-text-secondary: #9aa1ad; + --color-text-tertiary: #6b7280; + + --color-primary: #3b82f6; + --color-primary-hover: #2f6fe0; + --color-primary-soft: #16233f; + + --color-danger: #f87171; + --color-danger-hover: #ef4444; + --color-danger-soft: #2a1717; + + --color-divider: #23262f; + --color-toast-bg: #e6e8ec; + --color-toast-fg: #1a1d23; + + color-scheme: dark; +} diff --git a/web/version.js b/web/version.js new file mode 100644 index 0000000..ec8982e --- /dev/null +++ b/web/version.js @@ -0,0 +1,3 @@ +// Frontend version. Bump this together with the top entry in +// docs/CHANGELOG_webui.md whenever the UI changes. +window.YDROPBX_VERSION = '0.1.1'; diff --git a/web/web.go b/web/web.go index 270ec96..9536ca2 100644 --- a/web/web.go +++ b/web/web.go @@ -2,5 +2,5 @@ package web import "embed" -//go:embed login.html index.html alpine.min.js components styles +//go:embed login.html index.html alpine.min.js version.js components styles var FS embed.FS