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
+14
View File
@@ -0,0 +1,14 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({ name: String, avatar: String, size: { type: Number, default: 26 } });
const isUrl = computed(() => (props.avatar || '').startsWith('http'));
const letter = computed(() => ((props.name || props.avatar || '?')[0] || '?').toUpperCase());
</script>
<template>
<span class="avatar" :style="{ width: size + 'px', height: size + 'px', fontSize: Math.round(size * 0.5) + 'px' }" :title="name">
<img v-if="isUrl" :src="avatar" :alt="name" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover" />
<span v-else-if="avatar">{{ avatar }}</span>
<span v-else>{{ letter }}</span>
</span>
</template>
@@ -0,0 +1,41 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useLocaleStore } from '../stores/locale';
const props = defineProps({ name: String });
const L = useLocaleStore();
const now = ref(new Date());
let timer = null;
onMounted(() => { timer = setInterval(() => (now.value = new Date()), 1000); });
onUnmounted(() => clearInterval(timer));
const period = computed(() => {
const h = now.value.getHours();
if (h >= 5 && h < 11) return { key: 'greet.morning', emoji: '🌅' };
if (h >= 11 && h < 14) return { key: 'greet.midday', emoji: '🍱' };
if (h >= 14 && h < 18) return { key: 'greet.afternoon', emoji: '☀️' };
if (h >= 18 && h < 23) return { key: 'greet.evening', emoji: '🌇' };
return { key: 'greet.night', emoji: '🌙' };
});
const clock = computed(() =>
now.value.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
);
const date = computed(() =>
now.value.toLocaleDateString(
L.locale === 'zh' ? 'zh-CN' : L.locale === 'de' ? 'de-DE' : 'en-US',
{ weekday: 'long', day: 'numeric', month: 'long' },
),
);
</script>
<template>
<div class="greeting">
<div>
<h2 style="margin: 0">{{ period.emoji }} {{ L.t(period.key) }}{{ name ? `, ${name}` : '' }}!</h2>
<p class="muted" style="margin: 4px 0 0">📅 {{ date }}</p>
</div>
<div class="clock">🕒 {{ clock }}</div>
</div>
</template>
+20 -6
View File
@@ -1,21 +1,33 @@
<script setup>
import { ref } from 'vue';
import api from '../api';
import { useLocaleStore } from '../stores/locale';
import UserSearch from './UserSearch.vue';
const props = defineProps({ workspaceId: String });
const emit = defineEmits(['invited']);
const L = useLocaleStore();
const email = ref('');
const picked = ref(null);
const role = ref('member');
const msg = ref('');
const err = ref('');
function onPick(u) {
picked.value = u;
email.value = u.email;
}
async function invite() {
msg.value = '';
err.value = '';
const target = email.value.trim();
if (!target) return;
try {
await api.post(`/workspaces/${props.workspaceId}/members`, { email: email.value, role: role.value });
msg.value = `Invited ${email.value} as ${role.value}`;
await api.post(`/workspaces/${props.workspaceId}/members`, { email: target, role: role.value });
msg.value = `🎉 ${target} → ${role.value}`;
email.value = '';
picked.value = null;
emit('invited');
} catch (e) {
err.value = e.response?.data?.error || 'Invite failed';
@@ -25,17 +37,19 @@ async function invite() {
<template>
<div class="card">
<h4>Invite collaborator</h4>
<p class="muted">They must register first with the same email.</p>
<div class="row">
<h4>{{ L.t('inv.title') }}</h4>
<p class="muted">{{ L.t('inv.hint') }}</p>
<UserSearch @pick="onPick" />
<div class="row" style="margin-top: 8px">
<input v-model="email" placeholder="teammate@example.com" />
<select v-model="role" style="max-width: 130px">
<option value="viewer">viewer</option>
<option value="member">member</option>
<option value="admin">admin</option>
</select>
<button @click="invite">Invite</button>
<button @click="invite">✉️ {{ L.t('inv.btn') }}</button>
</div>
<p v-if="picked" class="muted">🔎 {{ picked.name }} &lt;{{ picked.email }}&gt;</p>
<p v-if="msg" style="color: green">{{ msg }}</p>
<p v-if="err" style="color: red">{{ err }}</p>
</div>
+204
View File
@@ -0,0 +1,204 @@
<script setup>
import { reactive, ref, watch } from 'vue';
import api from '../api';
import { useAuthStore } from '../stores/auth';
import { useLocaleStore } from '../stores/locale';
import { useThemeStore } from '../stores/theme';
import { useUiStore } from '../stores/ui';
import Avatar from './Avatar.vue';
const auth = useAuthStore();
const L = useLocaleStore();
const theme = useThemeStore();
const ui = useUiStore();
const form = reactive({ name: '', email: '', avatar: '' });
const curPw = ref('');
const newPw = ref('');
const msg = ref('');
const err = ref('');
const bgUrl = ref('');
const bgBlur = ref(true);
const bgPreset = ref('none');
const GRADIENTS = {
none: '',
sunset: 'linear-gradient(135deg,#ff9a56,#ff5e78 55%,#a855f7)',
ocean: 'linear-gradient(135deg,#0ea5e9,#6366f1 55%,#14b8a6)',
forest: 'linear-gradient(135deg,#166534,#4d7c0f 60%,#a3a380)',
midnight: 'linear-gradient(135deg,#0f172a,#4c1d95 60%,#0ea5e9)',
};
const EMOJIS = ['🦊', '🐼', '🐸', '🚀', '🌙', '🔥', '🍩', '🎧', '🌵', '⚡'];
watch(
() => ui.settingsOpen,
(open) => {
if (open && auth.user) {
form.name = auth.user.name || '';
form.email = auth.user.email || '';
form.avatar = auth.user.avatar || '';
bgUrl.value = auth.user.bgImage?.startsWith('http') ? auth.user.bgImage : '';
bgPreset.value = Object.entries(GRADIENTS).find(
([, v]) => v && v === auth.user.bgImage,
)?.[0] || (auth.user.bgImage ? 'custom' : 'none');
bgBlur.value = auth.user.bgBlur !== false;
msg.value = '';
err.value = '';
}
},
);
function bgValue() {
if (bgPreset.value === 'custom') return bgUrl.value.trim();
return GRADIENTS[bgPreset.value] || '';
}
function persistUser(user) {
auth.user = user;
localStorage.setItem('user', JSON.stringify(user));
auth.applyPrefs();
}
async function saveProfile() {
msg.value = '';
err.value = '';
try {
const { data } = await api.patch('/users/me', {
name: form.name.trim(), email: form.email.trim(), avatar: form.avatar.trim(),
});
persistUser(data.user);
msg.value = L.t('set.saved');
} catch (e) {
err.value = e.response?.data?.error || 'Error';
}
}
async function changePw() {
msg.value = '';
err.value = '';
try {
await api.patch('/users/me/password', { currentPassword: curPw.value, newPassword: newPw.value });
curPw.value = '';
newPw.value = '';
msg.value = L.t('set.pwChanged');
} catch (e) {
err.value = e.response?.data?.error || 'Error';
}
}
async function setTheme(mode) {
theme.set(mode);
try {
const { data } = await api.patch('/users/me', { theme: mode });
persistUser(data.user);
} catch { /* offline-friendly: theme still applies locally */ }
}
async function setLocale(l) {
L.set(l);
try {
const { data } = await api.patch('/users/me', { locale: l });
persistUser(data.user);
} catch { /* keep local */ }
}
async function saveBackground() {
msg.value = '';
err.value = '';
try {
const { data } = await api.patch('/users/me', { bg_image: bgValue(), bg_blur: bgBlur.value });
persistUser(data.user);
msg.value = L.t('set.saved');
} catch (e) {
err.value = e.response?.data?.error || 'Error';
}
}
</script>
<template>
<div v-if="ui.settingsOpen" class="settings-overlay" @click.self="ui.closeSettings()">
<div class="settings-window">
<div class="settings-titlebar">
<div class="traffic">
<span class="dot red" @click="ui.closeSettings()"></span>
<span class="dot yellow"></span>
<span class="dot green"></span>
</div>
<strong>⚙️ {{ L.t('set.title') }}</strong>
<button class="ghost" @click="ui.closeSettings()">✕ {{ L.t('set.close') }}</button>
</div>
<div class="settings-body">
<nav class="settings-tabs">
<button :class="{ active: ui.settingsTab === 'profile' }" @click="ui.settingsTab = 'profile'">{{ L.t('set.profile') }}</button>
<button :class="{ active: ui.settingsTab === 'appearance' }" @click="ui.settingsTab = 'appearance'">{{ L.t('set.appearance') }}</button>
<button :class="{ active: ui.settingsTab === 'language' }" @click="ui.settingsTab = 'language'">{{ L.t('set.language') }}</button>
<button :class="{ active: ui.settingsTab === 'background' }" @click="ui.settingsTab = 'background'">{{ L.t('set.background') }}</button>
</nav>
<section class="settings-content">
<div v-if="ui.settingsTab === 'profile'" style="display: grid; gap: 10px">
<div class="row">
<Avatar :name="form.name" :avatar="form.avatar" :size="48" />
<div>
<label>{{ L.t('set.avatar') }}</label>
<input v-model="form.avatar" :placeholder="L.t('set.avatarHint')" />
</div>
</div>
<div class="emoji-row">
<span v-for="e in EMOJIS" :key="e" @click="form.avatar = e">{{ e }}</span>
</div>
<label>{{ L.t('set.name') }}</label>
<input v-model="form.name" />
<label>{{ L.t('set.email') }}</label>
<input v-model="form.email" type="email" />
<button @click="saveProfile">{{ L.t('set.save') }}</button>
<hr />
<label>{{ L.t('set.curPw') }}</label>
<input v-model="curPw" type="password" />
<label>{{ L.t('set.newPw') }}</label>
<input v-model="newPw" type="password" />
<button class="ghost" @click="changePw">{{ L.t('set.changePw') }}</button>
</div>
<div v-if="ui.settingsTab === 'appearance'" style="display: grid; gap: 10px">
<label>{{ L.t('set.theme') }}</label>
<div class="row">
<button :class="{ ghost: theme.mode !== 'light' }" @click="setTheme('light')">{{ L.t('set.light') }}</button>
<button :class="{ ghost: theme.mode !== 'dark' }" @click="setTheme('dark')">{{ L.t('set.dark') }}</button>
<button :class="{ ghost: theme.mode !== 'system' }" @click="setTheme('system')">{{ L.t('set.system') }}</button>
</div>
<p class="muted">👀 {{ L.t('set.theme') }}: <strong>{{ theme.resolved }}</strong></p>
</div>
<div v-if="ui.settingsTab === 'language'" style="display: grid; gap: 10px">
<button :class="{ ghost: L.locale !== 'de' }" @click="setLocale('de')">🇩🇪 Deutsch</button>
<button :class="{ ghost: L.locale !== 'en' }" @click="setLocale('en')">🇬🇧 English</button>
<button :class="{ ghost: L.locale !== 'zh' }" @click="setLocale('zh')">🇨🇳 中文</button>
</div>
<div v-if="ui.settingsTab === 'background'" style="display: grid; gap: 10px">
<div class="preset-grid">
<div
v-for="(grad, key) in GRADIENTS"
:key="key"
class="preset"
:class="{ active: bgPreset === key }"
:style="grad ? { background: grad } : {}"
@click="bgPreset = key"
>
{{ key === 'none' ? '🚫' : '' }}
</div>
</div>
<label>{{ L.t('set.bgUrl') }}</label>
<input v-model="bgUrl" placeholder="https://…" @input="bgPreset = 'custom'" />
<p class="muted">{{ L.t('set.bgUrlHint') }}</p>
<label class="row"><input type="checkbox" v-model="bgBlur" style="width: auto" /> {{ L.t('set.bgBlur') }}</label>
<button @click="saveBackground">{{ L.t('set.save') }}</button>
</div>
<p v-if="msg" style="color: green">{{ msg }}</p>
<p v-if="err" style="color: red">{{ err }}</p>
</section>
</div>
</div>
</div>
</template>
+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>
-12
View File
@@ -1,12 +0,0 @@
<script setup>
import { useThemeStore } from '../stores/theme';
const theme = useThemeStore();
</script>
<template>
<div class="row">
<button class="ghost" @click="theme.set('light')" :disabled="theme.resolved === 'light' && theme.mode !== 'system'">☀ Light</button>
<button class="ghost" @click="theme.set('dark')" :disabled="theme.resolved === 'dark' && theme.mode !== 'system'">🌙 Dark</button>
<button class="ghost" @click="theme.toggle()">Toggle</button>
</div>
</template>
@@ -0,0 +1,66 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useTimeStore, formatDuration } from '../stores/time';
import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale';
const time = useTimeStore();
const ws = useWorkspaceStore();
const L = useLocaleStore();
const router = useRouter();
const open = ref(false);
const desc = ref('');
const bookId = ref('');
const tick = ref(0);
let timer = null;
onMounted(() => { timer = setInterval(() => (tick.value += 1), 1000); });
onUnmounted(() => clearInterval(timer));
const elapsed = computed(() => {
void tick.value;
return time.open
? formatDuration((Date.now() - new Date(time.open.started_at).getTime()) / 1000)
: '00:00:00';
});
async function toggle() {
if (time.open) {
await time.stop();
open.value = false;
} else {
open.value = !open.value;
}
}
async function start() {
await time.start(desc.value, bookId.value || null);
desc.value = '';
open.value = false;
}
</script>
<template>
<div class="timetracker">
<button
class="ghost timer-btn"
:class="{ running: !!time.open }"
@click="toggle"
:title="L.t('time.title')"
>
⏱️ {{ time.open ? elapsed : L.t('time.start') }}
</button>
<div v-if="open && !time.open" class="card timer-pop">
<input v-model="desc" :placeholder="L.t('time.descPh')" />
<select v-model="bookId">
<option value="">{{ L.t('time.book') }}: {{ L.t('time.none') }}</option>
<option v-for="b in ws.books" :key="b.id" :value="b.id">{{ b.icon }} {{ b.title }}</option>
</select>
<div class="row">
<button @click="start">{{ L.t('time.start') }}</button>
<button class="ghost" @click="router.push('/time')">{{ L.t('time.entries') }}</button>
</div>
</div>
</div>
</template>
+53
View File
@@ -0,0 +1,53 @@
<script setup>
import { ref, watch } from 'vue';
import api from '../api';
import { useLocaleStore } from '../stores/locale';
import Avatar from './Avatar.vue';
const props = defineProps({ workspaceId: String });
const emit = defineEmits(['pick']);
const L = useLocaleStore();
const q = ref('');
const results = ref([]);
const busy = ref(false);
let timer = null;
watch(q, () => {
clearTimeout(timer);
timer = setTimeout(search, 250);
});
async function search() {
const query = q.value.trim();
if (query.length < 2) { results.value = []; return; }
busy.value = true;
try {
const { data } = await api.get('/users/discover', { params: { q: query } });
results.value = data.users;
} catch {
results.value = [];
} finally {
busy.value = false;
}
}
function pick(u) {
emit('pick', u);
q.value = '';
results.value = [];
}
</script>
<template>
<div class="discover">
<input v-model="q" :placeholder="L.t('inv.ph')" />
<div v-if="q.trim().length >= 2" class="discover-results card">
<div v-if="busy" class="muted">{{ L.t('c.loading') }}</div>
<div v-for="u in results" :key="u.id" class="discover-row" @click="pick(u)">
<Avatar :name="u.name" :avatar="u.avatar" :size="24" />
<span><strong>{{ u.name }}</strong><br /><span class="muted">{{ u.email }}</span></span>
</div>
<div v-if="!busy && !results.length" class="muted">∅</div>
</div>
</div>
</template>