Files
openStack/frontend/src/views/PageEditor.vue
T
x3 0bb4e5c1cd
ci / backend (push) Canceled after 0s
ci / frontend (push) Canceled after 0s
feat(frontend): bell, chat, uploads, glass, teams UI, fixes
- Notification bell with invites (accept/decline refreshes workspaces)
  and unread badge (polling + socket push); team chat view realtime
- Avatar/background uploads in settings + glassmorphism widgets toggle;
  background layer resolves /uploads URLs; full-bleed blurred backdrop
- Sidebar: visible creation errors, working sub-pages (parent_id),
  TEAM badge workspaces, refresh button, chat nav; admin creates users
- Tasks: per-team boards, assignee select with avatars; admin tabs for
  users/teams/groups (team workspaces, permission checkboxes)
- Register view removed (admin-only accounts); login localized;
  prettier emoji set; scaling/overflow + mobile fixes
2026-09-22 14:27:33 +02:00

185 lines
7.0 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 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 { 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([]);
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, TaskList, TaskItem.configure({ nested: true }), Table.configure({ resizable: true }), TableRow, TableHeader, TableCell],
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();
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;
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} …`;
clearTimeout(typingTimer);
typingTimer = setTimeout(() => (typingUser.value = ''), 1500);
});
socket.on('comment:added', ({ comment }) => comments.value.push(comment));
}
async function saveMeta() {
if (!page.value) return;
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();
}
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('🗑️?')) 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.fetchBooks();
await ws.fetchPages();
}
await load();
});
watch(() => route.params.id, load);
onUnmounted(() => {
clearTimeout(saveTimer);
socket?.off('page:updated');
});
</script>
<template>
<div class="layout">
<Sidebar :active-page-id="route.params.id" />
<main class="main" v-if="page">
<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">🟢 ({{ 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>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()">• {{ 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()">&lt;/&gt;</button>
<button @click="save()">💾 {{ L.t('c.save') }}</button>
</div>
<EditorContent :editor="editor" class="tiptap" />
<div class="card" style="margin-top: 16px">
<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="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">{{ L.t('c.loading') }}</p>
</main>
</div>
</template>