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
@@ -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. - 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. - 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. 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).
+16
View File
@@ -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. The format loosely follows Keep a Changelog and can be adapted to the team's habits.
本文档参考了 Keep a Changelog 的思路,也可以根据团队习惯调整。 本文档参考了 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 `<html>`; respects OS preference by default and persists the choice.
- 新增浅色/深色/跟随系统主题切换:通过 `theme` store 在 `<html>` 上写入 `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 ## [0.1.0] - 2026-08-27
### Changed / 变更 ### Changed / 变更
+1
View File
@@ -72,6 +72,7 @@ func (s *Server) Handler() http.Handler {
static := http.FileServerFS(web.FS) static := http.FileServerFS(web.FS)
mux.Handle("GET /components/", static) mux.Handle("GET /components/", static)
mux.Handle("GET /styles/", static) mux.Handle("GET /styles/", static)
mux.Handle("GET /version.js", static)
mux.Handle("GET /alpine.min.js", static) mux.Handle("GET /alpine.min.js", static)
return mux return mux
+1 -1
View File
@@ -22,7 +22,7 @@
return this.files.length; return this.files.length;
}, },
get title() { get title() {
return dir === 'inbox' ? 'Inbox' : 'Outbox'; return Alpine.store('locale').t(dir === 'inbox' ? 'inbox' : 'outbox');
}, },
get icon() { get icon() {
return dir === 'inbox' ? window.Icons.inbox : window.Icons.outbox; 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: 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>', '<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.inbox = inbox.files || [];
this.files.outbox = outbox.files || []; this.files.outbox = outbox.files || [];
} catch (e) { } catch (e) {
this.showToast('Failed to load files'); this.showToast('toast.loadFailed');
} }
}, },
showToast(message) { showToast(message) {
this.toast.message = message; this.toast.message = Alpine.store('locale').t(message);
this.toast.show = true; this.toast.show = true;
if (this.toast._timer) clearTimeout(this.toast._timer); if (this.toast._timer) clearTimeout(this.toast._timer);
this.toast._timer = setTimeout(() => { this.toast._timer = setTimeout(() => {
@@ -45,7 +45,7 @@
form.append('dir', dir); form.append('dir', dir);
await fetch('/api/upload', { method: 'POST', body: form }); await fetch('/api/upload', { method: 'POST', body: form });
} }
this.showToast('File uploaded'); this.showToast('toast.uploaded');
await this.loadFiles(); await this.loadFiles();
}, },
@@ -55,13 +55,13 @@
async deleteFile(id) { async deleteFile(id) {
const confirmed = await Alpine.store('confirm').show({ const confirmed = await Alpine.store('confirm').show({
title: 'Delete file', title: 'confirm.deleteTitle',
message: 'This file will be permanently removed. Continue?', message: 'confirm.deleteMessage',
danger: true, danger: true,
}); });
if (!confirmed) return; if (!confirmed) return;
await fetch('/api/files/' + id, { method: 'DELETE' }); await fetch('/api/files/' + id, { method: 'DELETE' });
this.showToast('File deleted'); this.showToast('toast.deleted');
await this.loadFiles(); await this.loadFiles();
}, },
@@ -70,7 +70,7 @@
const data = await res.json(); const data = await res.json();
try { try {
await navigator.clipboard.writeText(data.url); await navigator.clipboard.writeText(data.url);
this.showToast('Share link copied to clipboard'); this.showToast('toast.shareCopied');
} catch (_) { } catch (_) {
window.prompt('Share URL:', data.url); 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);
})();
+49 -18
View File
@@ -8,8 +8,22 @@
<link rel="stylesheet" href="/styles/base.css"> <link rel="stylesheet" href="/styles/base.css">
<link rel="stylesheet" href="/styles/components.css"> <link rel="stylesheet" href="/styles/components.css">
<!-- Set the theme before paint to avoid a flash of the wrong theme. -->
<script>
(function () {
try {
var t = localStorage.getItem('yd.theme') || 'system';
var dark = t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
} catch (e) {}
})();
</script>
<!-- Components register themselves on `alpine:init`; Alpine is loaded last. --> <!-- Components register themselves on `alpine:init`; Alpine is loaded last. -->
<script defer src="/components/icons.js"></script> <script defer src="/components/icons.js"></script>
<script defer src="/version.js"></script>
<script defer src="/components/i18n.js"></script>
<script defer src="/components/theme.js"></script>
<script defer src="/components/store.js"></script> <script defer src="/components/store.js"></script>
<script defer src="/components/fileSection.js"></script> <script defer src="/components/fileSection.js"></script>
<script defer src="/components/confirm.js"></script> <script defer src="/components/confirm.js"></script>
@@ -19,10 +33,22 @@
<div class="app"> <div class="app">
<header class="app-header"> <header class="app-header">
<h1 class="brand">y<span>Dropbox</span></h1> <h1 class="brand">y<span>Dropbox</span></h1>
<button class="btn btn-ghost btn-logout" @click="$store.files.logout()"> <div class="header-actions">
<span class="icon" x-html="Icons.logout"></span> <button class="btn btn-icon" type="button"
Logout @click="$store.locale.toggle()"
</button> :title="$t('lang.label')" :aria-label="$t('lang.label')">
<span class="icon" x-html="Icons.globe"></span>
</button>
<button class="btn btn-icon" type="button"
@click="$store.theme.cycle()"
:title="$t('theme.' + $store.theme.mode)" :aria-label="$t('theme.' + $store.theme.mode)">
<span class="icon" x-html="Icons[$store.theme.icon()]"></span>
</button>
<button class="btn btn-ghost btn-logout" @click="$store.files.logout()">
<span class="icon" x-html="Icons.logout"></span>
<span x-text="$t('nav.logout')"></span>
</button>
</div>
</header> </header>
<main> <main>
@@ -43,13 +69,13 @@
@drop.prevent="dragover = false; onDrop($event)"> @drop.prevent="dragover = false; onDrop($event)">
<input type="file" class="drop-zone-input" @change="onSelect($event)" multiple> <input type="file" class="drop-zone-input" @change="onSelect($event)" multiple>
<div class="drop-zone-icon"><span class="icon" x-html="Icons.upload"></span></div> <div class="drop-zone-icon"><span class="icon" x-html="Icons.upload"></span></div>
<div class="drop-zone-text"><strong>Click to upload</strong> or drag and drop</div> <div class="drop-zone-text"><strong x-text="$t('dropzone.click')"></strong> <span x-text="$t('dropzone.rest')"></span></div>
</div> </div>
<template x-if="count === 0"> <template x-if="count === 0">
<div class="empty-state"> <div class="empty-state">
<span class="empty-state-icon" x-html="Icons.empty"></span> <span class="empty-state-icon" x-html="Icons.empty"></span>
<div class="empty-state-text" x-text="'No files in ' + title.toLowerCase()"></div> <div class="empty-state-text" x-text="$t('empty.' + dir)"></div>
</div> </div>
</template> </template>
@@ -61,13 +87,13 @@
<div class="file-name" x-text="file.name"></div> <div class="file-name" x-text="file.name"></div>
</div> </div>
<div class="file-actions"> <div class="file-actions">
<button class="btn btn-icon" @click="$store.files.download(file.id)" title="Download"> <button class="btn btn-icon" @click="$store.files.download(file.id)" :title="$t('action.download')">
<span class="icon" x-html="Icons.download"></span> <span class="icon" x-html="Icons.download"></span>
</button> </button>
<button class="btn btn-icon" x-show="canShare" @click="$store.files.share(file.id)" title="Share"> <button class="btn btn-icon" x-show="canShare" @click="$store.files.share(file.id)" :title="$t('action.share')">
<span class="icon" x-html="Icons.share"></span> <span class="icon" x-html="Icons.share"></span>
</button> </button>
<button class="btn btn-icon btn-danger" @click="$store.files.deleteFile(file.id)" title="Delete"> <button class="btn btn-icon btn-danger" @click="$store.files.deleteFile(file.id)" :title="$t('action.delete')">
<span class="icon" x-html="Icons.trash"></span> <span class="icon" x-html="Icons.trash"></span>
</button> </button>
</div> </div>
@@ -93,13 +119,13 @@
@drop.prevent="dragover = false; onDrop($event)"> @drop.prevent="dragover = false; onDrop($event)">
<input type="file" class="drop-zone-input" @change="onSelect($event)" multiple> <input type="file" class="drop-zone-input" @change="onSelect($event)" multiple>
<div class="drop-zone-icon"><span class="icon" x-html="Icons.upload"></span></div> <div class="drop-zone-icon"><span class="icon" x-html="Icons.upload"></span></div>
<div class="drop-zone-text"><strong>Click to upload</strong> or drag and drop</div> <div class="drop-zone-text"><strong x-text="$t('dropzone.click')"></strong> <span x-text="$t('dropzone.rest')"></span></div>
</div> </div>
<template x-if="count === 0"> <template x-if="count === 0">
<div class="empty-state"> <div class="empty-state">
<span class="empty-state-icon" x-html="Icons.empty"></span> <span class="empty-state-icon" x-html="Icons.empty"></span>
<div class="empty-state-text" x-text="'No files in ' + title.toLowerCase()"></div> <div class="empty-state-text" x-text="$t('empty.' + dir)"></div>
</div> </div>
</template> </template>
@@ -111,10 +137,10 @@
<div class="file-name" x-text="file.name"></div> <div class="file-name" x-text="file.name"></div>
</div> </div>
<div class="file-actions"> <div class="file-actions">
<button class="btn btn-icon" @click="$store.files.download(file.id)" title="Download"> <button class="btn btn-icon" @click="$store.files.download(file.id)" :title="$t('action.download')">
<span class="icon" x-html="Icons.download"></span> <span class="icon" x-html="Icons.download"></span>
</button> </button>
<button class="btn btn-icon btn-danger" @click="$store.files.deleteFile(file.id)" title="Delete"> <button class="btn btn-icon btn-danger" @click="$store.files.deleteFile(file.id)" :title="$t('action.delete')">
<span class="icon" x-html="Icons.trash"></span> <span class="icon" x-html="Icons.trash"></span>
</button> </button>
</div> </div>
@@ -141,20 +167,25 @@
@click.stop @click.stop
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
:aria-label="title"> :aria-label="$t(title)">
<h3 class="modal-title" x-text="title"></h3> <h3 class="modal-title" x-text="$t(title)"></h3>
<p class="modal-body" x-text="message"></p> <p class="modal-body" x-text="$t(message)"></p>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-ghost" type="button" @click="cancel()">Cancel</button> <button class="btn btn-ghost" type="button" @click="cancel()" x-text="$t('confirm.cancel')"></button>
<button class="btn" <button class="btn"
:class="danger ? 'btn-danger' : 'btn-primary'" :class="danger ? 'btn-danger' : 'btn-primary'"
type="button" type="button"
x-ref="accept" x-ref="accept"
@click="accept()" @click="accept()"
x-text="danger ? 'Delete' : 'Confirm'"></button> x-text="$t(danger ? 'confirm.delete' : 'confirm.confirm')"></button>
</div> </div>
</div> </div>
</div> </div>
<footer class="app-footer">
<span class="app-footer-brand">yDropbox</span>
<span class="app-footer-version" x-text="'v' + YDROPBX_VERSION"></span>
</footer>
</div> </div>
</body> </body>
</html> </html>
+33 -5
View File
@@ -7,16 +7,44 @@
<link rel="stylesheet" href="/styles/tokens.css"> <link rel="stylesheet" href="/styles/tokens.css">
<link rel="stylesheet" href="/styles/base.css"> <link rel="stylesheet" href="/styles/base.css">
<link rel="stylesheet" href="/styles/components.css"> <link rel="stylesheet" href="/styles/components.css">
<script>
(function () {
try {
var t = localStorage.getItem('yd.theme') || 'system';
var dark = t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
} catch (e) {}
})();
</script>
<script defer src="/components/icons.js"></script>
<script defer src="/components/i18n.js"></script>
<script defer src="/components/theme.js"></script>
<script defer src="/alpine.min.js"></script>
</head> </head>
<body> <body x-data>
<div class="page-topbar">
<button class="btn btn-icon" type="button"
@click="$store.locale.toggle()"
:title="$t('lang.label')" :aria-label="$t('lang.label')">
<span class="icon" x-html="Icons.globe"></span>
</button>
<button class="btn btn-icon" type="button"
@click="$store.theme.cycle()"
:title="$t('theme.' + $store.theme.mode)" :aria-label="$t('theme.' + $store.theme.mode)">
<span class="icon" x-html="Icons[$store.theme.icon()]"></span>
</button>
</div>
<div class="login-wrap"> <div class="login-wrap">
<div class="login-card"> <div class="login-card">
<h1>y<span>Dropbox</span></h1> <h1>y<span>Dropbox</span></h1>
<p>Enter your access token to continue</p> <p x-text="$t('login.subtitle')"></p>
<form id="loginForm"> <form id="loginForm">
<input type="password" id="token" placeholder="Access Token" required autocomplete="current-password"> <input type="password" id="token" :placeholder="$t('login.token')" required autocomplete="current-password">
<div class="login-error" id="loginError">Invalid token. Please try again.</div> <div class="login-error" id="loginError" x-text="$t('login.error')"></div>
<button type="submit" class="btn btn-primary">Sign In</button> <button type="submit" class="btn btn-primary" x-text="$t('login.submit')"></button>
</form> </form>
</div> </div>
</div> </div>
+36 -3
View File
@@ -23,6 +23,12 @@
margin-bottom: var(--space-8); margin-bottom: var(--space-8);
} }
.header-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.brand { .brand {
font-size: var(--text-2xl); font-size: var(--text-2xl);
font-weight: 700; font-weight: 700;
@@ -209,7 +215,7 @@
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-3);
padding: var(--space-3) var(--space-5); padding: var(--space-3) var(--space-5);
border-bottom: 1px solid #f3f4f6; border-bottom: 1px solid var(--color-divider);
transition: background var(--transition-fast); transition: background var(--transition-fast);
} }
.file-row:last-child { .file-row:last-child {
@@ -284,8 +290,8 @@
padding: var(--space-3) var(--space-5); padding: var(--space-3) var(--space-5);
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: 500; font-weight: 500;
color: #fff; color: var(--color-toast-fg);
background: var(--color-text); background: var(--color-toast-bg);
border-radius: var(--radius-md); border-radius: var(--radius-md);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
opacity: 0; 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 (confirmation dialog) ----- */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
@@ -346,6 +370,15 @@
} }
/* ----- Login ----- */ /* ----- 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 { .login-wrap {
display: flex; display: flex;
align-items: center; align-items: center;
+35
View File
@@ -21,6 +21,13 @@
--color-success: #10b981; --color-success: #10b981;
/* Structural tokens */
--color-divider: #f3f4f6;
--color-toast-bg: #1a1d23;
--color-toast-fg: #ffffff;
color-scheme: light;
/* ===== Spacing (4px base) ===== */ /* ===== Spacing (4px base) ===== */
--space-1: 0.25rem; --space-1: 0.25rem;
--space-2: 0.5rem; --space-2: 0.5rem;
@@ -61,3 +68,31 @@
--z-toast: 1000; --z-toast: 1000;
--z-modal: 1100; --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;
}
+3
View File
@@ -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';
+1 -1
View File
@@ -2,5 +2,5 @@ package web
import "embed" 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 var FS embed.FS