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:
x3
2026-09-22 13:33:22 +02:00
parent 4ba8f8bd21
commit 5252e3ab4f
22 changed files with 3274 additions and 0 deletions
+17
View File
@@ -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
+12
View File
@@ -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>
+8
View File
@@ -0,0 +1,8 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
+2460
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
+17
View File
@@ -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>
+25
View File
@@ -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;
+93
View File
@@ -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; }
+42
View File
@@ -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>
+27
View File
@@ -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>
+12
View File
@@ -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>
+12
View File
@@ -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();
+24
View File
@@ -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;
+22
View File
@@ -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;
}
+43
View File
@@ -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();
},
},
});
+26
View File
@@ -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');
},
},
});
+47
View File
@@ -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');
},
},
});
+108
View File
@@ -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 }} &lt;{{ m.email }}&gt;</span>
<span class="muted">{{ m.role }}</span>
</div>
</div>
</main>
</div>
</template>
+35
View File
@@ -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>
+169
View File
@@ -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()">&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>
+37
View File
@@ -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>
+12
View File
@@ -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 },
},
},
});