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,17 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
ARG VITE_API_URL=/api
|
||||
ARG VITE_SOCKET_URL=""
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_SOCKET_URL=$VITE_SOCKET_URL
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Serve stage
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>openStack</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+2460
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "openstack-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^2.4.0",
|
||||
"@tiptap/pm": "^2.4.0",
|
||||
"@tiptap/starter-kit": "^2.4.0",
|
||||
"@tiptap/vue-3": "^2.4.0",
|
||||
"axios": "^1.7.2",
|
||||
"pinia": "^2.1.7",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { useThemeStore } from './stores/theme';
|
||||
import ThemeToggle from './components/ThemeToggle.vue';
|
||||
|
||||
const theme = useThemeStore();
|
||||
onMounted(() => theme.apply());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="position: fixed; top: 12px; right: 12px; z-index: 50">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(r) => r,
|
||||
(err) => {
|
||||
if (err.response?.status === 401 && !location.pathname.startsWith('/login')) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
if (!location.pathname.startsWith('/register')) location.href = '/login';
|
||||
}
|
||||
return Promise.reject(err);
|
||||
},
|
||||
);
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,93 @@
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--bg-soft: #f7f7f5;
|
||||
--sidebar: #fbfbfa;
|
||||
--text: #111111;
|
||||
--muted: #6b6b6b;
|
||||
--border: #e6e6e2;
|
||||
--accent: #2383e2;
|
||||
--accent-soft: #e8f3fd;
|
||||
--card: #ffffff;
|
||||
--input: #ffffff;
|
||||
}
|
||||
[data-theme='dark'] {
|
||||
--bg: #191919;
|
||||
--bg-soft: #202020;
|
||||
--sidebar: #202020;
|
||||
--text: #e8e8e8;
|
||||
--muted: #9b9b9b;
|
||||
--border: #2e2e2e;
|
||||
--accent: #4fa3e3;
|
||||
--accent-soft: #1e3a52;
|
||||
--card: #202020;
|
||||
--input: #262626;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
a { color: var(--accent); }
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
input, select, textarea {
|
||||
background: var(--input);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
.layout { display: flex; min-height: 100vh; }
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background: var(--sidebar);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.main { flex: 1; padding: 24px; max-width: 960px; }
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
.auth-wrap { max-width: 380px; margin: 10vh auto; }
|
||||
.auth-wrap h1 { margin-bottom: 4px; }
|
||||
.muted { color: var(--muted); font-size: 13px; }
|
||||
.row { display: flex; gap: 8px; align-items: center; }
|
||||
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.tree-item { padding: 4px 8px; border-radius: 6px; cursor: pointer; }
|
||||
.tree-item:hover { background: var(--bg-soft); }
|
||||
.tree-item.active { background: var(--accent-soft); }
|
||||
.presence { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.avatar {
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
background: var(--accent); color: #fff;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; font-weight: 700;
|
||||
}
|
||||
.tiptap { min-height: 300px; border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
|
||||
.tiptap:focus { outline: 2px solid var(--accent); }
|
||||
.toolbar { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.toolbar button { padding: 4px 8px; font-size: 13px; }
|
||||
.comment { border-top: 1px solid var(--border); padding: 8px 0; }
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import api from '../api';
|
||||
|
||||
const props = defineProps({ workspaceId: String });
|
||||
const emit = defineEmits(['invited']);
|
||||
const email = ref('');
|
||||
const role = ref('member');
|
||||
const msg = ref('');
|
||||
const err = ref('');
|
||||
|
||||
async function invite() {
|
||||
msg.value = '';
|
||||
err.value = '';
|
||||
try {
|
||||
await api.post(`/workspaces/${props.workspaceId}/members`, { email: email.value, role: role.value });
|
||||
msg.value = `Invited ${email.value} as ${role.value}`;
|
||||
email.value = '';
|
||||
emit('invited');
|
||||
} catch (e) {
|
||||
err.value = e.response?.data?.error || 'Invite failed';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<h4>Invite collaborator</h4>
|
||||
<p class="muted">They must register first with the same email.</p>
|
||||
<div class="row">
|
||||
<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>
|
||||
</div>
|
||||
<p v-if="msg" style="color: green">{{ msg }}</p>
|
||||
<p v-if="err" style="color: red">{{ err }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
defineProps({ nodes: { type: Array, default: () => [] }, activeId: String, depth: { type: Number, default: 0 } });
|
||||
defineEmits(['open', 'add-child']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-for="n in nodes" :key="n.id">
|
||||
<div
|
||||
class="tree-item"
|
||||
:class="{ active: n.id === activeId }"
|
||||
:style="{ paddingLeft: 8 + depth * 14 + 'px' }"
|
||||
>
|
||||
<span @click="$emit('open', n.id)" style="flex: 1">{{ n.icon }} {{ n.title }}</span>
|
||||
<button class="ghost" style="padding: 0 6px" @click.stop="$emit('add-child', n.id)" title="New sub-page">+</button>
|
||||
</div>
|
||||
<PageTree
|
||||
v-if="n.children?.length"
|
||||
:nodes="n.children"
|
||||
:active-id="activeId"
|
||||
:depth="depth + 1"
|
||||
@open="$emit('open', $event)"
|
||||
@add-child="$emit('add-child', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<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,12 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import { useThemeStore } from './stores/theme';
|
||||
import './assets/style.css';
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
useThemeStore().apply();
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import Login from '../views/Login.vue';
|
||||
import Register from '../views/Register.vue';
|
||||
import Dashboard from '../views/Dashboard.vue';
|
||||
import PageEditor from '../views/PageEditor.vue';
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', component: Login, meta: { guest: true } },
|
||||
{ path: '/register', component: Register, meta: { guest: true } },
|
||||
{ path: '/', component: Dashboard, meta: { auth: true } },
|
||||
{ path: '/page/:id', component: PageEditor, meta: { auth: true } },
|
||||
];
|
||||
|
||||
const router = createRouter({ history: createWebHistory(), routes });
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const auth = useAuthStore();
|
||||
if (to.meta.auth && !auth.isLoggedIn) return '/login';
|
||||
if (to.meta.guest && auth.isLoggedIn) return '/';
|
||||
return true;
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
let socket = null;
|
||||
|
||||
export function connectSocket() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return null;
|
||||
if (socket?.connected) return socket;
|
||||
socket?.disconnect();
|
||||
const url = import.meta.env.VITE_SOCKET_URL || 'http://localhost:3000';
|
||||
socket = io(url || undefined, { auth: { token } });
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function getSocket() {
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function disconnectSocket() {
|
||||
socket?.disconnect();
|
||||
socket = null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import api from '../api';
|
||||
import { connectSocket, disconnectSocket } from '../socket';
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
user: JSON.parse(localStorage.getItem('user') || 'null'),
|
||||
token: localStorage.getItem('token') || null,
|
||||
}),
|
||||
getters: {
|
||||
isLoggedIn: (s) => !!s.token,
|
||||
},
|
||||
actions: {
|
||||
async register(email, name, password) {
|
||||
const { data } = await api.post('/auth/register', { email, name, password });
|
||||
this.apply(data);
|
||||
},
|
||||
async login(email, password) {
|
||||
const { data } = await api.post('/auth/login', { email, password });
|
||||
this.apply(data);
|
||||
},
|
||||
apply(data) {
|
||||
this.user = data.user;
|
||||
this.token = data.token;
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
localStorage.setItem('token', data.token);
|
||||
connectSocket();
|
||||
},
|
||||
async fetchMe() {
|
||||
if (!this.token) return;
|
||||
const { data } = await api.get('/auth/me');
|
||||
this.user = data.user;
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
},
|
||||
logout() {
|
||||
this.user = null;
|
||||
this.token = null;
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
disconnectSocket();
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export const useThemeStore = defineStore('theme', {
|
||||
state: () => ({
|
||||
mode: localStorage.getItem('theme') || 'system',
|
||||
}),
|
||||
getters: {
|
||||
resolved: (s) => {
|
||||
if (s.mode === 'light' || s.mode === 'dark') return s.mode;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
set(mode) {
|
||||
this.mode = mode;
|
||||
localStorage.setItem('theme', mode);
|
||||
this.apply();
|
||||
},
|
||||
apply() {
|
||||
document.documentElement.dataset.theme = this.resolved;
|
||||
},
|
||||
toggle() {
|
||||
this.set(this.resolved === 'dark' ? 'light' : 'dark');
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import api from '../api';
|
||||
|
||||
export const useWorkspaceStore = defineStore('workspace', {
|
||||
state: () => ({
|
||||
workspaces: [],
|
||||
currentId: localStorage.getItem('workspaceId') || null,
|
||||
pages: [],
|
||||
role: null,
|
||||
}),
|
||||
actions: {
|
||||
async fetchWorkspaces() {
|
||||
const { data } = await api.get('/workspaces');
|
||||
this.workspaces = data.workspaces;
|
||||
if (!this.currentId && this.workspaces[0]) this.setCurrent(this.workspaces[0].id);
|
||||
return this.workspaces;
|
||||
},
|
||||
setCurrent(id) {
|
||||
this.currentId = id;
|
||||
localStorage.setItem('workspaceId', id);
|
||||
},
|
||||
async createWorkspace(name) {
|
||||
const { data } = await api.post('/workspaces', { name });
|
||||
this.workspaces.unshift({ ...data.workspace, role: 'owner' });
|
||||
this.setCurrent(data.workspace.id);
|
||||
return data.workspace;
|
||||
},
|
||||
async fetchPages() {
|
||||
if (!this.currentId) return [];
|
||||
const { data } = await api.get(`/pages/by-workspace/${this.currentId}`);
|
||||
this.pages = data.pages;
|
||||
this.role = data.role;
|
||||
return this.pages;
|
||||
},
|
||||
tree() {
|
||||
const byParent = new Map();
|
||||
for (const p of this.pages) {
|
||||
const key = p.parent_id || 'root';
|
||||
if (!byParent.has(key)) byParent.set(key, []);
|
||||
byParent.get(key).push(p);
|
||||
}
|
||||
const build = (key) =>
|
||||
(byParent.get(key) || []).map((p) => ({ ...p, children: build(p.id) }));
|
||||
return build('root');
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:3000', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user