feat: stabilize admin dashboard, refactor group management, implement PWA, and fix routing

This commit is contained in:
2026-05-03 19:51:18 +05:00
parent 622ca04170
commit 0598e07823
36 changed files with 1387 additions and 467 deletions
+25 -2
View File
@@ -23,7 +23,19 @@
<div class="flex-1 flex flex-col relative min-w-0">
<!-- Toolbar -->
<div class="h-10 bg-slate-900 border-b border-slate-700 flex items-center px-3 gap-2 z-20">
<input v-model="canvasName" @change="saveCanvas" class="text-sm font-semibold text-slate-300 mr-2 bg-transparent border border-transparent hover:border-slate-600 focus:border-blue-500 focus:bg-slate-800 outline-none rounded px-2 py-1 w-64 transition" title="Переименовать карту" />
<input v-model="canvasName" @change="saveCanvas" class="text-sm font-semibold text-slate-300 mr-2 bg-transparent border border-transparent hover:border-slate-600 focus:border-blue-500 focus:bg-slate-800 outline-none rounded px-2 py-1 w-48 transition" title="Переименовать карту" />
<!-- Group Selection -->
<div class="flex items-center gap-1 bg-slate-800 border border-slate-700 rounded px-2 py-1">
<span class="text-[10px] text-slate-500 uppercase font-bold mr-1">Группа:</span>
<select v-model="selectedGroupId" @change="saveCanvas" class="bg-transparent text-xs text-slate-300 outline-none cursor-pointer hover:text-white transition">
<option :value="null">Личная (нет)</option>
<option v-for="g in groupsList" :key="g.id" :value="g.id">
👥 {{ g.name }}
</option>
</select>
</div>
<span v-if="isDirty" class="px-2 py-1 bg-yellow-600 text-yellow-100 rounded text-xs font-medium">
● Несохранено
</span>
@@ -204,6 +216,8 @@ const authHeaders = computed(() => ({ Authorization: `Bearer ${auth.token.value}
const elements = ref([])
const selectedNode = ref(null)
const canvasName = ref(props.canvasId)
const selectedGroupId = ref(null)
const groupsList = ref([])
const nodeTypes = [
{ type: 'text', name: 'Текст', icon: '📝', description: 'Markdown заметка' },
@@ -219,6 +233,7 @@ const loadData = async () => {
const data = await loadCanvas(props.canvasId)
elements.value = transformToFlowElements(data)
canvasName.value = data.name || props.canvasId
selectedGroupId.value = data.groupId || null
// Mark initial state as saved
markAsSaved(data)
}
@@ -639,7 +654,7 @@ const saveCanvas = async () => {
console.log('[DEBUG saveCanvas] Sending PUT request...')
const res = await $fetch(`/api/canvas/${props.canvasId}`, {
method: 'PUT',
body: { ...obsidianCanvas, name: canvasName.value },
body: { ...obsidianCanvas, name: canvasName.value, groupId: selectedGroupId.value },
headers: authHeaders.value
})
console.log('[DEBUG saveCanvas] PUT request successful!', res)
@@ -690,6 +705,14 @@ onMounted(async () => {
} catch (e) {
console.error('Failed to load canvases', e)
}
// Load groups for assignment
try {
const data = await $fetch('/api/groups')
groupsList.value = data.groups || []
} catch (e) {
console.error('Failed to load groups', e)
}
})
</script>
+51
View File
@@ -0,0 +1,51 @@
<template>
<div v-if="deferredPrompt && !isStandalone" class="fixed bottom-6 right-6 z-50">
<button
@click="installApp"
class="bg-blue-600 hover:bg-blue-500 text-white px-6 py-3 rounded-2xl font-bold shadow-2xl flex items-center gap-3 transition-all transform hover:scale-105 active:scale-95 group"
>
<span class="text-xl group-hover:rotate-12 transition-transform">📲</span>
<span>Установить XFlow</span>
</button>
</div>
</template>
<script setup>
const deferredPrompt = ref(null)
const isStandalone = ref(false)
onMounted(() => {
isStandalone.value = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
deferredPrompt.value = e
})
window.addEventListener('appinstalled', () => {
deferredPrompt.value = null
})
})
const installApp = async () => {
if (!deferredPrompt.value) {
alert('Браузер еще не подготовил установку. Пожалуйста, подождите 10-20 секунд или проверьте вкладку Application в консоли.')
return
}
deferredPrompt.value.prompt()
const { outcome } = await deferredPrompt.value.userChoice
console.log(`PWA: user choice outcome: ${outcome}`)
deferredPrompt.value = null
}
</script>
<style scoped>
@keyframes bounce-subtle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
.animate-bounce-subtle {
animation: bounce-subtle 3s ease-in-out infinite;
}
</style>
+1
View File
@@ -5,6 +5,7 @@ interface User {
id: string
email: string
username: string
displayName?: string
role?: string
}
+2 -2
View File
@@ -11,8 +11,8 @@
<NuxtLink to="/settings" class="text-sm text-slate-400 hover:text-white transition">Настройки</NuxtLink>
</div>
<div class="flex items-center gap-4">
<NuxtLink :to="`/profile/${user?.id}`" class="text-sm text-slate-400 hover:text-white transition">
{{ user?.username }}
<NuxtLink :to="`/profile/${user?.username || user?.id}`" class="text-sm text-slate-400 hover:text-white transition">
{{ user?.displayName || user?.username }}
</NuxtLink>
<button @click="logout" class="text-sm text-red-400 hover:text-red-300 transition">Выйти</button>
</div>
+8 -8
View File
@@ -1,15 +1,15 @@
export default defineNuxtRouteMiddleware((to, from) => {
const token = useCookie('xflow_token')
if (!token.value) {
return navigateTo('/login')
}
if (!token.value) return navigateTo('/login')
try {
const payload = JSON.parse(atob(token.value.split('.')[1]))
if (payload.role !== 'admin') {
return navigateTo('/')
}
} catch {
const base64Url = token.value.split('.')[1]
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
const payload = JSON.parse(window.atob(base64))
if (payload.role !== 'admin') return navigateTo('/')
} catch (e) {
return navigateTo('/login')
}
})
+1 -172
View File
@@ -1,174 +1,3 @@
<template>
<div class="min-h-screen bg-slate-950 p-6">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-slate-100">XFlow Admin Panel</h1>
<div class="flex items-center gap-2 text-sm text-slate-400">
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
Online
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-blue-400">{{ stats.users }}</div>
<div class="text-sm text-slate-400 mt-1">Registered Users</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-emerald-400">{{ stats.canvases }}</div>
<div class="text-sm text-slate-400 mt-1">Canvases</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-yellow-400">{{ stats.nodes }}</div>
<div class="text-sm text-slate-400 mt-1">Total Nodes</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-purple-400">{{ stats.pushes }}</div>
<div class="text-sm text-slate-400 mt-1">Push Subscribers</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Users -->
<div class="lg:col-span-2 bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
<h2 class="text-lg font-semibold text-slate-200">Users</h2>
<button @click="refreshData" class="text-sm text-blue-400 hover:text-blue-300">Refresh</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-slate-800 text-slate-400">
<tr>
<th class="text-left p-3">Email</th>
<th class="text-left p-3">Username</th>
<th class="text-left p-3">Role</th>
<th class="text-left p-3">ID</th>
</tr>
</thead>
<tbody class="text-slate-300">
<tr v-for="user in users" :key="user.id" class="border-t border-slate-800">
<td class="p-3">{{ user.email }}</td>
<td class="p-3">{{ user.username }}</td>
<td class="p-3">
<span :class="roleBadge(user.role)">{{ user.role }}</span>
</td>
<td class="p-3 text-xs text-slate-500 font-mono">{{ user.id }}</td>
</tr>
<tr v-if="users.length === 0">
<td colspan="4" class="p-6 text-center text-slate-500">No users found</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Canvases -->
<div class="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
<div class="p-4 border-b border-slate-700">
<h2 class="text-lg font-semibold text-slate-200">Canvases</h2>
</div>
<div class="divide-y divide-slate-800">
<div v-for="canvas in canvases" :key="canvas.id" class="p-3">
<div class="flex items-center justify-between">
<NuxtLink :to="`/canvas/${canvas.id}`" class="text-blue-400 hover:text-blue-300 font-medium">
{{ canvas.name }}
</NuxtLink>
<span class="text-xs text-slate-500">{{ canvas.nodeCount }} nodes</span>
</div>
<div class="text-xs text-slate-500 mt-1">{{ formatDate(canvas.updatedAt) }}</div>
</div>
<div v-if="canvases.length === 0" class="p-6 text-center text-slate-500">
No canvases yet
</div>
</div>
</div>
</div>
<!-- Script Runner -->
<div class="mt-6 bg-slate-900 border border-slate-700 rounded-xl p-6">
<h2 class="text-lg font-semibold text-slate-200 mb-4">Script Runner</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
v-for="script in scripts"
:key="script.id"
class="bg-slate-800 rounded-lg p-4"
>
<div class="flex items-center justify-between mb-2">
<span class="font-medium text-slate-200">{{ script.name }}</span>
<span class="text-xs px-2 py-1 rounded-full" :class="statusClass(script.status)">{{ script.status }}</span>
</div>
<button
@click="runScript(script.id)"
class="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-sm transition"
:disabled="script.status === 'running'"
>
Run
</button>
</div>
</div>
</div>
</div>
</div>
<NuxtPage />
</template>
<script setup>
definePageMeta({
middleware: ['auth', 'admin']
})
const users = ref([])
const canvases = ref([])
const stats = reactive({ users: 0, canvases: 0, nodes: 0, pushes: 0 })
const scripts = ref([
{ id: 'sync-git', name: 'Sync Git Server', status: 'idle' },
{ id: 'cleanup-nodes', name: 'Cleanup Orphan Nodes', status: 'idle' },
{ id: 'export-all', name: 'Export All Canvases', status: 'idle' }
])
const refreshData = async () => {
try {
const data = await $fetch('/api/admin/stats')
users.value = data.users || []
canvases.value = data.canvases || []
stats.users = data.stats?.users || 0
stats.canvases = data.stats?.canvases || 0
stats.nodes = data.stats?.nodes || 0
stats.pushes = data.stats?.pushes || 0
} catch (e) {
console.error('Failed to load admin data', e)
}
}
const roleBadge = (role) => {
return role === 'admin'
? 'px-2 py-0.5 bg-red-900/50 text-red-300 rounded text-xs'
: 'px-2 py-0.5 bg-slate-700 text-slate-300 rounded text-xs'
}
const statusClass = (status) => {
if (status === 'running') return 'bg-yellow-900/50 text-yellow-300'
if (status === 'completed') return 'bg-emerald-900/50 text-emerald-300'
return 'bg-slate-700 text-slate-400'
}
const formatDate = (date) => {
if (!date) return 'N/A'
return new Date(date).toLocaleDateString('ru-RU')
}
const runScript = async (scriptId) => {
const script = scripts.value.find(s => s.id === scriptId)
if (!script || script.status === 'running') return
script.status = 'running'
try {
await $fetch(`/api/admin/scripts/${scriptId}`, { method: 'POST' })
script.status = 'completed'
} catch (e) {
script.status = 'failed'
}
setTimeout(() => { script.status = 'idle' }, 3000)
}
onMounted(refreshData)
</script>
+1 -148
View File
@@ -1,150 +1,3 @@
<template>
<div class="min-h-screen bg-slate-950 p-6">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold text-slate-100">Team Management</h1>
<p class="text-slate-400 mt-1">Create and manage groups, members and permissions</p>
</div>
<button
@click="showCreateModal = true"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition flex items-center gap-2"
>
<span class="text-xl">+</span> New Group
</button>
</div>
<!-- Groups Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="group in groups"
:key="group.id"
class="bg-slate-900 border border-slate-800 rounded-2xl p-6 hover:border-slate-700 transition group"
>
<div class="flex items-start justify-between mb-4">
<div class="w-12 h-12 bg-blue-900/30 rounded-xl flex items-center justify-center text-blue-400 text-xl font-bold">
{{ group.name.charAt(0) }}
</div>
<div class="flex gap-2">
<span v-if="!group.isPublic" class="px-2 py-1 bg-amber-900/30 text-amber-400 text-xs rounded-md border border-amber-900/50">Private</span>
<span class="px-2 py-1 bg-slate-800 text-slate-400 text-xs rounded-md">{{ group.memberCount }} members</span>
</div>
</div>
<h3 class="text-xl font-semibold text-slate-100 mb-2">{{ group.name }}</h3>
<p class="text-slate-400 text-sm mb-6 line-clamp-2">{{ group.description || 'No description provided' }}</p>
<div class="flex items-center justify-between pt-4 border-t border-slate-800">
<div class="flex -space-x-2">
<div v-for="i in Math.min(group.memberCount, 3)" :key="i" class="w-8 h-8 rounded-full border-2 border-slate-900 bg-slate-800 flex items-center justify-center text-[10px] text-slate-400 font-bold">
U{{ i }}
</div>
<div v-if="group.memberCount > 3" class="w-8 h-8 rounded-full border-2 border-slate-900 bg-slate-700 flex items-center justify-center text-[10px] text-slate-300 font-bold">
+{{ group.memberCount - 3 }}
</div>
</div>
<button @click="openGroupSettings(group)" class="text-blue-400 hover:text-blue-300 text-sm font-medium">Manage</button>
</div>
</div>
</div>
<!-- Create Group Modal -->
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
<div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md p-6 shadow-2xl">
<h2 class="text-2xl font-bold text-slate-100 mb-4">Create New Group</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-400 mb-1">Group Name</label>
<input
v-model="newGroup.name"
type="text"
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-slate-100 focus:ring-2 focus:ring-blue-500 outline-none"
placeholder="e.g. Development Team"
/>
</div>
<div>
<label class="block text-sm font-medium text-slate-400 mb-1">Description</label>
<textarea
v-model="newGroup.description"
rows="3"
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-slate-100 focus:ring-2 focus:ring-blue-500 outline-none"
placeholder="What is this group for?"
></textarea>
</div>
<div class="flex items-center gap-3 py-2">
<input type="checkbox" v-model="newGroup.isPublic" id="isPublic" class="w-5 h-5 rounded border-slate-700 bg-slate-800 text-blue-600 focus:ring-blue-500">
<label for="isPublic" class="text-slate-300 select-none">Make group public</label>
</div>
</div>
<div class="flex gap-3 mt-8">
<button
@click="showCreateModal = false"
class="flex-1 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg font-medium transition"
>
Cancel
</button>
<button
@click="createGroup"
class="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition"
:disabled="loading"
>
{{ loading ? 'Creating...' : 'Create Group' }}
</button>
</div>
</div>
</div>
</div>
</div>
<NuxtPage />
</template>
<script setup>
definePageMeta({
middleware: ['auth', 'admin']
})
const groups = ref([])
const showCreateModal = ref(false)
const loading = ref(false)
const newGroup = reactive({
name: '',
description: '',
isPublic: true
})
const fetchGroups = async () => {
try {
const data = await $fetch('/api/groups')
groups.value = data.groups
} catch (e) {
console.error('Failed to fetch groups', e)
}
}
const createGroup = async () => {
if (!newGroup.name.trim()) return
loading.value = true
try {
await $fetch('/api/groups', {
method: 'POST',
body: newGroup
})
showCreateModal.value = false
newGroup.name = ''
newGroup.description = ''
await fetchGroups()
} catch (e) {
alert('Failed to create group')
} finally {
loading.value = false
}
}
const openGroupSettings = (group) => {
// To be implemented: detailed group management
navigateTo(`/admin/groups/${group.id}`)
}
onMounted(fetchGroups)
</script>
+81
View File
@@ -0,0 +1,81 @@
<template>
<div class="min-h-screen bg-slate-950 p-6">
<div class="max-w-4xl mx-auto" v-if="group">
<div class="flex items-center gap-4 mb-8 text-sm">
<NuxtLink to="/admin/groups" class="text-slate-500 hover:text-white transition">Группы</NuxtLink>
<span class="text-slate-700">/</span>
<span class="text-slate-300">{{ group.name }}</span>
</div>
<div class="bg-slate-900 border border-slate-800 rounded-3xl p-8 mb-8">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold text-white">{{ group.name }}</h1>
<button @click="deleteGroup" class="px-4 py-2 bg-red-900/20 text-red-400 hover:bg-red-900/40 rounded-lg text-sm transition">Удалить группу</button>
</div>
<p class="text-slate-400">{{ group.description || 'Нет описания' }}</p>
</div>
<!-- Members Section -->
<div class="bg-slate-900 border border-slate-800 rounded-3xl overflow-hidden">
<div class="p-6 border-b border-slate-800 flex items-center justify-between">
<h2 class="text-xl font-bold text-white">Участники ({{ members.length }})</h2>
<button @click="showAddMemberModal = true" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-bold transition">+ Добавить</button>
</div>
<div class="divide-y divide-slate-800">
<div v-for="m in members" :key="m.id" class="p-4 flex items-center justify-between hover:bg-slate-800/30 transition">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-slate-800 rounded-full flex items-center justify-center font-bold text-slate-400">
{{ m.user.username.charAt(0).toUpperCase() }}
</div>
<div>
<div class="text-slate-200 font-medium">{{ m.user.username }}</div>
<div class="text-xs text-slate-500">{{ m.role }}</div>
</div>
</div>
<button v-if="m.role !== 'admin'" @click="removeMember(m.userId)" class="text-slate-600 hover:text-red-400 transition text-xl">&times;</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
middleware: ['auth', 'admin']
})
const route = useRoute()
const auth = useAuth()
const group = ref(null)
const members = ref([])
const showAddMemberModal = ref(false)
const fetchGroup = async () => {
try {
const data = await $fetch(`/api/groups/${route.params.id}`, {
headers: { Authorization: `Bearer ${auth.token.value}` }
})
group.value = data.group
members.value = data.members || []
} catch (e) {
console.error('Failed to fetch group', e)
navigateTo('/admin/groups')
}
}
const deleteGroup = async () => {
if (!confirm('Вы уверены, что хотите удалить эту группу?')) return
try {
await $fetch(`/api/groups/${route.params.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${auth.token.value}` }
})
navigateTo('/admin/groups')
} catch (e) {
alert('Ошибка при удалении')
}
}
onMounted(fetchGroup)
</script>
+173
View File
@@ -0,0 +1,173 @@
<template>
<div class="min-h-screen bg-slate-950 p-6">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold text-slate-100">Управление Группами</h1>
<p class="text-slate-400 mt-1">Создание и управление командами, участниками и правами</p>
</div>
<button
@click="showCreateModal = true"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition flex items-center gap-2 shadow-lg shadow-blue-900/20"
>
<span class="text-xl">+</span> Новая Группа
</button>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
<!-- Groups Grid -->
<div v-else-if="groups.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="group in groups"
:key="group.id"
class="bg-slate-900 border border-slate-800 rounded-2xl p-6 hover:border-slate-700 transition group"
>
<div class="flex items-start justify-between mb-4">
<div class="w-12 h-12 bg-blue-900/30 rounded-xl flex items-center justify-center text-blue-400 text-xl font-bold">
{{ group.name.charAt(0) }}
</div>
<div class="flex gap-2">
<span v-if="!group.isPublic" class="px-2 py-1 bg-amber-900/30 text-amber-400 text-[10px] uppercase font-bold rounded-md border border-amber-900/50">Private</span>
<span class="px-2 py-1 bg-slate-800 text-slate-400 text-[10px] uppercase font-bold rounded-md">{{ group.memberCount }} members</span>
</div>
</div>
<h3 class="text-xl font-semibold text-slate-100 mb-2">{{ group.name }}</h3>
<p class="text-slate-400 text-sm mb-6 line-clamp-2 h-10">{{ group.description || 'Нет описания' }}</p>
<div class="flex items-center justify-between pt-4 border-t border-slate-800">
<div class="flex -space-x-2">
<div v-for="i in Math.min(group.memberCount, 3)" :key="i" class="w-8 h-8 rounded-full border-2 border-slate-900 bg-slate-800 flex items-center justify-center text-[10px] text-slate-400 font-bold">
U{{ i }}
</div>
<div v-if="group.memberCount > 3" class="w-8 h-8 rounded-full border-2 border-slate-900 bg-slate-700 flex items-center justify-center text-[10px] text-slate-300 font-bold">
+{{ group.memberCount - 3 }}
</div>
</div>
<button @click="openGroupSettings(group)" class="px-4 py-1.5 bg-slate-800 hover:bg-slate-700 text-blue-400 rounded-lg text-sm font-medium transition">Управлять</button>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="bg-slate-900 border border-slate-800 border-dashed rounded-3xl p-12 text-center">
<div class="text-6xl mb-4">👥</div>
<h3 class="text-xl font-bold text-slate-200">Групп пока нет</h3>
<p class="text-slate-500 mt-2 max-w-sm mx-auto">Создайте свою первую команду, чтобы работать над проектами совместно.</p>
<button @click="showCreateModal = true" class="mt-6 px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition">Создать группу</button>
</div>
<!-- Create Group Modal -->
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm" @click.self="showCreateModal = false">
<div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md p-6 shadow-2xl">
<h2 class="text-2xl font-bold text-slate-100 mb-4 text-center">Новая Группа</h2>
<div class="space-y-4">
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Название группы</label>
<input
v-model="newGroup.name"
type="text"
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-100 focus:border-blue-500 outline-none transition"
placeholder="Например: Отдел разработки"
/>
</div>
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Описание</label>
<textarea
v-model="newGroup.description"
rows="3"
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-100 focus:border-blue-500 outline-none transition resize-none"
placeholder="Чем занимается эта группа?"
></textarea>
</div>
<div class="flex items-center gap-3 py-2 bg-slate-800/50 p-3 rounded-lg border border-slate-700/50">
<input type="checkbox" v-model="newGroup.isPublic" id="isPublic" class="w-5 h-5 rounded border-slate-700 bg-slate-800 text-blue-600 focus:ring-blue-500 cursor-pointer">
<label for="isPublic" class="text-slate-300 select-none cursor-pointer text-sm">Сделать группу публичной (доступной всем)</label>
</div>
</div>
<div class="flex gap-3 mt-8">
<button
@click="showCreateModal = false"
class="flex-1 px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl font-bold transition"
>
Отмена
</button>
<button
@click="createGroup"
class="flex-1 px-4 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition shadow-lg shadow-blue-900/40"
:disabled="creating"
>
{{ creating ? 'Создание...' : 'Создать' }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
middleware: ['auth', 'admin']
})
const auth = useAuth()
const groups = ref([])
const showCreateModal = ref(false)
const loading = ref(false)
const creating = ref(false)
const newGroup = reactive({
name: '',
description: '',
isPublic: true
})
const fetchGroups = async () => {
loading.value = true
try {
const data = await $fetch('/api/groups', {
headers: { Authorization: `Bearer ${auth.token.value}` }
})
groups.value = data.groups || []
} catch (e) {
console.error('[Groups Page] Failed to fetch groups', e)
} finally {
loading.value = false
}
}
const createGroup = async () => {
if (!newGroup.name.trim()) return
creating.value = true
try {
await $fetch('/api/groups', {
method: 'POST',
body: newGroup,
headers: { Authorization: `Bearer ${auth.token.value}` }
})
showCreateModal.value = false
newGroup.name = ''
newGroup.description = ''
await fetchGroups()
} catch (e) {
alert('Ошибка при создании группы: ' + (e.data?.message || e.message))
} finally {
creating.value = false
}
}
const openGroupSettings = (group) => {
navigateTo(`/admin/groups/${group.id}`)
}
onMounted(() => {
fetchGroups()
})
</script>
+153
View File
@@ -0,0 +1,153 @@
<template>
<div class="min-h-screen bg-slate-950 p-6">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-slate-100">XFlow Admin Panel</h1>
<div class="flex items-center gap-2 text-sm text-slate-400">
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
Online
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-blue-400">{{ stats.users }}</div>
<div class="text-sm text-slate-400 mt-1">Registered Users</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-emerald-400">{{ stats.canvases }}</div>
<div class="text-sm text-slate-400 mt-1">Canvases</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-yellow-400">{{ stats.nodes }}</div>
<div class="text-sm text-slate-400 mt-1">Total Nodes</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-purple-400">{{ stats.pushes }}</div>
<div class="text-sm text-slate-400 mt-1">Push Subscribers</div>
</div>
</div>
<!-- Quick Actions -->
<div class="flex gap-4 mb-6">
<NuxtLink
to="/admin/groups"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition flex items-center gap-2"
>
👥 Manage Groups & Teams
</NuxtLink>
<button
@click="refreshData"
class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg font-medium transition"
>
🔄 Refresh Data
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Users -->
<div class="lg:col-span-2 bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
<h2 class="text-lg font-semibold text-slate-200">Users</h2>
<button @click="refreshData" class="text-sm text-blue-400 hover:text-blue-300">Refresh</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-slate-800 text-slate-400">
<tr>
<th class="text-left p-3">Email</th>
<th class="text-left p-3">Username</th>
<th class="text-left p-3">Role</th>
<th class="text-left p-3">ID</th>
</tr>
</thead>
<tbody class="text-slate-300">
<tr v-for="user in users" :key="user.id" class="border-t border-slate-800">
<td class="p-3">{{ user.email }}</td>
<td class="p-3">{{ user.username }}</td>
<td class="p-3">
<span :class="roleBadge(user.role)">{{ user.role }}</span>
</td>
<td class="p-3 text-xs text-slate-500 font-mono">{{ user.id }}</td>
</tr>
<tr v-if="users.length === 0">
<td colspan="4" class="p-6 text-center text-slate-500">No users found</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Canvases -->
<div class="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
<div class="p-4 border-b border-slate-700">
<h2 class="text-lg font-semibold text-slate-200">Canvases</h2>
</div>
<div class="divide-y divide-slate-800">
<div v-for="canvas in canvases" :key="canvas.id" class="p-3">
<div class="flex items-center justify-between">
<NuxtLink :to="`/canvas/${canvas.id}`" class="text-blue-400 hover:text-blue-300 font-medium">
{{ canvas.name }}
</NuxtLink>
<span class="text-xs text-slate-500">{{ canvas.nodeCount }} nodes</span>
</div>
<div class="text-xs text-slate-500 mt-1">{{ formatDate(canvas.updatedAt) }}</div>
</div>
<div v-if="canvases.length === 0" class="p-6 text-center text-slate-500">
No canvases yet
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
middleware: ['auth', 'admin']
})
const users = ref([])
const canvases = ref([])
const stats = reactive({ users: 0, canvases: 0, nodes: 0, pushes: 0 })
const auth = useAuth()
const refreshData = async () => {
if (!auth.token.value) return
try {
const data = await $fetch('/api/admin/stats', {
headers: { Authorization: `Bearer ${auth.token.value}` }
})
users.value = data.users || []
canvases.value = data.canvases || []
stats.users = data.stats?.users || 0
stats.canvases = data.stats?.canvases || 0
stats.nodes = data.stats?.nodes || 0
stats.pushes = data.stats?.pushes || 0
} catch (e) {
console.error('Failed to load admin data', e)
}
}
const roleBadge = (role) => {
return role === 'admin'
? 'px-2 py-0.5 bg-red-900/50 text-red-300 rounded text-xs'
: 'px-2 py-0.5 bg-slate-700 text-slate-300 rounded text-xs'
}
const formatDate = (date) => {
if (!date) return 'N/A'
return new Date(date).toLocaleDateString('ru-RU')
}
onMounted(async () => {
// Ждем инициализации пользователя если нужно
if (!auth.user.value) {
await auth.initAuth()
}
refreshData()
})
</script>
+29 -13
View File
@@ -15,41 +15,57 @@
<h1 class="text-2xl font-bold mb-6">Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<NuxtLink
to="/canvas"
to="/canvases"
class="p-6 bg-slate-800 border border-slate-700 rounded-lg hover:border-blue-500 transition group"
>
<div class="text-3xl mb-2">🗺️</div>
<h3 class="font-semibold group-hover:text-blue-400 transition">Canvas</h3>
<p class="text-sm text-slate-400">Визуальное управление задачами</p>
<h3 class="font-semibold group-hover:text-blue-400 transition">Проекты</h3>
<p class="text-sm text-slate-400">{{ homeStats.canvases }} канвасов доступно</p>
</NuxtLink>
<div class="p-6 bg-slate-800 border border-slate-700 rounded-lg">
<div class="text-3xl mb-2">✅</div>
<h3 class="font-semibold">Задачи</h3>
<p class="text-sm text-slate-400">{{ completedTasks }} / {{ totalTasks }} выполнено</p>
<p class="text-sm text-slate-400">{{ homeStats.tasks.completed }} / {{ homeStats.tasks.total }} выполнено</p>
</div>
<div class="p-6 bg-slate-800 border border-slate-700 rounded-lg">
<div class="text-3xl mb-2">🏆</div>
<h3 class="font-semibold">Ачивки</h3>
<p class="text-sm text-slate-400">{{ achievements }} разблокировано</p>
<p class="text-sm text-slate-400">{{ homeStats.achievements }} разблокировано</p>
</div>
</div>
</div>
<ClientOnly>
<InstallPWA />
</ClientOnly>
</div>
</template>
<script setup>
const { isAuthenticated } = useAuth()
const { stats: achievementStats, loadAchievements } = useAchievements()
const { isAuthenticated, token } = useAuth()
const homeStats = ref({
canvases: 0,
tasks: { total: 0, completed: 0 },
achievements: 0
})
const loadingStats = ref(true)
const completedTasks = computed(() => achievementStats.value.completedTasks)
const totalTasks = computed(() => 3) // mock total from canvas
const achievements = computed(() => achievementStats.value.total)
const fetchHomeStats = async () => {
if (!isAuthenticated.value) return
try {
const data = await $fetch('/api/user/stats', {
headers: { Authorization: `Bearer ${token.value}` }
})
homeStats.value = data.stats
} catch (e) {
console.error('Failed to load home stats', e)
} finally {
loadingStats.value = false
}
}
onMounted(() => {
if (isAuthenticated.value) {
loadAchievements()
}
fetchHomeStats()
})
</script>
+8 -1
View File
@@ -33,7 +33,7 @@ definePageMeta({
layout: 'default'
})
const { googleSignIn, initAuth } = useAuth()
const { googleSignIn, initAuth, isAuthenticated } = useAuth()
const loading = ref(false)
const signInWithGoogle = async () => {
@@ -49,6 +49,13 @@ const signInWithGoogle = async () => {
}
onMounted(() => {
if (isAuthenticated.value) {
navigateTo('/')
}
initAuth()
})
watch(isAuthenticated, (val) => {
if (val) navigateTo('/')
})
</script>
+5
View File
@@ -223,6 +223,11 @@ const loadProfile = async () => {
profile.value = profileData.profile
userCanvases.value = canvasesData.canvases
userActivities.value = activitiesData.activities
// Redirect to pretty URL if using UID but username exists
if (profile.value.user?.username && route.params.id === profile.value.userId) {
navigateTo(`/profile/${profile.value.user.username}`, { replace: true })
}
if (isOwnProfile.value) {
const [groupsData, allGroupsData] = await Promise.all([
+113 -1
View File
@@ -3,6 +3,66 @@
<h1 class="text-2xl font-bold mb-6">Настройки</h1>
<div class="space-y-6">
<!-- Profile Settings -->
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span>👤</span> Личный профиль
</h2>
<div class="space-y-4">
<div class="flex items-center gap-4 mb-4">
<img :src="profileForm.avatar" class="w-16 h-16 rounded-full bg-slate-700 object-cover border-2 border-blue-500/30" />
<div class="flex-1">
<label class="text-xs text-slate-500 uppercase font-bold">Ссылка на аватар</label>
<input v-model="profileForm.avatar" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 outline-none focus:border-blue-500" />
</div>
</div>
<div class="grid grid-cols-1 gap-4">
<div>
<label class="text-xs text-slate-500 uppercase font-bold">Имя (Display Name)</label>
<input v-model="profileForm.displayName" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 outline-none focus:border-blue-500" />
</div>
</div>
<div>
<label class="text-xs text-slate-500 uppercase font-bold">О себе (Bio)</label>
<textarea v-model="profileForm.bio" rows="3" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 outline-none focus:border-blue-500 resize-none"></textarea>
</div>
<button
@click="saveProfile"
:disabled="saving"
class="w-full py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition disabled:opacity-50"
>
{{ saving ? 'Сохранение...' : 'Сохранить изменения' }}
</button>
<p v-if="saveMessage" :class="saveMessage.type === 'error' ? 'text-red-400' : 'text-emerald-400'" class="text-center text-xs mt-2">
{{ saveMessage.text }}
</p>
</div>
</div>
<!-- My Groups -->
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span>👥</span> Мои Группы
</h2>
<div v-if="userGroups.length === 0" class="text-slate-500 text-sm py-4">
Вы не состоите ни в одной группе.
</div>
<div v-else class="space-y-3">
<div v-for="g in userGroups" :key="g.id" class="flex items-center justify-between p-3 bg-slate-900/50 rounded-lg border border-slate-700">
<div>
<div class="font-medium text-slate-200">{{ g.name }}</div>
<div class="text-xs text-slate-500">{{ g.description }}</div>
</div>
<span :class="g.membershipRole === 'admin' ? 'text-purple-400 bg-purple-900/20' : 'text-blue-400 bg-blue-900/20'" class="text-[10px] uppercase font-bold px-2 py-0.5 rounded border border-current/20">
{{ g.membershipRole }}
</span>
</div>
</div>
</div>
<!-- Push Notifications -->
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
@@ -72,10 +132,59 @@ definePageMeta({
middleware: 'auth'
})
const { user } = useAuth()
const { isSupported, subscription, requestPermission, subscribe: pushSubscribe } = usePush()
const { achievements, loadAchievements } = useAchievements()
const permissionGranted = ref(false)
const saving = ref(false)
const saveMessage = ref(null)
const userGroups = ref([])
const profileForm = reactive({
displayName: '',
username: '',
bio: '',
avatar: ''
})
const loadProfile = async () => {
try {
const data = await $fetch(`/api/users/${user.value.id}/profile`, {
headers: { Authorization: `Bearer ${useAuth().token.value}` }
})
const groupsData = await $fetch('/api/user/groups', {
headers: { Authorization: `Bearer ${useAuth().token.value}` }
})
userGroups.value = groupsData.groups || []
profileForm.displayName = data.profile.displayName || ''
profileForm.username = data.profile.user.username || ''
profileForm.bio = data.profile.bio || ''
profileForm.avatar = data.profile.avatar || ''
} catch (e) {
console.error('Failed to load profile', e)
}
}
const saveProfile = async () => {
saving.value = true
saveMessage.value = null
try {
await $fetch('/api/user/profile', {
method: 'PUT',
body: profileForm,
headers: { Authorization: `Bearer ${useAuth().token.value}` }
})
saveMessage.value = { type: 'success', text: 'Профиль обновлен успешно!' }
// Update local user data if needed
user.value.username = profileForm.username
} catch (e) {
saveMessage.value = { type: 'error', text: e.data?.message || 'Ошибка при сохранении' }
} finally {
saving.value = false
}
}
const enablePush = async () => {
const granted = await requestPermission()
@@ -91,6 +200,9 @@ const subscribe = async () => {
onMounted(async () => {
permissionGranted.value = Notification.permission === 'granted'
await loadAchievements()
await Promise.all([
loadAchievements(),
loadProfile()
])
})
</script>
+94 -1
View File
@@ -5,6 +5,56 @@
<h1 class="text-3xl font-bold text-white flex items-center gap-3">
<span class="text-blue-500">📋</span> Мои Задачи
</h1>
<div class="flex items-center gap-3">
<button @click="showCreateModal = true" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium shadow-lg shadow-blue-900/20 transition flex items-center gap-2">
<span>➕</span> Новая задача
</button>
</div>
</div>
<!-- Create Task Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" @click.self="showCreateModal = false">
<div class="bg-slate-800 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
<h2 class="text-xl font-bold text-white mb-6">Создать новую задачу</h2>
<form @submit.prevent="createTask" class="space-y-4">
<div>
<label class="text-xs text-slate-500 uppercase font-bold">Название задачи</label>
<input v-model="newTask.title" required class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500" placeholder="Что нужно сделать?" />
</div>
<div>
<label class="text-xs text-slate-500 uppercase font-bold">Проект (Канвас)</label>
<select v-model="newTask.canvasId" required class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500">
<option value="" disabled>Выберите проект...</option>
<option v-for="c in availableCanvases" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-xs text-slate-500 uppercase font-bold">Приоритет</label>
<select v-model="newTask.priority" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500">
<option value="low">Низкий</option>
<option value="medium">Средний</option>
<option value="high">Высокий</option>
</select>
</div>
<div>
<label class="text-xs text-slate-500 uppercase font-bold">Статус</label>
<select v-model="newTask.state" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500">
<option v-for="col in columns" :key="col.id" :value="col.id">{{ col.title }}</option>
</select>
</div>
</div>
<div class="flex gap-3 pt-4">
<button type="submit" :disabled="creating" class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition disabled:opacity-50">
{{ creating ? 'Создание...' : 'Создать задачу' }}
</button>
<button type="button" @click="showCreateModal = false" class="px-6 py-3 bg-slate-700 hover:bg-slate-600 text-white rounded-xl font-bold transition">Отмена</button>
</div>
</form>
</div>
</div>
<div v-if="loading" class="flex items-center justify-center h-64">
@@ -92,6 +142,16 @@ const columnsData = reactive({
completed: []
})
const showCreateModal = ref(false)
const creating = ref(false)
const availableCanvases = ref([])
const newTask = reactive({
title: '',
canvasId: '',
priority: 'medium',
state: 'todo'
})
const columns = [
{ id: 'todo', title: 'К выполнению', icon: '📋', color: 'text-slate-400' },
{ id: 'in_progress', title: 'В работе', icon: '⏳', color: 'text-blue-400' },
@@ -125,6 +185,36 @@ const fetchTasks = async () => {
}
}
const fetchCanvases = async () => {
try {
const res = await $fetch('/api/canvas')
availableCanvases.value = res.canvases || []
if (availableCanvases.value.length > 0) {
newTask.canvasId = availableCanvases.value[0].id
}
} catch (e) {
console.error('Failed to load canvases', e)
}
}
const createTask = async () => {
creating.value = true
try {
await $fetch('/api/tasks/create', {
method: 'POST',
body: newTask,
headers: { Authorization: `Bearer ${auth.token.value}` }
})
showCreateModal.value = false
newTask.title = ''
await fetchTasks() // Refresh list
} catch (e) {
alert('Ошибка при создании задачи: ' + (e.data?.message || e.message))
} finally {
creating.value = false
}
}
const onTaskMove = async (evt, newStatus) => {
if (evt.added) {
const task = evt.added.element
@@ -197,7 +287,10 @@ onMounted(async () => {
if (!auth.user.value) {
await auth.init()
}
await fetchTasks()
await Promise.all([
fetchTasks(),
fetchCanvases()
])
})
</script>
+34
View File
@@ -0,0 +1,34 @@
{
"id": "/",
"name": "XFlow",
"short_name": "XFlow",
"description": "XFlow Obsidian Canvas PWA",
"lang": "ru",
"categories": ["productivity", "business"],
"theme_color": "#0f172a",
"background_color": "#0f172a",
"display": "standalone",
"orientation": "portrait",
"start_url": "/",
"scope": "/",
"icons": [
{
"src": "/pwa-192x192.svg",
"sizes": "192x192",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/pwa-512x512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/maskable-icon-512x512.svg",
"sizes": "512x512",
"type": "image/svg+xml",
"purpose": "maskable"
}
]
}
+31 -12
View File
@@ -1,28 +1,47 @@
import { requireAuth } from '~/server/utils/auth'
import { users, canvases, pushSubscriptions } from '~/server/utils/db'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
console.log(`[Admin API] Request from user: ${user.email}, Role: ${user.role}`)
if (user.role !== 'admin') {
throw createError({ statusCode: 403, message: 'Admin access required' })
}
const usersList = Array.from(users.values())
const canvasesList = Array.from(canvases.values()).map(c => ({
id: c.id,
name: c.name,
nodeCount: c.nodes.length,
updatedAt: c.updatedAt
}))
// 1. Получаем список всех пользователей с их профилями
const usersList = await prisma.user.findMany({
include: { profile: true }
})
// 2. Получаем список всех канвасов
const canvasesList = await prisma.canvas.findMany({
select: {
id: true,
name: true,
nodes: true,
updatedAt: true,
ownerId: true
}
})
// 3. Считаем общее кол-во пушей (если есть таблица)
let pushCount = 0
try {
pushCount = await prisma.pushSubscription.count()
} catch (e) {}
return {
users: usersList,
canvases: canvasesList,
canvases: canvasesList.map(c => ({
...c,
nodeCount: (c.nodes as any[])?.length || 0
})),
stats: {
users: usersList.length,
canvases: canvasesList.length,
nodes: canvasesList.reduce((sum, c) => sum + c.nodeCount, 0),
pushes: pushSubscriptions.size
nodes: canvasesList.reduce((sum, c) => sum + ((c.nodes as any[])?.length || 0), 0),
pushes: pushCount
}
}
})
})
+10 -3
View File
@@ -34,12 +34,15 @@ export default defineEventHandler(async (event) => {
const fbUser: FirebaseUser = verifyRes.users[0]
const email = fbUser.email
const username = fbUser.displayName || email.split('@')[0]
const username = email.split('@')[0]
const userId = fbUser.localId
const user = await prisma.user.upsert({
where: { email },
update: { username },
update: {
username
// Мы не затираем профиль при каждом входе, но нам нужно его получить
},
create: {
id: userId,
email,
@@ -48,10 +51,13 @@ export default defineEventHandler(async (event) => {
role: 'user',
profile: {
create: {
displayName: username,
displayName: fbUser.displayName || username,
avatar: fbUser.photoUrl || `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}`
}
}
},
include: {
profile: true
}
})
@@ -59,6 +65,7 @@ export default defineEventHandler(async (event) => {
id: user.id,
email: user.email,
username: user.username,
displayName: user.profile?.displayName || user.username,
role: user.role
})
@@ -0,0 +1,53 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const adminUser = requireAuth(event)
const groupId = getRouterParam(event, 'id')
const body = await readBody(event)
const { email, role = 'member' } = body
if (!groupId || !email) {
throw createError({ statusCode: 400, message: 'Group ID and Email are required' })
}
// 1. Проверяем, является ли текущий пользователь админом группы или глобальным админом
const group = await prisma.group.findUnique({
where: { id: groupId }
})
if (!group || (group.ownerId !== adminUser.id && adminUser.role !== 'admin')) {
throw createError({ statusCode: 403, message: 'Only group owners or admins can add members' })
}
// 2. Ищем пользователя по email
const userToAdd = await prisma.user.findUnique({
where: { email }
})
if (!userToAdd) {
throw createError({ statusCode: 404, message: 'User with this email not found in system' })
}
// 3. Добавляем в группу
try {
const membership = await prisma.groupMember.upsert({
where: {
userId_groupId: {
userId: userToAdd.id,
groupId: groupId
}
},
update: { role },
create: {
userId: userToAdd.id,
groupId: groupId,
role
}
})
return { success: true, membership }
} catch (e) {
throw createError({ statusCode: 500, message: 'Failed to add member to group' })
}
})
+36
View File
@@ -0,0 +1,36 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const id = getRouterParam(event, 'id')
const group = await prisma.group.findUnique({
where: { id },
include: {
members: {
include: {
user: {
select: {
id: true,
username: true,
email: true
}
}
}
}
}
})
if (!group) {
throw createError({ statusCode: 404, message: 'Group not found' })
}
// Проверяем, имеет ли пользователь право видеть детали группы
const isMember = group.members.some(m => m.userId === user.id)
if (!group.isPublic && !isMember && user.role !== 'admin') {
throw createError({ statusCode: 403, message: 'Access denied' })
}
return { group }
})
+1 -1
View File
@@ -5,7 +5,7 @@ export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const allGroups = await prisma.group.findMany({
where: { isPublic: true },
where: user.role === 'admin' ? {} : { isPublic: true },
include: {
members: { include: { user: { select: { id: true, username: true } } } },
_count: { select: { members: true, canvases: true } }
+69
View File
@@ -0,0 +1,69 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { canvasId, title, priority, state } = body
if (!canvasId || !title) {
throw createError({ statusCode: 400, message: 'Canvas ID and Title are required' })
}
// 1. Получаем канвас
const canvas = await prisma.canvas.findUnique({
where: { id: canvasId }
})
if (!canvas) {
throw createError({ statusCode: 404, message: 'Canvas not found' })
}
// 2. Проверяем права
const isOwner = canvas.ownerId === user.id
let isMember = false
if (canvas.groupId) {
const membership = await prisma.groupMember.findFirst({
where: { userId: user.id, groupId: canvas.groupId }
})
isMember = !!membership
}
if (!isOwner && !isMember && user.role !== 'admin') {
throw createError({ statusCode: 403, message: 'No permission to add tasks to this canvas' })
}
// 3. Создаем новый узел задачи
const nodes = (canvas.nodes as any[]) || []
// Генерируем позицию (просто в центре или скраю)
const lastNode = nodes[nodes.length - 1]
const x = lastNode?.position?.x || 100
const y = (lastNode?.position?.y || 100) + 100
const newTaskNode = {
id: `task-${Date.now()}`,
type: 'task',
position: { x, y },
data: {
text: `# ${title}`,
priority: priority || 'medium',
state: state || 'todo',
createdAt: new Date().toISOString()
},
// Дублируем поля для удобства фильтрации на бэкенде
priority: priority || 'medium',
state: state || 'todo',
label: title
}
nodes.push(newTaskNode)
// 4. Сохраняем в БД
await prisma.canvas.update({
where: { id: canvasId },
data: { nodes }
})
return { success: true, task: newTaskNode }
})
+55
View File
@@ -0,0 +1,55 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
// 1. Получаем группы пользователя
const userGroups = await prisma.groupMember.findMany({
where: { userId: user.id },
select: { groupId: true }
})
const groupIds = userGroups.map(g => g.groupId)
// 2. Получаем все доступные канвасы
const canvases = await prisma.canvas.findMany({
where: {
OR: [
{ ownerId: user.id },
{ groupId: { in: groupIds } },
{ isPublic: true }
]
},
select: {
id: true,
name: true,
nodes: true
}
})
// 3. Собираем задачи из всех канвасов
const allTasks: any[] = []
for (const canvas of canvases) {
const nodes = (canvas.nodes as any[]) || []
const taskNodes = nodes.filter(node => node.type === 'task')
taskNodes.forEach(task => {
allTasks.push({
...task,
canvasId: canvas.id,
canvasName: canvas.name
})
})
}
// Сортируем: сначала высокий приоритет, потом дата
allTasks.sort((a, b) => {
const pPriority = { high: 3, medium: 2, low: 1 }
const prioA = pPriority[a.priority || a.data?.priority] || 0
const prioB = pPriority[b.priority || b.data?.priority] || 0
return prioB - prioA
})
return { tasks: allTasks }
})
+22 -11
View File
@@ -7,10 +7,10 @@ export default defineEventHandler(async (event) => {
const { taskId, canvasId, newState } = body
if (!taskId || !canvasId || !newState) {
throw createError({ statusCode: 400, message: 'taskId, canvasId and newState are required' })
throw createError({ statusCode: 400, message: 'Missing parameters' })
}
// 1. Найти холст
// 1. Получаем канвас
const canvas = await prisma.canvas.findUnique({
where: { id: canvasId }
})
@@ -19,7 +19,21 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 404, message: 'Canvas not found' })
}
// 2. Найти и обновить узел в JSON
// 2. Проверяем права на редактирование
const isOwner = canvas.ownerId === user.id
let isMember = false
if (canvas.groupId) {
const membership = await prisma.groupMember.findFirst({
where: { userId: user.id, groupId: canvas.groupId }
})
isMember = !!membership
}
if (!isOwner && !isMember && user.role !== 'admin') {
throw createError({ statusCode: 403, message: 'No permission to update this task' })
}
// 3. Обновляем узел внутри массива nodes
const nodes = (canvas.nodes as any[]) || []
const nodeIdx = nodes.findIndex(n => n.id === taskId)
@@ -29,18 +43,15 @@ export default defineEventHandler(async (event) => {
// Обновляем состояние
nodes[nodeIdx].state = newState
// Также обновляем дату изменения, если нужно
nodes[nodeIdx].updatedAt = new Date().toISOString()
if (nodes[nodeIdx].data) {
nodes[nodeIdx].data.state = newState
}
// 3. Сохранить изменения в БД
// 4. Сохраняем обратно в БД
await prisma.canvas.update({
where: { id: canvasId },
data: { nodes }
})
return {
success: true,
message: `Task ${taskId} moved to ${newState}`
}
return { success: true }
})
+20
View File
@@ -0,0 +1,20 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { displayName, bio, avatar } = body
// Обновляем данные в таблице Profile
const updatedProfile = await prisma.profile.update({
where: { userId: user.id },
data: {
...(displayName !== undefined && { displayName }),
...(bio !== undefined && { bio }),
...(avatar !== undefined && { avatar })
}
})
return { success: true, profile: updatedProfile }
})
+51
View File
@@ -0,0 +1,51 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
// 1. Считаем канвасы (личные и где участник)
const canvasCount = await prisma.canvas.count({
where: {
OR: [
{ ownerId: user.id },
{ groupId: { not: null } } // Тут можно усложнить, но для начала так
]
}
})
// 2. Считаем задачи во всех доступных канвасах
const canvases = await prisma.canvas.findMany({
where: {
OR: [
{ ownerId: user.id },
{ groupId: { not: null } }
]
},
select: { nodes: true }
})
let totalTasks = 0
let completedTasks = 0
for (const canvas of canvases) {
const nodes = (canvas.nodes as any[]) || []
const tasks = nodes.filter(n => n.type === 'task')
totalTasks += tasks.length
completedTasks += tasks.filter(t => t.data?.state === 'completed' || t.state === 'completed').length
}
// 3. Ачивки (пока возвращаем 0, так как модели нет в схеме)
const achievementCount = 0
return {
stats: {
canvases: canvasCount,
tasks: {
total: totalTasks,
completed: completedTasks
},
achievements: achievementCount
}
}
})
+17 -4
View File
@@ -8,10 +8,23 @@ export default defineEventHandler(async (event) => {
}
// Get user's recent activities
const userActivities = Array.from(activities.values())
.filter(activity => activity.userId === userId)
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 50) // Limit to 50 most recent
const user = await prisma.user.findFirst({
where: {
OR: [{ id: userId }, { username: userId }]
}
})
if (!user) {
throw createError({ statusCode: 404, message: 'User not found' })
}
// Get user's activities from database or memory
const userActivities = await prisma.activity.findMany({
where: { userId: user.id },
orderBy: { timestamp: 'desc' },
take: 20
})
// Limit to 50 most recent
return { activities: userActivities }
})
@@ -7,6 +7,12 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, message: 'User ID is required' })
}
const user = await prisma.user.findFirst({
where: {
OR: [{ id: userId }, { username: userId }]
}
})
// Get user's group memberships
const userGroupIds = Array.from(userGroups.values())
.filter(membership => membership.userId === userId)
@@ -0,0 +1,39 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const currentUser = requireAuth(event)
const targetUserIdOrUsername = getRouterParam(event, 'id')
if (!targetUserIdOrUsername) {
throw createError({ statusCode: 400, message: 'User ID is required' })
}
// Находим целевого пользователя (по ID или username)
const targetUser = await prisma.user.findFirst({
where: {
OR: [
{ id: targetUserIdOrUsername },
{ username: targetUserIdOrUsername }
]
}
})
if (!targetUser) {
throw createError({ statusCode: 404, message: 'Target user not found' })
}
try {
const follow = await prisma.follows.findUnique({
where: {
followerId_followingId: {
followerId: currentUser.id,
followingId: targetUser.id
}
}
})
return { isFollowing: !!follow }
} catch (e) {
return { isFollowing: false }
}
})
+40
View File
@@ -0,0 +1,40 @@
import { requireAuth } from '~/server/utils/auth'
import { prisma } from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
const currentUser = requireAuth(event)
const targetIdOrUsername = getRouterParam(event, 'id')
const targetUser = await prisma.user.findFirst({
where: {
OR: [
{ id: targetIdOrUsername },
{ username: targetIdOrUsername }
]
}
})
if (!targetUser) {
throw createError({ statusCode: 404, message: 'User not found' })
}
if (targetUser.id === currentUser.id) {
throw createError({ statusCode: 400, message: 'You cannot follow yourself' })
}
await prisma.follows.upsert({
where: {
followerId_followingId: {
followerId: currentUser.id,
followingId: targetUser.id
}
},
update: {},
create: {
followerId: currentUser.id,
followingId: targetUser.id
}
})
return { success: true }
})
+35 -10
View File
@@ -7,27 +7,52 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, message: 'User ID is required' })
}
const profile = await prisma.profile.findUnique({
where: { userId },
let profile = await prisma.profile.findFirst({
where: {
OR: [
{ userId: userId },
{ user: { username: userId } }
]
},
include: {
user: {
include: {
_count: {
select: {
canvases: true,
followers: true,
following: true
}
select: { canvases: true, followers: true, following: true }
}
}
}
}
})
// If profile doesn't exist but user does, create it
if (!profile) {
// If profile doesn't exist but user might exist in Firebase,
// we could potentially create it here or return 404
throw createError({ statusCode: 404, message: 'Profile not found' })
const user = await prisma.user.findFirst({
where: {
OR: [{ id: userId }, { username: userId }]
}
})
if (!user) {
throw createError({ statusCode: 404, message: 'User not found' })
}
profile = await prisma.profile.create({
data: {
userId: user.id,
displayName: user.username,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${user.id}`
},
include: {
user: {
include: {
_count: {
select: { canvases: true, followers: true, following: true }
}
}
}
}
})
}
return {
+16 -5
View File
@@ -1,25 +1,36 @@
import jwt from 'jsonwebtoken'
import { H3Event, createError } from 'h3'
const JWT_SECRET = process.env.NUXT_JWT_SECRET || 'your-super-secret-jwt-key'
export interface AuthUser {
id: string
email: string
username: string
displayName?: string
role?: string
}
export function generateToken(user: AuthUser): string {
const config = useRuntimeConfig()
const secret = config.jwtSecret || 'your-super-secret-jwt-key'
return jwt.sign(
{ id: user.id, email: user.email, username: user.username, role: user.role || 'user' },
JWT_SECRET,
{
id: user.id,
email: user.email,
username: user.username,
displayName: user.displayName,
role: user.role || 'user'
},
secret,
{ expiresIn: '7d' }
)
}
export function verifyToken(token: string): AuthUser {
return jwt.verify(token, JWT_SECRET) as AuthUser
const config = useRuntimeConfig()
const secret = config.jwtSecret || 'your-super-secret-jwt-key'
return jwt.verify(token, secret) as AuthUser
}
export function getAuthUser(event: H3Event): AuthUser | null {