feat(frontend): settings window, i18n, admin UI, books, tasks, calendar, timer
- 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:
@@ -3,16 +3,25 @@ import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { EditorContent, useEditor } from '@tiptap/vue-3';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import TaskList from '@tiptap/extension-task-list';
|
||||
import TaskItem from '@tiptap/extension-task-item';
|
||||
import Table from '@tiptap/extension-table';
|
||||
import TableRow from '@tiptap/extension-table-row';
|
||||
import TableCell from '@tiptap/extension-table-cell';
|
||||
import TableHeader from '@tiptap/extension-table-header';
|
||||
import api from '../api';
|
||||
import { connectSocket } from '../socket';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useWorkspaceStore } from '../stores/workspace';
|
||||
import PageTree from '../components/PageTree.vue';
|
||||
import { useLocaleStore } from '../stores/locale';
|
||||
import Sidebar from '../components/Sidebar.vue';
|
||||
import Avatar from '../components/Avatar.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const ws = useWorkspaceStore();
|
||||
const L = useLocaleStore();
|
||||
|
||||
const page = ref(null);
|
||||
const comments = ref([]);
|
||||
@@ -26,7 +35,7 @@ let saveTimer = null;
|
||||
let typingTimer = null;
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit],
|
||||
extensions: [StarterKit, TaskList, TaskItem.configure({ nested: true }), Table.configure({ resizable: true }), TableRow, TableHeader, TableCell],
|
||||
content: '',
|
||||
editable: true,
|
||||
onUpdate: ({ editor }) => {
|
||||
@@ -44,7 +53,6 @@ async function save(ed = editor.value) {
|
||||
if (!page.value || !canEdit.value || !ed) return;
|
||||
const content_json = ed.getJSON();
|
||||
const content_html = ed.getHTML();
|
||||
// Persist via REST (authoritative) + broadcast via socket for instant sync
|
||||
await api.patch(`/pages/${page.value.id}`, { content_json, content_html }).catch(() => {});
|
||||
socket?.emit('page:edit', { pageId: page.value.id, content_json, content_html });
|
||||
}
|
||||
@@ -56,7 +64,6 @@ async function load() {
|
||||
const { data } = await api.get(`/pages/${id}`);
|
||||
page.value = data.page;
|
||||
comments.value = data.comments;
|
||||
canEdit.value = ['owner', 'admin', 'member'].includes(ws.role) || true;
|
||||
editor.value?.commands.setContent(data.page.content_json?.type ? data.page.content_json : (data.page.content_html || ''));
|
||||
joinRoom(id);
|
||||
} catch (e) {
|
||||
@@ -82,16 +89,18 @@ function joinRoom(pageId) {
|
||||
});
|
||||
socket.on('presence:update', ({ members }) => { presence.value = members; });
|
||||
socket.on('typing', ({ name }) => {
|
||||
typingUser.value = `${name} is typing…`;
|
||||
typingUser.value = `✍️ ${name} …`;
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(() => (typingUser.value = ''), 1500);
|
||||
});
|
||||
socket.on('comment:added', ({ comment }) => comments.value.push(comment));
|
||||
}
|
||||
|
||||
async function saveTitle() {
|
||||
async function saveMeta() {
|
||||
if (!page.value) return;
|
||||
const { data } = await api.patch(`/pages/${page.value.id}`, { title: page.value.title });
|
||||
const { data } = await api.patch(`/pages/${page.value.id}`, {
|
||||
title: page.value.title, book_id: page.value.book_id,
|
||||
});
|
||||
page.value = data.page;
|
||||
ws.fetchPages();
|
||||
}
|
||||
@@ -104,7 +113,7 @@ async function addComment() {
|
||||
}
|
||||
|
||||
async function removePage() {
|
||||
if (!confirm('Delete this page?')) return;
|
||||
if (!confirm('🗑️?')) return;
|
||||
await api.delete(`/pages/${page.value.id}`);
|
||||
await ws.fetchPages();
|
||||
router.push('/');
|
||||
@@ -112,7 +121,10 @@ async function removePage() {
|
||||
|
||||
onMounted(async () => {
|
||||
if (!ws.workspaces.length) await ws.fetchWorkspaces();
|
||||
if (!ws.pages.length && ws.currentId) await ws.fetchPages();
|
||||
if (!ws.pages.length && ws.currentId) {
|
||||
await ws.fetchBooks();
|
||||
await ws.fetchPages();
|
||||
}
|
||||
await load();
|
||||
});
|
||||
watch(() => route.params.id, load);
|
||||
@@ -124,46 +136,49 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<button class="ghost" @click="router.push('/')">← Dashboard</button>
|
||||
<PageTree :nodes="ws.tree()" :active-id="route.params.id" @open="router.push(`/page/${$event}`)" @add-child="router.push('/')" />
|
||||
</aside>
|
||||
<Sidebar :active-page-id="route.params.id" />
|
||||
<main class="main" v-if="page">
|
||||
<div class="topbar">
|
||||
<input v-model="page.title" @change="saveTitle" style="font-size: 22px; font-weight: 700" />
|
||||
<button class="ghost" @click="removePage">Delete</button>
|
||||
<div class="topbar wrap">
|
||||
<input v-model="page.title" @change="saveMeta" style="font-size: 22px; font-weight: 700; flex: 1" />
|
||||
<select v-model="page.book_id" @change="saveMeta" style="max-width: 200px" :title="L.t('task.book')">
|
||||
<option :value="null">📄 {{ L.t('task.none') }}</option>
|
||||
<option v-for="b in ws.books" :key="b.id" :value="b.id">{{ b.icon }} {{ b.title }}</option>
|
||||
</select>
|
||||
<button class="ghost" @click="removePage">🗑️</button>
|
||||
</div>
|
||||
<div class="presence" style="margin-bottom: 8px">
|
||||
<span class="muted">Online ({{ presence.length }}):</span>
|
||||
<span v-for="m in presence" :key="m.userId" class="avatar" :title="m.email">{{ (m.name || m.email || '?')[0].toUpperCase() }}</span>
|
||||
<span class="muted">🟢 ({{ presence.length }}):</span>
|
||||
<Avatar v-for="m in presence" :key="m.userId" :name="m.name" :size="26" />
|
||||
<span class="muted" v-if="typingUser">{{ typingUser }}</span>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleBold().run()">B</button>
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleBold().run()"><b>B</b></button>
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleItalic().run()"><i>I</i></button>
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleHeading({ level: 1 }).run()">H1</button>
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()">H2</button>
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleBulletList().run()">• List</button>
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleBulletList().run()">• {{ L.t('side.pages') }}</button>
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleTaskList().run()">☑️</button>
|
||||
<button class="ghost" @click="editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run()">▦ Table</button>
|
||||
<button class="ghost" @click="editor?.chain().focus().toggleCodeBlock().run()"></></button>
|
||||
<button @click="save()">Save</button>
|
||||
<button @click="save()">💾 {{ L.t('c.save') }}</button>
|
||||
</div>
|
||||
<EditorContent :editor="editor" class="tiptap" />
|
||||
<div class="card" style="margin-top: 16px">
|
||||
<h3>Comments ({{ comments.length }})</h3>
|
||||
<h3>💬 {{ L.t('com.title') }} ({{ comments.length }})</h3>
|
||||
<div v-for="c in comments" :key="c.id" class="comment">
|
||||
<strong>{{ c.author_name || 'Someone' }}</strong>
|
||||
<span class="muted"> · {{ new Date(c.created_at).toLocaleString() }}</span>
|
||||
<p>{{ c.body }}</p>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 8px">
|
||||
<input v-model="newComment" placeholder="Write a comment…" @keyup.enter="addComment" />
|
||||
<button @click="addComment">Post</button>
|
||||
<input v-model="newComment" :placeholder="L.t('com.ph')" @keyup.enter="addComment" />
|
||||
<button @click="addComment">{{ L.t('com.post') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<main class="main" v-else>
|
||||
<p v-if="err" style="color: red">{{ err }}</p>
|
||||
<p v-else class="muted">Loading…</p>
|
||||
<p v-else class="muted">{{ L.t('c.loading') }}</p>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user