From d84e4dfdba6942b93106d322c5cf558a9a9aac4c Mon Sep 17 00:00:00 2001 From: XingfenD Date: Thu, 27 Aug 2026 15:03:09 +0800 Subject: [PATCH 1/2] feat(web): componentize frontend and replace native delete confirm - Split monolithic app.js/style.css into Alpine.js components (icons, store, fileSection, confirm) - Add central Alpine.store('files') as single source of truth for file lists and actions - Replace duplicated Inbox/Outbox markup with reusable fileSection component (props: dir, canShare) - Add shared window.Icons module to remove duplicated inline SVGs - Replace browser-native confirm() for file deletion with a styled, accessible modal dialog - Split CSS into design tokens, base, and component layers; modernize visuals - Update web.go embed and server static routes for the new component/style dirs --- docs/CHANGELOG_webui.md | 13 + internal/server/server.go | 7 +- web/app.js | 69 ------ web/components/confirm.js | 85 +++++++ web/components/fileSection.js | 44 ++++ web/components/icons.js | 33 +++ web/components/store.js | 88 +++++++ web/index.html | 256 +++++++++++--------- web/login.html | 4 +- web/style.css | 340 --------------------------- web/styles/base.css | 54 +++++ web/styles/components.css | 430 ++++++++++++++++++++++++++++++++++ web/styles/tokens.css | 63 +++++ web/web.go | 2 +- 14 files changed, 966 insertions(+), 522 deletions(-) delete mode 100644 web/app.js create mode 100644 web/components/confirm.js create mode 100644 web/components/fileSection.js create mode 100644 web/components/icons.js create mode 100644 web/components/store.js delete mode 100644 web/style.css create mode 100644 web/styles/base.css create mode 100644 web/styles/components.css create mode 100644 web/styles/tokens.css diff --git a/docs/CHANGELOG_webui.md b/docs/CHANGELOG_webui.md index 541ba7e..619804f 100644 --- a/docs/CHANGELOG_webui.md +++ b/docs/CHANGELOG_webui.md @@ -6,6 +6,19 @@ 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.0] - 2026-08-27 + +### Changed / 变更 + +- Refactored the vanilla frontend into a component-based structure (Alpine.js components, a central state store, and a shared icon module) while keeping the no-build, Go-served static-file approach. +- 将原生前端重构为组件化结构(基于 Alpine.js 的组件、集中式状态 store、共享图标模块),同时保留由 Go 直接托管、无需构建的静态文件方案。 + +- Replaced the duplicated Inbox/Outbox markup with a single reusable `fileSection` component configured by props (`dir`, `canShare`). +- 用同一个可复用的 `fileSection` 组件(按属性 `dir`、`canShare` 配置)替换了原先重复的收件箱/发件箱标记。 + +- Split styles into design tokens, base, and component layers under `web/styles/`, and moved UI icons into a shared `window.Icons` module. +- 将样式拆分为 `web/styles/` 下的设计令牌、基础样式与组件样式三层,并把界面图标统一收敛到共享的 `window.Icons` 模块。 + ## [0.0.1] - 2026-04-28 ### Added / 新增 diff --git a/internal/server/server.go b/internal/server/server.go index 590c231..b825a4d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -69,9 +69,10 @@ func (s *Server) Handler() http.Handler { w.Write(data) })) - mux.Handle("GET /style.css", http.FileServerFS(web.FS)) - mux.Handle("GET /app.js", http.FileServerFS(web.FS)) - mux.Handle("GET /alpine.min.js", http.FileServerFS(web.FS)) + static := http.FileServerFS(web.FS) + mux.Handle("GET /components/", static) + mux.Handle("GET /styles/", static) + mux.Handle("GET /alpine.min.js", static) return mux } diff --git a/web/app.js b/web/app.js deleted file mode 100644 index 552cb3c..0000000 --- a/web/app.js +++ /dev/null @@ -1,69 +0,0 @@ -function app() { - return { - inboxFiles: [], - outboxFiles: [], - dragover: false, - toast: { show: false, message: '' }, - async loadFiles() { - const [inbox, outbox] = await Promise.all([ - fetch('/api/files?dir=inbox').then(r => r.json()), - fetch('/api/files?dir=outbox').then(r => r.json()) - ]); - this.inboxFiles = inbox.files || []; - this.outboxFiles = outbox.files || []; - }, - showToast(msg) { - this.toast.message = msg; - this.toast.show = true; - setTimeout(() => { this.toast.show = false; }, 2500); - }, - async upload(event, dir) { - const files = event.target.files; - if (!files || !files.length) return; - for (const file of files) { - const form = new FormData(); - form.append('file', file); - form.append('dir', dir); - await fetch('/api/upload', { method: 'POST', body: form }); - } - event.target.value = ''; - this.showToast('File uploaded'); - this.loadFiles(); - }, - async uploadDropped(event, dir) { - const files = event.dataTransfer.files; - if (!files || !files.length) return; - for (const file of files) { - const form = new FormData(); - form.append('file', file); - form.append('dir', dir); - await fetch('/api/upload', { method: 'POST', body: form }); - } - this.showToast('File uploaded'); - this.loadFiles(); - }, - download(id) { - window.location.href = '/api/files/' + id + '/download'; - }, - async deleteFile(id) { - if (!confirm('Delete this file?')) return; - await fetch('/api/files/' + id, { method: 'DELETE' }); - this.showToast('File deleted'); - this.loadFiles(); - }, - async share(id) { - const res = await fetch('/api/files/' + id + '/share', { method: 'POST' }); - const data = await res.json(); - try { - await navigator.clipboard.writeText(data.url); - this.showToast('Share link copied to clipboard'); - } catch (_) { - alert('Share URL: ' + data.url); - } - }, - async logout() { - await fetch('/api/logout', { method: 'POST' }); - window.location.href = '/login'; - } - }; -} diff --git a/web/components/confirm.js b/web/components/confirm.js new file mode 100644 index 0000000..d150a28 --- /dev/null +++ b/web/components/confirm.js @@ -0,0 +1,85 @@ +/** + * Confirmation dialog (a promise-based modal store + UI component). + * + * Replaces the browser-native `confirm()` with a styled, accessible modal. + * Components request confirmation via `Alpine.store('confirm').show(opts)`, + * which resolves `true`/`false` when the user acts — mirroring a UI-library + * dialog API. + */ +(function () { + function register() { + Alpine.store('confirm', { + open: false, + title: '', + message: '', + danger: false, + _resolve: null, + + show(opts) { + this.title = opts.title || 'Confirm'; + this.message = opts.message || ''; + this.danger = opts.danger || false; + this.open = true; + return new Promise((resolve) => { + this._resolve = resolve; + }); + }, + + accept() { + this._close(true); + }, + + cancel() { + this._close(false); + }, + + _close(result) { + this.open = false; + const resolve = this._resolve; + this._resolve = null; + if (resolve) resolve(result); + }, + }); + + Alpine.data('confirmDialog', () => ({ + get store() { + return Alpine.store('confirm'); + }, + get open() { + return this.store.open; + }, + get title() { + return this.store.title; + }, + get message() { + return this.store.message; + }, + get danger() { + return this.store.danger; + }, + + accept() { + this.store.accept(); + }, + cancel() { + this.store.cancel(); + }, + onEscape() { + if (this.open) this.cancel(); + }, + + init() { + this.$watch('open', (visible) => { + if (visible) { + this.$nextTick(() => { + if (this.$refs.accept) this.$refs.accept.focus(); + }); + } + }); + }, + })); + } + + if (window.Alpine) register(); + else document.addEventListener('alpine:init', register); +})(); diff --git a/web/components/fileSection.js b/web/components/fileSection.js new file mode 100644 index 0000000..6b687f9 --- /dev/null +++ b/web/components/fileSection.js @@ -0,0 +1,44 @@ +/** + * fileSection component. + * + * A single reusable component parametrized by `dir` and `canShare`. Both the + * Inbox and Outbox on the page are the same component instance with different + * props, replacing the previously copy-pasted
blocks. + * + * Usage: + *
...
+ */ +(function () { + function register() { + Alpine.data('fileSection', (dir, opts = {}) => ({ + dir, + canShare: opts.canShare ?? false, + dragover: false, + + get files() { + return Alpine.store('files').files[dir]; + }, + get count() { + return this.files.length; + }, + get title() { + return dir === 'inbox' ? 'Inbox' : 'Outbox'; + }, + get icon() { + return dir === 'inbox' ? window.Icons.inbox : window.Icons.outbox; + }, + + async onSelect(e) { + await Alpine.store('files').upload(this.dir, e.target.files); + e.target.value = ''; + }, + + async onDrop(e) { + await Alpine.store('files').upload(this.dir, e.dataTransfer.files); + }, + })); + } + + if (window.Alpine) register(); + else document.addEventListener('alpine:init', register); +})(); diff --git a/web/components/icons.js b/web/components/icons.js new file mode 100644 index 0000000..d77d4b4 --- /dev/null +++ b/web/components/icons.js @@ -0,0 +1,33 @@ +/** + * Central icon set. Each entry is an inline SVG string (currentColor aware) + * injected via Alpine's x-html. Keeping icons here prevents the duplicated + * markup that previously littered every list row and header. + */ +window.Icons = { + logout: + '', + + inbox: + '', + + outbox: + '', + + file: + '', + + download: + '', + + share: + '', + + trash: + '', + + upload: + '', + + empty: + '', +}; diff --git a/web/components/store.js b/web/components/store.js new file mode 100644 index 0000000..ee18dbe --- /dev/null +++ b/web/components/store.js @@ -0,0 +1,88 @@ +/** + * Global state store (Alpine.store). + * + * Single source of truth for the file lists and shared actions, analogous to a + * Pinia/Redux store in a framework app. Components read reactive getters and + * dispatch actions here instead of owning their own duplicate state. + */ +(function () { + function register() { + Alpine.store('files', { + files: { inbox: [], outbox: [] }, + toast: { show: false, message: '', _timer: null }, + + async init() { + await this.loadFiles(); + }, + + async loadFiles() { + try { + const [inbox, outbox] = await Promise.all([ + fetch('/api/files?dir=inbox').then((r) => r.json()), + fetch('/api/files?dir=outbox').then((r) => r.json()), + ]); + this.files.inbox = inbox.files || []; + this.files.outbox = outbox.files || []; + } catch (e) { + this.showToast('Failed to load files'); + } + }, + + showToast(message) { + this.toast.message = message; + this.toast.show = true; + if (this.toast._timer) clearTimeout(this.toast._timer); + this.toast._timer = setTimeout(() => { + this.toast.show = false; + }, 2500); + }, + + async upload(dir, fileList) { + if (!fileList || !fileList.length) return; + for (const file of fileList) { + const form = new FormData(); + form.append('file', file); + form.append('dir', dir); + await fetch('/api/upload', { method: 'POST', body: form }); + } + this.showToast('File uploaded'); + await this.loadFiles(); + }, + + download(id) { + window.location.href = '/api/files/' + id + '/download'; + }, + + async deleteFile(id) { + const confirmed = await Alpine.store('confirm').show({ + title: 'Delete file', + message: 'This file will be permanently removed. Continue?', + danger: true, + }); + if (!confirmed) return; + await fetch('/api/files/' + id, { method: 'DELETE' }); + this.showToast('File deleted'); + await this.loadFiles(); + }, + + async share(id) { + const res = await fetch('/api/files/' + id + '/share', { method: 'POST' }); + const data = await res.json(); + try { + await navigator.clipboard.writeText(data.url); + this.showToast('Share link copied to clipboard'); + } catch (_) { + window.prompt('Share URL:', data.url); + } + }, + + async logout() { + await fetch('/api/logout', { method: 'POST' }); + window.location.href = '/login'; + }, + }); + } + + if (window.Alpine) register(); + else document.addEventListener('alpine:init', register); +})(); diff --git a/web/index.html b/web/index.html index 4c717b7..a254a0e 100644 --- a/web/index.html +++ b/web/index.html @@ -1,120 +1,160 @@ - + - - - yDropbox - - + + + yDropbox + + + + + + + + + + - -
-
-

