Files
openStack/frontend/src/views/PageEditor.vue
T
x3 5252e3ab4f feat(frontend): Vue 3 workspace UI with light/dark mode
- Vue 3 + Vite + Pinia + Vue Router, Axios client with JWT interceptor
- Auth views (login/register), dashboard with workspace switcher and search
- Nested PageTree sidebar, TipTap rich-text editor with debounced save
- Live collaboration via Socket.IO (remote updates, presence avatars, typing)
- Comments thread per page, invite-members card, member list
- Theme store (light/dark/system) persisted, CSS variables, ThemeToggle
- Multi-stage Dockerfile (build + nginx) for hosting
2026-09-22 13:33:22 +02:00

170 lines
6.2 KiB
Vue

<script setup>
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 api from '../api';
import { connectSocket } from '../socket';
import { useAuthStore } from '../stores/auth';
import { useWorkspaceStore } from '../stores/workspace';
import PageTree from '../components/PageTree.vue';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const ws = useWorkspaceStore();
const page = ref(null);
const comments = ref([]);
const newComment = ref('');
const presence = ref([]);
const typingUser = ref('');
const err = ref('');
const canEdit = ref(true);
let socket = null;
let saveTimer = null;
let typingTimer = null;
const editor = useEditor({
extensions: [StarterKit],
content: '',
editable: true,
onUpdate: ({ editor }) => {
queueSave(editor);
socket?.emit('typing', { pageId: page.value?.id, name: auth.user?.name });
},
});
function queueSave(ed) {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => save(ed), 800);
}
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 });
}
async function load() {
err.value = '';
const id = route.params.id;
try {
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) {
err.value = e.response?.data?.error || 'Failed to load page';
}
}
function joinRoom(pageId) {
socket = connectSocket();
if (!socket) return;
socket.emit('page:join', { pageId });
socket.off('page:updated');
socket.off('presence:update');
socket.off('typing');
socket.off('comment:added');
socket.on('page:updated', ({ page: updated, from }) => {
if (from === auth.user?.id) return;
page.value = { ...page.value, ...updated };
const current = editor.value?.getJSON();
if (JSON.stringify(current) !== JSON.stringify(updated.content_json)) {
editor.value?.commands.setContent(updated.content_json?.type ? updated.content_json : updated.content_html || '');
}
});
socket.on('presence:update', ({ members }) => { presence.value = members; });
socket.on('typing', ({ name }) => {
typingUser.value = `${name} is typing…`;
clearTimeout(typingTimer);
typingTimer = setTimeout(() => (typingUser.value = ''), 1500);
});
socket.on('comment:added', ({ comment }) => comments.value.push(comment));
}
async function saveTitle() {
if (!page.value) return;
const { data } = await api.patch(`/pages/${page.value.id}`, { title: page.value.title });
page.value = data.page;
ws.fetchPages();
}
async function addComment() {
if (!newComment.value.trim()) return;
const { data } = await api.post(`/pages/${page.value.id}/comments`, { body: newComment.value });
comments.value.push(data.comment);
newComment.value = '';
}
async function removePage() {
if (!confirm('Delete this page?')) return;
await api.delete(`/pages/${page.value.id}`);
await ws.fetchPages();
router.push('/');
}
onMounted(async () => {
if (!ws.workspaces.length) await ws.fetchWorkspaces();
if (!ws.pages.length && ws.currentId) await ws.fetchPages();
await load();
});
watch(() => route.params.id, load);
onUnmounted(() => {
clearTimeout(saveTimer);
socket?.off('page:updated');
});
</script>
<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>
<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>
<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" 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().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().toggleCodeBlock().run()">&lt;/&gt;</button>
<button @click="save()">Save</button>
</div>
<EditorContent :editor="editor" class="tiptap" />
<div class="card" style="margin-top: 16px">
<h3>Comments ({{ 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>
</div>
</div>
</main>
<main class="main" v-else>
<p v-if="err" style="color: red">{{ err }}</p>
<p v-else class="muted">Loading…</p>
</main>
</div>
</template>