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
+128
View File
@@ -0,0 +1,128 @@
<script setup>
import { computed, onMounted, ref } 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 tasks = ref([]);
const title = ref('');
const due = ref('');
const priority = ref('normal');
const bookId = ref('');
const pageId = ref('');
const cols = computed(() => ({
todo: tasks.value.filter((t) => t.status === 'todo'),
doing: tasks.value.filter((t) => t.status === 'doing'),
done: tasks.value.filter((t) => t.status === 'done'),
}));
function isOverdue(t) {
return t.status !== 'done' && t.due_at && new Date(t.due_at) < new Date();
}
function fmtDate(d) {
return d ? new Date(d).toLocaleString() : L.t('task.noDue');
}
async function load() {
if (!ws.currentId) return;
const { data } = await api.get(`/tasks/by-workspace/${ws.currentId}`);
tasks.value = data.tasks;
}
async function create() {
if (!title.value.trim()) return;
const { data } = await api.post('/tasks', {
workspace_id: ws.currentId,
title: title.value.trim(),
priority: priority.value,
due_at: due.value || null,
book_id: bookId.value || null,
page_id: pageId.value || null,
});
tasks.value.unshift(data.task);
title.value = '';
due.value = '';
bookId.value = '';
pageId.value = '';
}
async function setStatus(t, status) {
const { data } = await api.patch(`/tasks/${t.id}`, { status });
Object.assign(t, data.task);
}
async function remove(t) {
if (!confirm('🗑️?')) return;
await api.delete(`/tasks/${t.id}`);
tasks.value = tasks.value.filter((x) => x.id !== t.id);
}
onMounted(async () => {
if (!ws.workspaces.length) await ws.fetchWorkspaces();
await ws.fetchBooks();
await ws.fetchPages();
await load();
});
</script>
<template>
<div class="layout">
<Sidebar />
<main class="main">
<h2>✅ {{ L.t('task.title') }}</h2>
<div class="card new-task">
<div class="row wrap">
<input v-model="title" :placeholder="L.t('task.ph')" @keyup.enter="create" style="flex: 2" />
<input v-model="due" type="datetime-local" :title="L.t('task.due')" />
<select v-model="priority" style="max-width: 130px">
<option value="low">🟢 {{ L.t('task.low') }}</option>
<option value="normal">🟡 {{ L.t('task.normal') }}</option>
<option value="high">🔴 {{ L.t('task.high') }}</option>
</select>
</div>
<div class="row wrap">
<select v-model="bookId">
<option value="">{{ L.t('task.book') }}: {{ L.t('task.none') }}</option>
<option v-for="b in ws.books" :key="b.id" :value="b.id">{{ b.icon }} {{ b.title }}</option>
</select>
<select v-model="pageId">
<option value="">{{ L.t('task.page') }}: {{ L.t('task.none') }}</option>
<option v-for="p in ws.pages" :key="p.id" :value="p.id">{{ p.title }}</option>
</select>
<button @click="create">+ {{ L.t('task.new') }}</button>
</div>
</div>
<div class="kanban">
<div v-for="col in ['todo', 'doing', 'done']" :key="col" class="kanban-col">
<h3>{{ L.t(`task.${col}`) }} ({{ cols[col].length }})</h3>
<div v-for="t in cols[col]" :key="t.id" class="card task-card" :class="{ overdue: isOverdue(t) }">
<strong>{{ t.title }}</strong>
<div class="muted">
{{ t.priority === 'high' ? '🔴' : t.priority === 'low' ? '🟢' : '🟡' }}
🕒 {{ fmtDate(t.due_at) }}
<span v-if="isOverdue(t)" class="overdue-tag">{{ L.t('task.overdue') }}</span>
</div>
<div class="muted">
<span v-if="t.book_title">📚 {{ t.book_title }}</span>
<span v-if="t.page_title" class="link" @click="router.push(`/page/${t.page_id}`)">📄 {{ t.page_title }}</span>
</div>
<div class="row wrap">
<button v-if="t.status !== 'todo'" class="ghost mini" @click="setStatus(t, 'todo')">{{ L.t('task.toTodo') }}</button>
<button v-if="t.status === 'todo'" class="ghost mini" @click="setStatus(t, 'doing')">{{ L.t('task.toDoing') }}</button>
<button v-if="t.status === 'doing'" class="ghost mini" @click="setStatus(t, 'done')">{{ L.t('task.toDone') }}</button>
<button v-if="t.status === 'done'" class="ghost mini" @click="setStatus(t, 'todo')">{{ L.t('task.reopen') }}</button>
<button class="ghost mini" @click="remove(t)">🗑️</button>
</div>
</div>
</div>
</div>
</main>
</div>
</template>