yDropbox

- -
+ +
+
+

yDropbox

+ +
-
-
-

- - Inbox - -

-
-
-
- -
- -
-
Click to upload or drag and drop
-
+
+ +
+
+

+ + + +

+
- +
+ +
+
Click to upload or drag and drop
+
-
    - -
-
-
+ -
-
-

- - Outbox - -

-
-
- +
    + +
+
-
    - -
-
-
+ +
+
+

+ + + +

+
-
+
+ +
+
Click to upload or drag and drop
+
+ + + + +
+ + +
+ + + - + diff --git a/web/login.html b/web/login.html index e03a82c..c5e64da 100644 --- a/web/login.html +++ b/web/login.html @@ -4,7 +4,9 @@ yDropbox Login - + + +
diff --git a/web/style.css b/web/style.css deleted file mode 100644 index 3140df4..0000000 --- a/web/style.css +++ /dev/null @@ -1,340 +0,0 @@ -*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} - -:root { - --bg: #f5f6f8; - --surface: #fff; - --border: #e2e5ea; - --text: #1a1d23; - --text-secondary: #6b7280; - --primary: #2563eb; - --primary-hover: #1d4ed8; - --danger: #ef4444; - --danger-hover: #dc2626; - --success: #10b981; - --radius: 10px; - --shadow: 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04); - --shadow-lg: 0 4px 12px rgba(0,0,0,.08); -} - -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: var(--bg); - color: var(--text); - line-height: 1.5; - min-height: 100vh; -} - -.app { - max-width: 720px; - margin: 0 auto; - padding: 24px 20px 60px; -} - -header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 32px; -} - -header h1 { - font-size: 22px; - font-weight: 700; - letter-spacing: -.3px; - color: var(--text); -} - -header h1 span { - color: var(--primary); -} - -.btn { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 7px 14px; - font-size: 13px; - font-weight: 500; - border: 1px solid var(--border); - border-radius: 7px; - background: var(--surface); - color: var(--text); - cursor: pointer; - transition: all .15s ease; - white-space: nowrap; -} - -.btn:hover { background: #f0f1f3; } - -.btn-primary { - background: var(--primary); - color: #fff; - border-color: var(--primary); -} - -.btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); } - -.btn-danger { - color: var(--danger); - border-color: #fecaca; -} - -.btn-danger:hover { background: #fef2f2; border-color: var(--danger); } - -.btn-ghost { - border-color: transparent; - background: transparent; -} - -.btn-ghost:hover { background: #f0f1f3; } - -.btn-sm { padding: 5px 10px; font-size: 12px; } - -.btn-logout { - border-color: transparent; - color: var(--text-secondary); - font-size: 13px; -} - -.btn-logout:hover { color: var(--text); background: #f0f1f3; } - -.section { - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius); - box-shadow: var(--shadow); - margin-bottom: 20px; - overflow: hidden; -} - -.section-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px 20px; - border-bottom: 1px solid var(--border); -} - -.section-header h2 { - font-size: 15px; - font-weight: 600; - display: flex; - align-items: center; - gap: 8px; -} - -.section-header .badge { - font-size: 11px; - font-weight: 600; - background: var(--bg); - color: var(--text-secondary); - padding: 2px 7px; - border-radius: 10px; -} - -.section-body { padding: 0; } - -.drop-zone { - margin: 16px 20px; - padding: 28px 20px; - border: 2px dashed var(--border); - border-radius: 8px; - text-align: center; - cursor: pointer; - transition: all .2s ease; - position: relative; -} - -.drop-zone:hover, .drop-zone.dragover { - border-color: var(--primary); - background: #eff6ff; -} - -.drop-zone input[type="file"] { - position: absolute; - inset: 0; - opacity: 0; - cursor: pointer; -} - -.drop-zone-icon { - font-size: 24px; - margin-bottom: 6px; - color: var(--text-secondary); -} - -.drop-zone-text { - font-size: 13px; - color: var(--text-secondary); -} - -.drop-zone-text strong { - color: var(--primary); -} - -.file-list { list-style: none; } - -.file-row { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 20px; - border-bottom: 1px solid #f3f4f6; - transition: background .1s ease; -} - -.file-row:last-child { border-bottom: none; } -.file-row:hover { background: #fafbfc; } - -.file-icon { - width: 36px; - height: 36px; - border-radius: 8px; - background: #eff6ff; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - flex-shrink: 0; -} - -.file-info { - flex: 1; - min-width: 0; -} - -.file-name { - font-size: 14px; - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.file-meta { - font-size: 12px; - color: var(--text-secondary); -} - -.file-actions { - display: flex; - gap: 4px; - flex-shrink: 0; - opacity: 0; - transition: opacity .15s ease; -} - -.file-row:hover .file-actions { opacity: 1; } - -.empty-state { - padding: 40px 20px; - text-align: center; - color: var(--text-secondary); -} - -.empty-state-icon { - font-size: 32px; - margin-bottom: 8px; - opacity: .5; -} - -.empty-state-text { - font-size: 14px; -} - -.login-wrap { - display: flex; - align-items: center; - justify-content: center; - min-height: 100vh; - padding: 20px; -} - -.login-card { - width: 100%; - max-width: 360px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: 12px; - box-shadow: var(--shadow-lg); - padding: 40px 32px; - text-align: center; -} - -.login-card h1 { - font-size: 24px; - font-weight: 700; - margin-bottom: 4px; -} - -.login-card h1 span { color: var(--primary); } - -.login-card p { - font-size: 14px; - color: var(--text-secondary); - margin-bottom: 28px; -} - -.login-card form { - display: flex; - flex-direction: column; - gap: 12px; -} - -.login-card input { - padding: 10px 14px; - font-size: 14px; - border: 1px solid var(--border); - border-radius: 8px; - outline: none; - transition: border-color .15s ease; - width: 100%; -} - -.login-card input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,.1); } - -.login-card .btn-primary { - width: 100%; - padding: 10px; - font-size: 14px; - justify-content: center; -} - -.login-error { - font-size: 13px; - color: var(--danger); - display: none; -} - -.login-error.show { display: block; } - -.toast { - position: fixed; - bottom: 24px; - left: 50%; - transform: translateX(-50%) translateY(80px); - background: var(--text); - color: #fff; - padding: 10px 20px; - border-radius: 8px; - font-size: 13px; - font-weight: 500; - box-shadow: var(--shadow-lg); - opacity: 0; - transition: all .3s ease; - z-index: 1000; - pointer-events: none; -} - -.toast.show { - opacity: 1; - transform: translateX(-50%) translateY(0); -} - -@media (max-width: 600px) { - .app { padding: 16px 12px 40px; } - .section-header { padding: 12px 16px; } - .file-row { padding: 10px 16px; } - .file-actions { opacity: 1; } - .drop-zone { margin: 12px 16px; } -} diff --git a/web/styles/base.css b/web/styles/base.css new file mode 100644 index 0000000..889eb16 --- /dev/null +++ b/web/styles/base.css @@ -0,0 +1,54 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + font-family: var(--font-sans); + background: var(--color-bg); + background-image: var(--color-bg-gradient); + background-attachment: fixed; + color: var(--color-text); + line-height: 1.5; + min-height: 100vh; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +button { + font-family: inherit; +} + +[x-cloak] { + display: none !important; +} + +a { + color: inherit; +} + +:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +/* ===== App shell ===== */ +.app { + max-width: 760px; + margin: 0 auto; + padding: var(--space-8) var(--space-5) var(--space-16); +} + +@media (max-width: 600px) { + .app { + padding: var(--space-5) var(--space-4) var(--space-16); + } +} diff --git a/web/styles/components.css b/web/styles/components.css new file mode 100644 index 0000000..9d44d23 --- /dev/null +++ b/web/styles/components.css @@ -0,0 +1,430 @@ +/* ============================================================ + Components + ============================================================ */ + +/* ----- Icon helper (x-html injected SVGs) ----- */ +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; +} +.icon svg { + display: block; + width: 1em; + height: 1em; +} + +/* ----- Header ----- */ +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-8); +} + +.brand { + font-size: var(--text-2xl); + font-weight: 700; + letter-spacing: -0.4px; + color: var(--color-text); +} +.brand span { + color: var(--color-primary); +} + +/* ----- Buttons ----- */ +.btn { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + font-size: var(--text-sm); + font-weight: 500; + line-height: 1; + color: var(--color-text); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast), + color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast); + white-space: nowrap; + user-select: none; +} +.btn:hover { + background: var(--color-surface-hover); +} +.btn:active { + transform: translateY(1px); +} + +.btn-primary { + background: var(--color-primary); + border-color: var(--color-primary); + color: #fff; +} +.btn-primary:hover { + background: var(--color-primary-hover); + border-color: var(--color-primary-hover); +} + +.btn-danger { + color: var(--color-danger); + border-color: #fecaca; +} +.btn-danger:hover { + background: var(--color-danger-soft); + border-color: var(--color-danger); +} + +.btn-ghost { + border-color: transparent; + background: transparent; +} +.btn-ghost:hover { + background: var(--color-surface-hover); +} + +.btn-sm { + padding: var(--space-2) var(--space-3); + font-size: var(--text-xs); +} + +.btn-logout { + color: var(--color-text-secondary); + font-size: var(--text-sm); +} +.btn-logout:hover { + color: var(--color-text); +} + +/* Icon-only button */ +.btn-icon { + padding: var(--space-2); + color: var(--color-text-secondary); +} +.btn-icon:hover { + color: var(--color-text); + background: var(--color-surface-hover); +} +.btn-icon.btn-danger:hover { + color: var(--color-danger); + background: var(--color-danger-soft); +} + +/* ----- Section (card) ----- */ +.section { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + margin-bottom: var(--space-5); + overflow: hidden; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--color-border); +} +.section-header h2 { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-lg); + font-weight: 600; +} +.section-header h2 .icon { + font-size: 1.05em; + color: var(--color-primary); +} + +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 var(--space-2); + font-size: var(--text-xs); + font-weight: 600; + color: var(--color-text-secondary); + background: var(--color-bg); + border-radius: var(--radius-full); +} + +/* ----- Drop zone ----- */ +.drop-zone { + position: relative; + margin: var(--space-4) var(--space-5); + padding: var(--space-8) var(--space-5); + text-align: center; + border: 2px dashed var(--color-border-strong); + border-radius: var(--radius-md); + cursor: pointer; + transition: border-color var(--transition-base), background var(--transition-base); +} +.drop-zone:hover, +.drop-zone.is-dragover { + border-color: var(--color-primary); + background: var(--color-primary-soft); +} +.drop-zone-input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} +.drop-zone-icon { + font-size: 1.6rem; + color: var(--color-text-tertiary); + margin-bottom: var(--space-2); + transition: color var(--transition-base); +} +.drop-zone:hover .drop-zone-icon, +.drop-zone.is-dragover .drop-zone-icon { + color: var(--color-primary); +} +.drop-zone-text { + font-size: var(--text-sm); + color: var(--color-text-secondary); +} +.drop-zone-text strong { + color: var(--color-primary); + font-weight: 600; +} + +/* ----- File list ----- */ +.file-list { + list-style: none; +} + +.file-row { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-5); + border-bottom: 1px solid #f3f4f6; + transition: background var(--transition-fast); +} +.file-row:last-child { + border-bottom: none; +} +.file-row:hover { + background: var(--color-surface-hover); +} + +.file-icon { + display: flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + flex-shrink: 0; + font-size: 18px; + color: var(--color-primary); + background: var(--color-primary-soft); + border-radius: var(--radius-md); +} + +.file-info { + flex: 1; + min-width: 0; +} +.file-name { + font-size: var(--text-sm); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-actions { + display: flex; + gap: var(--space-1); + flex-shrink: 0; + opacity: 0; + transition: opacity var(--transition-fast); +} +.file-row:hover .file-actions, +.file-row:focus-within .file-actions { + opacity: 1; +} + +/* ----- Empty state ----- */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + padding: var(--space-10) var(--space-5); + text-align: center; + color: var(--color-text-secondary); +} +.empty-state-icon { + font-size: 2rem; + color: var(--color-text-tertiary); + opacity: 0.6; +} +.empty-state-text { + font-size: var(--text-sm); +} + +/* ----- Toast ----- */ +.toast { + position: fixed; + left: 50%; + bottom: var(--space-6); + transform: translateX(-50%) translateY(120%); + padding: var(--space-3) var(--space-5); + font-size: var(--text-sm); + font-weight: 500; + color: #fff; + background: var(--color-text); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + opacity: 0; + pointer-events: none; + z-index: var(--z-toast); + transition: transform var(--transition-slow), opacity var(--transition-slow); +} +.toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +@media (prefers-reduced-motion: reduce) { + * { + transition-duration: 0.01ms !important; + } +} + +/* ----- Modal (confirmation dialog) ----- */ +.modal-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-4); + background: rgba(16, 24, 40, 0.45); + backdrop-filter: blur(2px); + z-index: var(--z-modal); +} + +.modal { + width: 100%; + max-width: 380px; + padding: var(--space-6); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); +} + +.modal-title { + font-size: var(--text-lg); + font-weight: 600; + margin-bottom: var(--space-2); +} + +.modal-body { + font-size: var(--text-sm); + color: var(--color-text-secondary); + margin-bottom: var(--space-6); +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); +} + +/* ----- Login ----- */ +.login-wrap { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: var(--space-5); +} +.login-card { + width: 100%; + max-width: 380px; + padding: var(--space-10) var(--space-8); + text-align: center; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); +} +.login-card h1 { + font-size: var(--text-2xl); + font-weight: 700; + margin-bottom: var(--space-1); +} +.login-card h1 span { + color: var(--color-primary); +} +.login-card p { + font-size: var(--text-sm); + color: var(--color-text-secondary); + margin-bottom: var(--space-6); +} +.login-card form { + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.login-card input { + width: 100%; + padding: var(--space-3) var(--space-4); + font-size: var(--text-base); + color: var(--color-text); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + outline: none; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} +.login-card input:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); +} +.login-card .btn-primary { + justify-content: center; + padding: var(--space-3); + font-size: var(--text-base); +} +.login-error { + display: none; + font-size: var(--text-sm); + color: var(--color-danger); +} +.login-error.show { + display: block; +} + +/* ----- Responsive ----- */ +@media (max-width: 600px) { + .section-header { + padding: var(--space-3) var(--space-4); + } + .file-row { + padding: var(--space-3) var(--space-4); + } + .file-actions { + opacity: 1; + } + .drop-zone { + margin: var(--space-3) var(--space-4); + } + .login-card { + padding: var(--space-8) var(--space-5); + } +} diff --git a/web/styles/tokens.css b/web/styles/tokens.css new file mode 100644 index 0000000..1a92034 --- /dev/null +++ b/web/styles/tokens.css @@ -0,0 +1,63 @@ +:root { + /* ===== Color ===== */ + --color-bg: #f4f5f7; + --color-bg-gradient: radial-gradient(1200px 600px at 50% -10%, #eef2ff 0%, var(--color-bg) 55%); + --color-surface: #ffffff; + --color-surface-hover: #fafbfc; + --color-border: #e6e8ec; + --color-border-strong: #d4d8df; + + --color-text: #1a1d23; + --color-text-secondary: #6b7280; + --color-text-tertiary: #9aa1ad; + + --color-primary: #2563eb; + --color-primary-hover: #1d4ed8; + --color-primary-soft: #eff6ff; + + --color-danger: #ef4444; + --color-danger-hover: #dc2626; + --color-danger-soft: #fef2f2; + + --color-success: #10b981; + + /* ===== Spacing (4px base) ===== */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-16: 4rem; + + /* ===== Radius ===== */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-full: 9999px; + + /* ===== Shadow ===== */ + --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.05); + --shadow-md: 0 4px 12px rgba(16, 24, 40, 0.08); + --shadow-lg: 0 16px 40px rgba(16, 24, 40, 0.14); + + /* ===== Typography ===== */ + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.25rem; + --text-2xl: 1.5rem; + + /* ===== Motion ===== */ + --transition-fast: 150ms ease; + --transition-base: 200ms ease; + --transition-slow: 300ms ease; + + /* ===== z-index ===== */ + --z-toast: 1000; + --z-modal: 1100; +} diff --git a/web/web.go b/web/web.go index 3ffd520..270ec96 100644 --- a/web/web.go +++ b/web/web.go @@ -2,5 +2,5 @@ package web import "embed" -//go:embed login.html index.html style.css app.js alpine.min.js +//go:embed login.html index.html alpine.min.js components styles var FS embed.FS From 5bb42a8b8a8ad0fed0e8cfe26ee8702a8f384b8d Mon Sep 17 00:00:00 2001 From: XingfenD Date: Thu, 27 Aug 2026 15:21:21 +0800 Subject: [PATCH 2/2] feat(web): add i18n, light/dark theme switching, and version constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- AGENTS.md | 2 +- docs/CHANGELOG_webui.md | 16 +++++ internal/server/server.go | 1 + web/components/fileSection.js | 2 +- web/components/i18n.js | 120 ++++++++++++++++++++++++++++++++++ web/components/icons.js | 12 ++++ web/components/store.js | 14 ++-- web/components/theme.js | 64 ++++++++++++++++++ web/index.html | 67 ++++++++++++++----- web/login.html | 38 +++++++++-- web/styles/components.css | 39 ++++++++++- web/styles/tokens.css | 35 ++++++++++ web/version.js | 3 + web/web.go | 2 +- 14 files changed, 379 insertions(+), 36 deletions(-) create mode 100644 web/components/i18n.js create mode 100644 web/components/theme.js create mode 100644 web/version.js 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 @@ + + + + + + + - + +
+ + +
+
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