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
This commit is contained in:
2026-08-27 15:03:09 +08:00
parent 110a8f7d9d
commit d84e4dfdba
14 changed files with 966 additions and 522 deletions
+88
View File
@@ -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);
})();