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
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
<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 PageTree from '../components/PageTree.vue';
|
||||
import InviteModal from '../components/InviteModal.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const ws = useWorkspaceStore();
|
||||
const router = useRouter();
|
||||
const newWs = ref('');
|
||||
const newPage = ref('');
|
||||
const search = ref('');
|
||||
const results = ref([]);
|
||||
const members = ref([]);
|
||||
|
||||
async function init() {
|
||||
await auth.fetchMe().catch(() => {});
|
||||
await ws.fetchWorkspaces();
|
||||
await ws.fetchPages();
|
||||
fetchMembers();
|
||||
}
|
||||
|
||||
async function fetchMembers() {
|
||||
if (!ws.currentId) return;
|
||||
const { data } = await api.get(`/workspaces/${ws.currentId}/members`);
|
||||
members.value = data.members;
|
||||
}
|
||||
|
||||
async function switchWs(id) {
|
||||
ws.setCurrent(id);
|
||||
await ws.fetchPages();
|
||||
fetchMembers();
|
||||
}
|
||||
|
||||
async function createWs() {
|
||||
if (!newWs.value.trim()) return;
|
||||
await ws.createWorkspace(newWs.value.trim());
|
||||
newWs.value = '';
|
||||
await ws.fetchPages();
|
||||
fetchMembers();
|
||||
}
|
||||
|
||||
async function createPage(parentId = null) {
|
||||
const title = newPage.value.trim() || 'Untitled';
|
||||
const { data } = await api.post('/pages', { workspace_id: ws.currentId, parent_id: parentId, title });
|
||||
newPage.value = '';
|
||||
await ws.fetchPages();
|
||||
router.push(`/page/${data.page.id}`);
|
||||
}
|
||||
|
||||
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(init);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<strong>{{ auth.user?.name }}'s notes</strong>
|
||||
<button class="ghost" @click="logout">Logout</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="New workspace" />
|
||||
<button @click="createWs">+</button>
|
||||
</div>
|
||||
<input v-model="search" placeholder="Search pages…" @input="doSearch" />
|
||||
<div v-if="results.length" class="card">
|
||||
<div v-for="r in results" :key="r.id" class="tree-item" @click="router.push(`/page/${r.id}`)">
|
||||
{{ r.title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input v-model="newPage" placeholder="New page title" @keyup.enter="createPage()" />
|
||||
<button @click="createPage()">+</button>
|
||||
</div>
|
||||
<PageTree :nodes="ws.tree()" @open="router.push(`/page/${$event}`)" @add-child="createPage($event)" />
|
||||
<div class="muted">Role: {{ ws.role }} · Members: {{ members.length }}</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<h2>Welcome, {{ auth.user?.name }}</h2>
|
||||
<p class="muted">Select a page on the left, or create one. Invite teammates below to collaborate in real time.</p>
|
||||
<InviteModal v-if="ws.currentId" :workspace-id="ws.currentId" @invited="fetchMembers" />
|
||||
<div class="card" style="margin-top: 12px">
|
||||
<h4>Members</h4>
|
||||
<div v-for="m in members" :key="m.id" class="row" style="justify-content: space-between; padding: 4px 0">
|
||||
<span>{{ m.name }} <{{ m.email }}></span>
|
||||
<span class="muted">{{ m.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const err = ref('');
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
async function submit() {
|
||||
err.value = '';
|
||||
try {
|
||||
await auth.login(email.value, password.value);
|
||||
router.push('/');
|
||||
} catch (e) {
|
||||
err.value = e.response?.data?.error || 'Login failed';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-wrap card">
|
||||
<h1>Welcome back</h1>
|
||||
<p class="muted">Log in to your workspace.</p>
|
||||
<form @submit.prevent="submit" style="display: grid; gap: 10px">
|
||||
<input v-model="email" type="email" placeholder="Email" required />
|
||||
<input v-model="password" type="password" placeholder="Password" required />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
<p v-if="err" style="color: red">{{ err }}</p>
|
||||
<p class="muted">No account? <router-link to="/register">Register</router-link></p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,169 @@
|
||||
<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()"></></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>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const email = ref('');
|
||||
const name = ref('');
|
||||
const password = ref('');
|
||||
const err = ref('');
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
async function submit() {
|
||||
err.value = '';
|
||||
try {
|
||||
await auth.register(email.value, name.value, password.value);
|
||||
router.push('/');
|
||||
} catch (e) {
|
||||
err.value = e.response?.data?.error || 'Registration failed';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-wrap card">
|
||||
<h1>Create account</h1>
|
||||
<p class="muted">Start your collaborative workspace.</p>
|
||||
<form @submit.prevent="submit" style="display: grid; gap: 10px">
|
||||
<input v-model="name" placeholder="Display name" required />
|
||||
<input v-model="email" type="email" placeholder="Email" required />
|
||||
<input v-model="password" type="password" placeholder="Password (min 8 chars)" required minlength="8" />
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
<p v-if="err" style="color: red">{{ err }}</p>
|
||||
<p class="muted">Have an account? <router-link to="/login">Log in</router-link></p>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user