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
+106
View File
@@ -0,0 +1,106 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue';
const ws = useWorkspaceStore();
const L = useLocaleStore();
const router = useRouter();
const cursor = ref(new Date());
cursor.value.setDate(1);
const tasks = ref([]);
const year = computed(() => cursor.value.getFullYear());
const month = computed(() => cursor.value.getMonth());
const monthName = computed(() =>
cursor.value.toLocaleDateString(L.locale === 'zh' ? 'zh-CN' : L.locale === 'de' ? 'de-DE' : 'en-US', {
month: 'long', year: 'numeric',
}),
);
const cells = computed(() => {
const first = new Date(year.value, month.value, 1);
const startDay = (first.getDay() + 6) % 7; // Monday first
const days = new Date(year.value, month.value + 1, 0).getDate();
const arr = [];
for (let i = 0; i < startDay; i++) arr.push(null);
for (let d = 1; d <= days; d++) arr.push(new Date(year.value, month.value, d));
return arr;
});
function dayKey(d) {
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
}
const byDay = computed(() => {
const map = {};
for (const t of tasks.value) {
if (!t.due_at) continue;
const d = new Date(t.due_at);
const k = dayKey(d);
if (!map[k]) map[k] = [];
map[k].push(t);
}
return map;
});
function isToday(d) {
const n = new Date();
return d && d.toDateString() === n.toDateString();
}
async function load() {
if (!ws.currentId) return;
const from = new Date(year.value, month.value, 1).toISOString();
const to = new Date(year.value, month.value + 1, 0, 23, 59).toISOString();
const { data } = await api.get(`/tasks/by-workspace/${ws.currentId}`, { params: { from, to } });
tasks.value = data.tasks;
}
function prev() { cursor.value = new Date(year.value, month.value - 1, 1); }
function next() { cursor.value = new Date(year.value, month.value + 1, 1); }
function goToday() { const n = new Date(); cursor.value = new Date(n.getFullYear(), n.getMonth(), 1); }
watch(cursor, load);
onMounted(async () => {
if (!ws.workspaces.length) await ws.fetchWorkspaces();
await load();
});
</script>
<template>
<div class="layout">
<Sidebar />
<main class="main">
<div class="topbar">
<h2 style="margin: 0">🗓️ {{ L.t('cal.title') }} — {{ monthName }}</h2>
<div class="row">
<button class="ghost" @click="prev">◀</button>
<button class="ghost" @click="goToday">{{ L.t('cal.today') }}</button>
<button class="ghost" @click="next">▶</button>
</div>
</div>
<div class="calendar">
<div class="cal-head" v-for="d in ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']" :key="d">{{ d }}</div>
<div v-for="(d, i) in cells" :key="i" class="cal-cell" :class="{ today: isToday(d) }">
<template v-if="d">
<div class="cal-day">{{ d.getDate() }}</div>
<div
v-for="t in (byDay[dayKey(d)] || [])" :key="t.id"
class="cal-task" :class="{ done: t.status === 'done' }"
:title="t.title"
@click="router.push('/tasks')"
>
{{ t.status === 'done' ? '✅' : '📌' }} {{ t.title }}
</div>
</template>
</div>
</div>
<p class="muted">📌 {{ L.t('task.due') }} → {{ L.t('cal.title') }} ✨</p>
</main>
</div>
</template>