feat(web): add i18n, light/dark theme switching, and version constant

- Add i18n (English + 简体中文) with a locale store, $t() magic, and a language toggle (persisted)
- Add light/dark/system theme switching via a theme store and [data-theme] token overrides
- Set the theme before paint to avoid a flash of the wrong theme
- Add frontend version constant web/version.js (window.YDROPBX_VERSION) shown in the app footer
- Update AGENTS.md version-control convention to mention web/version.js
- Bump version to 0.1.1 across frontend, webui changelog, and backend constant
This commit is contained in:
2026-08-27 15:21:21 +08:00
parent d84e4dfdba
commit 5bb42a8b8a
14 changed files with 379 additions and 36 deletions
+1 -1
View File
@@ -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;
+120
View File
@@ -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);
})();
+12
View File
@@ -30,4 +30,16 @@ window.Icons = {
empty:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 3-4-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>',
/* Theme */
sun:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>',
moon:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>',
monitor:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>',
/* Language */
globe:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
};
+7 -7
View File
@@ -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);
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Theme manager.
*
* Supports `light` / `dark` / `system`. The resolved theme is written to the
* `data-theme` attribute on <html>, 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);
})();