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:
@@ -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).
|
||||
|
||||
@@ -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 `<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
|
||||
|
||||
### Changed / 变更
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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>',
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
+46
-15
@@ -8,8 +8,22 @@
|
||||
<link rel="stylesheet" href="/styles/base.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. -->
|
||||
<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/fileSection.js"></script>
|
||||
<script defer src="/components/confirm.js"></script>
|
||||
@@ -19,10 +33,22 @@
|
||||
<div class="app">
|
||||
<header class="app-header">
|
||||
<h1 class="brand">y<span>Dropbox</span></h1>
|
||||
<div class="header-actions">
|
||||
<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>
|
||||
<button class="btn btn-ghost btn-logout" @click="$store.files.logout()">
|
||||
<span class="icon" x-html="Icons.logout"></span>
|
||||
Logout
|
||||
<span x-text="$t('nav.logout')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
@@ -43,13 +69,13 @@
|
||||
@drop.prevent="dragover = false; onDrop($event)">
|
||||
<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-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>
|
||||
|
||||
<template x-if="count === 0">
|
||||
<div class="empty-state">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -61,13 +87,13 @@
|
||||
<div class="file-name" x-text="file.name"></div>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -93,13 +119,13 @@
|
||||
@drop.prevent="dragover = false; onDrop($event)">
|
||||
<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-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>
|
||||
|
||||
<template x-if="count === 0">
|
||||
<div class="empty-state">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -111,10 +137,10 @@
|
||||
<div class="file-name" x-text="file.name"></div>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -141,20 +167,25 @@
|
||||
@click.stop
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title">
|
||||
<h3 class="modal-title" x-text="title"></h3>
|
||||
<p class="modal-body" x-text="message"></p>
|
||||
:aria-label="$t(title)">
|
||||
<h3 class="modal-title" x-text="$t(title)"></h3>
|
||||
<p class="modal-body" x-text="$t(message)"></p>
|
||||
<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"
|
||||
:class="danger ? 'btn-danger' : 'btn-primary'"
|
||||
type="button"
|
||||
x-ref="accept"
|
||||
@click="accept()"
|
||||
x-text="danger ? 'Delete' : 'Confirm'"></button>
|
||||
x-text="$t(danger ? 'confirm.delete' : 'confirm.confirm')"></button>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+33
-5
@@ -7,16 +7,44 @@
|
||||
<link rel="stylesheet" href="/styles/tokens.css">
|
||||
<link rel="stylesheet" href="/styles/base.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>
|
||||
<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-card">
|
||||
<h1>y<span>Dropbox</span></h1>
|
||||
<p>Enter your access token to continue</p>
|
||||
<p x-text="$t('login.subtitle')"></p>
|
||||
<form id="loginForm">
|
||||
<input type="password" id="token" placeholder="Access Token" required autocomplete="current-password">
|
||||
<div class="login-error" id="loginError">Invalid token. Please try again.</div>
|
||||
<button type="submit" class="btn btn-primary">Sign In</button>
|
||||
<input type="password" id="token" :placeholder="$t('login.token')" required autocomplete="current-password">
|
||||
<div class="login-error" id="loginError" x-text="$t('login.error')"></div>
|
||||
<button type="submit" class="btn btn-primary" x-text="$t('login.submit')"></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user