feat(frontend): settings window, i18n, admin UI, books, tasks, calendar, timer
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s

- Settings window with tabs: profile (name, email, avatar, password),
  appearance (light/dark/system), language (de/en/zh), background
  (gradient presets, custom URL, blur toggle); persisted server-side
- i18n dictionaries (de/en/zh) applied across nav, sidebar, tasks,
  calendar, time tracking, admin, invites and comments
- Greeting header with time-of-day message, live clock and date; cooler
  theme, cards, kanban, calendar grid and data-table styling
- Shared sidebar: nav, collapsible books with per-book pages, top-level
  pages, search; user chip opens settings
- Tasks kanban with priorities, overdue flags and book/page links;
  calendar month view fed by task deadlines; time view with entries,
  durations and totals; top-right live timer with optional book link
- User discovery autocomplete in the invite card; editor gains tables
  and checklists; standalone theme buttons removed
This commit is contained in:
x3
2026-09-22 13:56:26 +02:00
parent b852583499
commit fb396d313a
30 changed files with 1698 additions and 190 deletions
+52
View File
@@ -0,0 +1,52 @@
import { defineStore } from 'pinia';
import api from '../api';
export function formatDuration(totalSeconds) {
const s = Math.max(0, Math.floor(totalSeconds || 0));
const h = String(Math.floor(s / 3600)).padStart(2, '0');
const m = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
const sec = String(s % 60).padStart(2, '0');
return `${h}:${m}:${sec}`;
}
export const useTimeStore = defineStore('time', {
state: () => ({
entries: [],
open: null, // running timer entry (ended_at == null)
workspaceId: null,
}),
getters: {
elapsed: (s) => {
if (!s.open) return 0;
return (Date.now() - new Date(s.open.started_at).getTime()) / 1000;
},
},
actions: {
async fetch(workspaceId) {
this.workspaceId = workspaceId;
const { data } = await api.get(`/time/by-workspace/${workspaceId}`);
this.entries = data.entries;
this.open = data.open;
},
async start(description = '', book_id = null) {
const { data } = await api.post('/time/start', {
workspace_id: this.workspaceId, description, book_id,
});
this.open = data.entry;
this.entries.unshift(data.entry);
return data.entry;
},
async stop() {
const { data } = await api.post('/time/stop', { workspace_id: this.workspaceId });
this.open = null;
const idx = this.entries.findIndex((e) => e.id === data.entry.id);
if (idx >= 0) this.entries[idx] = { ...data.entry, user_name: this.entries[idx].user_name };
else this.entries.unshift(data.entry);
return data.entry;
},
async remove(id) {
await api.delete(`/time/${id}`);
this.entries = this.entries.filter((e) => e.id !== id);
},
},
});