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,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>
|
||||
Reference in New Issue
Block a user