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
+153
View File
@@ -0,0 +1,153 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { useAuthStore } from '../stores/auth';
import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale';
import { useUiStore } from '../stores/ui';
import PageTree from './PageTree.vue';
import Avatar from './Avatar.vue';
const props = defineProps({ activePageId: String });
const auth = useAuthStore();
const ws = useWorkspaceStore();
const L = useLocaleStore();
const ui = useUiStore();
const router = useRouter();
const newWs = ref('');
const newPage = ref('');
const newBook = ref('');
const search = ref('');
const results = ref([]);
const collapsed = ref(JSON.parse(localStorage.getItem('collapsedBooks') || '{}'));
function toggleBook(id) {
collapsed.value[id] = !collapsed.value[id];
localStorage.setItem('collapsedBooks', JSON.stringify(collapsed.value));
}
async function refresh() {
await ws.fetchBooks();
await ws.fetchPages();
}
async function switchWs(id) {
ws.setCurrent(id);
await refresh();
}
async function createWs() {
if (!newWs.value.trim()) return;
await ws.createWorkspace(newWs.value.trim());
newWs.value = '';
await refresh();
}
async function createPage(bookId = null) {
const title = newPage.value.trim() || 'Untitled';
const { data } = await api.post('/pages', {
workspace_id: ws.currentId, book_id: bookId, title,
});
newPage.value = '';
await ws.fetchPages();
router.push(`/page/${data.page.id}`);
}
async function createBook() {
if (!newBook.value.trim()) return;
await ws.createBook(newBook.value.trim(), '📚');
newBook.value = '';
}
async function deleteBook(id) {
if (!confirm('🗑️?')) return;
await api.delete(`/books/${id}`);
await refresh();
}
async function doSearch() {
if (!search.value.trim()) { results.value = []; return; }
const { data } = await api.get('/pages/search/query', {
params: { q: search.value, workspace_id: ws.currentId },
});
results.value = data.results;
}
function logout() {
auth.logout();
router.push('/login');
}
onMounted(refresh);
</script>
<template>
<aside class="sidebar">
<div class="user-chip">
<Avatar :name="auth.user?.name" :avatar="auth.user?.avatar" :size="32" />
<strong style="flex: 1">{{ auth.user?.name }}</strong>
<button class="ghost icon-btn" @click="ui.openSettings()" title="⚙️">⚙️</button>
</div>
<select :value="ws.currentId" @change="switchWs($event.target.value)">
<option v-for="w in ws.workspaces" :key="w.id" :value="w.id">{{ w.name }} ({{ w.role }})</option>
</select>
<div class="row">
<input v-model="newWs" :placeholder="L.t('side.newWorkspace')" @keyup.enter="createWs" />
<button @click="createWs">+</button>
</div>
<nav class="side-nav">
<router-link to="/">🏠 {{ L.t('nav.dashboard') }}</router-link>
<router-link to="/tasks">✅ {{ L.t('nav.tasks') }}</router-link>
<router-link to="/calendar">🗓️ {{ L.t('nav.calendar') }}</router-link>
<router-link to="/time">⏱️ {{ L.t('nav.time') }}</router-link>
<router-link v-if="auth.user?.isAdmin" to="/admin">🛡️ {{ L.t('nav.admin') }}</router-link>
</nav>
<input v-model="search" :placeholder="L.t('side.searchPh')" @input="doSearch" />
<div v-if="results.length" class="card search-results">
<div v-for="r in results" :key="r.id" class="tree-item" @click="router.push(`/page/${r.id}`)">
🔍 {{ r.title }}
</div>
</div>
<div class="side-label">{{ L.t('side.books') }}</div>
<div class="row">
<input v-model="newBook" :placeholder="L.t('side.newBookPh')" @keyup.enter="createBook" />
<button @click="createBook">+</button>
</div>
<div v-for="b in ws.books" :key="b.id" class="book">
<div class="tree-item book-head" @click="toggleBook(b.id)">
<span>{{ collapsed[b.id] ? '▶' : '▼' }} {{ b.icon }} {{ b.title }}</span>
<span>
<button class="ghost mini" @click.stop="createPage(b.id)" title="+">+</button>
<button class="ghost mini" @click.stop="deleteBook(b.id)" title="🗑️">🗑️</button>
</span>
</div>
<div v-if="!collapsed[b.id]" class="book-pages">
<PageTree
:nodes="ws.treeFor(b.id)" :active-id="activePageId"
@open="router.push(`/page/${$event}`)" @add-child="createPage(b.id)"
/>
</div>
</div>
<div class="side-label">{{ L.t('side.pages') }}</div>
<div class="row">
<input v-model="newPage" :placeholder="L.t('side.newPagePh')" @keyup.enter="createPage()" />
<button @click="createPage()">+</button>
</div>
<PageTree
:nodes="ws.tree()" :active-id="activePageId"
@open="router.push(`/page/${$event}`)" @add-child="createPage()"
/>
<div class="side-footer">
<span class="muted">{{ ws.role }}</span>
<button class="ghost" @click="logout">🚪 {{ L.t('nav.logout') }}</button>
</div>
</aside>
</template>