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"> <div class="flex-1 flex flex-col relative min-w-0">
<!-- Toolbar --> <!-- Toolbar -->
<div class="h-10 bg-slate-900 border-b border-slate-700 flex items-center px-3 gap-2 z-20"> <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 v-if="isDirty" class="px-2 py-1 bg-yellow-600 text-yellow-100 rounded text-xs font-medium">
● Несохранено ● Несохранено
</span> </span>
@@ -204,6 +216,8 @@ const authHeaders = computed(() => ({ Authorization: `Bearer ${auth.token.value}
const elements = ref([]) const elements = ref([])
const selectedNode = ref(null) const selectedNode = ref(null)
const canvasName = ref(props.canvasId) const canvasName = ref(props.canvasId)
const selectedGroupId = ref(null)
const groupsList = ref([])
const nodeTypes = [ const nodeTypes = [
{ type: 'text', name: 'Текст', icon: '📝', description: 'Markdown заметка' }, { type: 'text', name: 'Текст', icon: '📝', description: 'Markdown заметка' },
@@ -219,6 +233,7 @@ const loadData = async () => {
const data = await loadCanvas(props.canvasId) const data = await loadCanvas(props.canvasId)
elements.value = transformToFlowElements(data) elements.value = transformToFlowElements(data)
canvasName.value = data.name || props.canvasId canvasName.value = data.name || props.canvasId
selectedGroupId.value = data.groupId || null
// Mark initial state as saved // Mark initial state as saved
markAsSaved(data) markAsSaved(data)
} }
@@ -639,7 +654,7 @@ const saveCanvas = async () => {
console.log('[DEBUG saveCanvas] Sending PUT request...') console.log('[DEBUG saveCanvas] Sending PUT request...')
const res = await $fetch(`/api/canvas/${props.canvasId}`, { const res = await $fetch(`/api/canvas/${props.canvasId}`, {
method: 'PUT', method: 'PUT',
body: { ...obsidianCanvas, name: canvasName.value }, body: { ...obsidianCanvas, name: canvasName.value, groupId: selectedGroupId.value },
headers: authHeaders.value headers: authHeaders.value
}) })
console.log('[DEBUG saveCanvas] PUT request successful!', res) console.log('[DEBUG saveCanvas] PUT request successful!', res)
@@ -690,6 +705,14 @@ onMounted(async () => {
} catch (e) { } catch (e) {
console.error('Failed to load canvases', 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> </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 id: string
email: string email: string
username: string username: string
displayName?: string
role?: 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> <NuxtLink to="/settings" class="text-sm text-slate-400 hover:text-white transition">Настройки</NuxtLink>
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<NuxtLink :to="`/profile/${user?.id}`" class="text-sm text-slate-400 hover:text-white transition"> <NuxtLink :to="`/profile/${user?.username || user?.id}`" class="text-sm text-slate-400 hover:text-white transition">
{{ user?.username }} {{ user?.displayName || user?.username }}
</NuxtLink> </NuxtLink>
<button @click="logout" class="text-sm text-red-400 hover:text-red-300 transition">Выйти</button> <button @click="logout" class="text-sm text-red-400 hover:text-red-300 transition">Выйти</button>
</div> </div>
+8 -8
View File
@@ -1,15 +1,15 @@
export default defineNuxtRouteMiddleware((to, from) => { export default defineNuxtRouteMiddleware((to, from) => {
const token = useCookie('xflow_token') const token = useCookie('xflow_token')
if (!token.value) {
return navigateTo('/login') if (!token.value) return navigateTo('/login')
}
try { try {
const payload = JSON.parse(atob(token.value.split('.')[1])) const base64Url = token.value.split('.')[1]
if (payload.role !== 'admin') { const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
return navigateTo('/') const payload = JSON.parse(window.atob(base64))
}
} catch { if (payload.role !== 'admin') return navigateTo('/')
} catch (e) {
return navigateTo('/login') return navigateTo('/login')
} }
}) })
+1 -172
View File
@@ -1,174 +1,3 @@
<template> <template>
<div class="min-h-screen bg-slate-950 p-6"> <NuxtPage />
<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>
</template> </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> <template>
<div class="min-h-screen bg-slate-950 p-6"> <NuxtPage />
<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>
</template> </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> <h1 class="text-2xl font-bold mb-6">Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<NuxtLink <NuxtLink
to="/canvas" to="/canvases"
class="p-6 bg-slate-800 border border-slate-700 rounded-lg hover:border-blue-500 transition group" 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> <div class="text-3xl mb-2">🗺️</div>
<h3 class="font-semibold group-hover:text-blue-400 transition">Canvas</h3> <h3 class="font-semibold group-hover:text-blue-400 transition">Проекты</h3>
<p class="text-sm text-slate-400">Визуальное управление задачами</p> <p class="text-sm text-slate-400">{{ homeStats.canvases }} канвасов доступно</p>
</NuxtLink> </NuxtLink>
<div class="p-6 bg-slate-800 border border-slate-700 rounded-lg"> <div class="p-6 bg-slate-800 border border-slate-700 rounded-lg">
<div class="text-3xl mb-2">✅</div> <div class="text-3xl mb-2">✅</div>
<h3 class="font-semibold">Задачи</h3> <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>
<div class="p-6 bg-slate-800 border border-slate-700 rounded-lg"> <div class="p-6 bg-slate-800 border border-slate-700 rounded-lg">
<div class="text-3xl mb-2">🏆</div> <div class="text-3xl mb-2">🏆</div>
<h3 class="font-semibold">Ачивки</h3> <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> </div>
</div> </div>
<ClientOnly>
<InstallPWA />
</ClientOnly>
</div> </div>
</template> </template>
<script setup> <script setup>
const { isAuthenticated } = useAuth() const { isAuthenticated, token } = useAuth()
const { stats: achievementStats, loadAchievements } = useAchievements() const homeStats = ref({
canvases: 0,
tasks: { total: 0, completed: 0 },
achievements: 0
})
const loadingStats = ref(true)
const completedTasks = computed(() => achievementStats.value.completedTasks) const fetchHomeStats = async () => {
const totalTasks = computed(() => 3) // mock total from canvas if (!isAuthenticated.value) return
const achievements = computed(() => achievementStats.value.total) 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(() => { onMounted(() => {
if (isAuthenticated.value) { fetchHomeStats()
loadAchievements()
}
}) })
</script> </script>
+8 -1
View File
@@ -33,7 +33,7 @@ definePageMeta({
layout: 'default' layout: 'default'
}) })
const { googleSignIn, initAuth } = useAuth() const { googleSignIn, initAuth, isAuthenticated } = useAuth()
const loading = ref(false) const loading = ref(false)
const signInWithGoogle = async () => { const signInWithGoogle = async () => {
@@ -49,6 +49,13 @@ const signInWithGoogle = async () => {
} }
onMounted(() => { onMounted(() => {
if (isAuthenticated.value) {
navigateTo('/')
}
initAuth() initAuth()
}) })
watch(isAuthenticated, (val) => {
if (val) navigateTo('/')
})
</script> </script>
+5
View File
@@ -224,6 +224,11 @@ const loadProfile = async () => {
userCanvases.value = canvasesData.canvases userCanvases.value = canvasesData.canvases
userActivities.value = activitiesData.activities 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) { if (isOwnProfile.value) {
const [groupsData, allGroupsData] = await Promise.all([ const [groupsData, allGroupsData] = await Promise.all([
authFetch('/api/user/groups'), authFetch('/api/user/groups'),
+113 -1
View File
@@ -3,6 +3,66 @@
<h1 class="text-2xl font-bold mb-6">Настройки</h1> <h1 class="text-2xl font-bold mb-6">Настройки</h1>
<div class="space-y-6"> <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 --> <!-- Push Notifications -->
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6"> <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"> <h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
@@ -72,10 +132,59 @@ definePageMeta({
middleware: 'auth' middleware: 'auth'
}) })
const { user } = useAuth()
const { isSupported, subscription, requestPermission, subscribe: pushSubscribe } = usePush() const { isSupported, subscription, requestPermission, subscribe: pushSubscribe } = usePush()
const { achievements, loadAchievements } = useAchievements() const { achievements, loadAchievements } = useAchievements()
const permissionGranted = ref(false) 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 enablePush = async () => {
const granted = await requestPermission() const granted = await requestPermission()
@@ -91,6 +200,9 @@ const subscribe = async () => {
onMounted(async () => { onMounted(async () => {
permissionGranted.value = Notification.permission === 'granted' permissionGranted.value = Notification.permission === 'granted'
await loadAchievements() await Promise.all([
loadAchievements(),
loadProfile()
])
}) })
</script> </script>
+94 -1
View File
@@ -5,6 +5,56 @@
<h1 class="text-3xl font-bold text-white flex items-center gap-3"> <h1 class="text-3xl font-bold text-white flex items-center gap-3">
<span class="text-blue-500">📋</span> Мои Задачи <span class="text-blue-500">📋</span> Мои Задачи
</h1> </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>
<div v-if="loading" class="flex items-center justify-center h-64"> <div v-if="loading" class="flex items-center justify-center h-64">
@@ -92,6 +142,16 @@ const columnsData = reactive({
completed: [] completed: []
}) })
const showCreateModal = ref(false)
const creating = ref(false)
const availableCanvases = ref([])
const newTask = reactive({
title: '',
canvasId: '',
priority: 'medium',
state: 'todo'
})
const columns = [ const columns = [
{ id: 'todo', title: 'К выполнению', icon: '📋', color: 'text-slate-400' }, { id: 'todo', title: 'К выполнению', icon: '📋', color: 'text-slate-400' },
{ id: 'in_progress', title: 'В работе', icon: '⏳', color: 'text-blue-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) => { const onTaskMove = async (evt, newStatus) => {
if (evt.added) { if (evt.added) {
const task = evt.added.element const task = evt.added.element
@@ -197,7 +287,10 @@ onMounted(async () => {
if (!auth.user.value) { if (!auth.user.value) {
await auth.init() await auth.init()
} }
await fetchTasks() await Promise.all([
fetchTasks(),
fetchCanvases()
])
}) })
</script> </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"
}
]
}
+30 -11
View File
@@ -1,28 +1,47 @@
import { requireAuth } from '~/server/utils/auth' 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) => { export default defineEventHandler(async (event) => {
const user = requireAuth(event) const user = requireAuth(event)
console.log(`[Admin API] Request from user: ${user.email}, Role: ${user.role}`)
if (user.role !== 'admin') { if (user.role !== 'admin') {
throw createError({ statusCode: 403, message: 'Admin access required' }) throw createError({ statusCode: 403, message: 'Admin access required' })
} }
const usersList = Array.from(users.values()) // 1. Получаем список всех пользователей с их профилями
const canvasesList = Array.from(canvases.values()).map(c => ({ const usersList = await prisma.user.findMany({
id: c.id, include: { profile: true }
name: c.name, })
nodeCount: c.nodes.length,
updatedAt: c.updatedAt // 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 { return {
users: usersList, users: usersList,
canvases: canvasesList, canvases: canvasesList.map(c => ({
...c,
nodeCount: (c.nodes as any[])?.length || 0
})),
stats: { stats: {
users: usersList.length, users: usersList.length,
canvases: canvasesList.length, canvases: canvasesList.length,
nodes: canvasesList.reduce((sum, c) => sum + c.nodeCount, 0), nodes: canvasesList.reduce((sum, c) => sum + ((c.nodes as any[])?.length || 0), 0),
pushes: pushSubscriptions.size pushes: pushCount
} }
} }
}) })
+10 -3
View File
@@ -34,12 +34,15 @@ export default defineEventHandler(async (event) => {
const fbUser: FirebaseUser = verifyRes.users[0] const fbUser: FirebaseUser = verifyRes.users[0]
const email = fbUser.email const email = fbUser.email
const username = fbUser.displayName || email.split('@')[0] const username = email.split('@')[0]
const userId = fbUser.localId const userId = fbUser.localId
const user = await prisma.user.upsert({ const user = await prisma.user.upsert({
where: { email }, where: { email },
update: { username }, update: {
username
// Мы не затираем профиль при каждом входе, но нам нужно его получить
},
create: { create: {
id: userId, id: userId,
email, email,
@@ -48,10 +51,13 @@ export default defineEventHandler(async (event) => {
role: 'user', role: 'user',
profile: { profile: {
create: { create: {
displayName: username, displayName: fbUser.displayName || username,
avatar: fbUser.photoUrl || `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}` 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, id: user.id,
email: user.email, email: user.email,
username: user.username, username: user.username,
displayName: user.profile?.displayName || user.username,
role: user.role 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 user = requireAuth(event)
const allGroups = await prisma.group.findMany({ const allGroups = await prisma.group.findMany({
where: { isPublic: true }, where: user.role === 'admin' ? {} : { isPublic: true },
include: { include: {
members: { include: { user: { select: { id: true, username: true } } } }, members: { include: { user: { select: { id: true, username: true } } } },
_count: { select: { members: true, canvases: 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 const { taskId, canvasId, newState } = body
if (!taskId || !canvasId || !newState) { 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({ const canvas = await prisma.canvas.findUnique({
where: { id: canvasId } where: { id: canvasId }
}) })
@@ -19,7 +19,21 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 404, message: 'Canvas not found' }) 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 nodes = (canvas.nodes as any[]) || []
const nodeIdx = nodes.findIndex(n => n.id === taskId) const nodeIdx = nodes.findIndex(n => n.id === taskId)
@@ -29,18 +43,15 @@ export default defineEventHandler(async (event) => {
// Обновляем состояние // Обновляем состояние
nodes[nodeIdx].state = newState nodes[nodeIdx].state = newState
if (nodes[nodeIdx].data) {
nodes[nodeIdx].data.state = newState
}
// Также обновляем дату изменения, если нужно // 4. Сохраняем обратно в БД
nodes[nodeIdx].updatedAt = new Date().toISOString()
// 3. Сохранить изменения в БД
await prisma.canvas.update({ await prisma.canvas.update({
where: { id: canvasId }, where: { id: canvasId },
data: { nodes } data: { nodes }
}) })
return { return { success: true }
success: true,
message: `Task ${taskId} moved to ${newState}`
}
}) })
+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 // Get user's recent activities
const userActivities = Array.from(activities.values()) const user = await prisma.user.findFirst({
.filter(activity => activity.userId === userId) where: {
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) OR: [{ id: userId }, { username: userId }]
.slice(0, 50) // Limit to 50 most recent }
})
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 } return { activities: userActivities }
}) })
@@ -7,6 +7,12 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, message: 'User ID is required' }) 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 // Get user's group memberships
const userGroupIds = Array.from(userGroups.values()) const userGroupIds = Array.from(userGroups.values())
.filter(membership => membership.userId === userId) .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' }) throw createError({ statusCode: 400, message: 'User ID is required' })
} }
const profile = await prisma.profile.findUnique({ let profile = await prisma.profile.findFirst({
where: { userId }, where: {
OR: [
{ userId: userId },
{ user: { username: userId } }
]
},
include: { include: {
user: { user: {
include: { include: {
_count: { _count: {
select: { select: { canvases: true, followers: true, following: true }
canvases: true,
followers: true,
following: true
}
} }
} }
} }
} }
}) })
// If profile doesn't exist but user does, create it
if (!profile) { if (!profile) {
// If profile doesn't exist but user might exist in Firebase, const user = await prisma.user.findFirst({
// we could potentially create it here or return 404 where: {
throw createError({ statusCode: 404, message: 'Profile not found' }) 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 { return {
+16 -5
View File
@@ -1,25 +1,36 @@
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import { H3Event, createError } from 'h3' import { H3Event, createError } from 'h3'
const JWT_SECRET = process.env.NUXT_JWT_SECRET || 'your-super-secret-jwt-key'
export interface AuthUser { export interface AuthUser {
id: string id: string
email: string email: string
username: string username: string
displayName?: string
role?: string role?: string
} }
export function generateToken(user: AuthUser): string { export function generateToken(user: AuthUser): string {
const config = useRuntimeConfig()
const secret = config.jwtSecret || 'your-super-secret-jwt-key'
return jwt.sign( 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' } { expiresIn: '7d' }
) )
} }
export function verifyToken(token: string): AuthUser { 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 { export function getAuthUser(event: H3Event): AuthUser | null {
+18 -38
View File
@@ -8,52 +8,32 @@ export default defineNuxtConfig({
'@vueuse/nuxt' '@vueuse/nuxt'
], ],
experimental: {
appManifest: false
},
css: ['~/assets/css/main.css'], css: ['~/assets/css/main.css'],
tailwindcss: { tailwindcss: {
cssPath: '~/assets/css/main.css' cssPath: '~/assets/css/main.css'
}, },
pwa: { app: {
registerType: 'autoUpdate', head: {
workbox: { title: 'XFlow',
globPatterns: ['**/*.{js,css,html,png,svg,ico}'], link: [
}, { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
client: { { rel: 'manifest', href: '/manifest.json' }
installPrompt: true,
},
manifest: {
name: 'XFlow',
short_name: 'XFlow',
description: 'XFlow Obsidian Canvas PWA',
theme_color: '#0f172a',
background_color: '#0f172a',
display: 'standalone',
orientation: 'portrait',
scope: '/',
start_url: '/',
icons: [
{
src: 'pwa-192x192.svg',
sizes: '192x192',
type: 'image/svg+xml'
},
{
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'
}
] ]
}, }
},
pwa: {
injectRegister: 'script',
registerType: 'autoUpdate',
devOptions: { devOptions: {
enabled: false enabled: false,
suppressWarnings: true
} }
}, },
+2 -1
View File
@@ -1,11 +1,12 @@
import { defineConfig } from "prisma/config"; import { defineConfig } from "prisma/config";
// Призма 7 иногда требует явной загрузки .env для некоторых команд
export default defineConfig({ export default defineConfig({
schema: "prisma/schema.prisma", schema: "prisma/schema.prisma",
migrations: { migrations: {
path: "prisma/migrations", path: "prisma/migrations",
}, },
datasource: { datasource: {
url: process.env["DATABASE_URL"], url: process.env.DATABASE_URL || "postgresql://admin:secure_master_pass@localhost:5432/xflow_crm_db",
}, },
}); });
+40 -34
View File
@@ -8,20 +8,19 @@ datasource db {
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String @unique
username String @unique username String @unique
password String password String
role String @default("user") role String @default("user")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
profile Profile?
canvases Canvas[]
groupMembers GroupMember[]
activities Activity[] activities Activity[]
followers Follow[] @relation("following") canvases Canvas[]
following Follow[] @relation("follower") following Follow[] @relation("follower")
followers Follow[] @relation("following")
groupMembers GroupMember[]
profile Profile?
} }
model Profile { model Profile {
@@ -34,8 +33,7 @@ model Profile {
followerCount Int @default(0) followerCount Int @default(0)
followingCount Int @default(0) followingCount Int @default(0)
joinedAt DateTime @default(now()) joinedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
model Canvas { model Canvas {
@@ -48,22 +46,22 @@ model Canvas {
edges Json @default("[]") edges Json @default("[]")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
group Group? @relation(fields: [groupId], references: [id])
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
group Group? @relation(fields: [groupId], references: [id], onDelete: SetNull)
} }
model Group { model Group {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
description String @default("") description String @default("")
isPublic Boolean @default(true) isPublic Boolean @default(true)
ownerId String ownerId String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
controlType String? @default("REMOTE")
members GroupMember[] canvases Canvas[]
canvases Canvas[] members GroupMember[]
Kiosk Kiosk[]
} }
model GroupMember { model GroupMember {
@@ -72,9 +70,8 @@ model GroupMember {
groupId String groupId String
role String @default("member") role String @default("member")
joinedAt DateTime @default(now()) joinedAt DateTime @default(now())
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@unique([userId, groupId]) @@unique([userId, groupId])
} }
@@ -87,8 +84,7 @@ model Activity {
targetType String targetType String
timestamp DateTime @default(now()) timestamp DateTime @default(now())
metadata String? metadata String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
model Follow { model Follow {
@@ -96,9 +92,19 @@ model Follow {
followerId String followerId String
followingId String followingId String
since DateTime @default(now()) since DateTime @default(now())
follower User @relation("follower", fields: [followerId], references: [id], onDelete: Cascade)
follower User @relation("follower", fields: [followerId], references: [id], onDelete: Cascade) following User @relation("following", fields: [followingId], references: [id], onDelete: Cascade)
following User @relation("following", fields: [followingId], references: [id], onDelete: Cascade)
@@unique([followerId, followingId]) @@unique([followerId, followingId])
} }
model Kiosk {
id String @id
name String
location String?
groupId String
status String @default("offline")
lastSeen DateTime @default(now())
createdAt DateTime @default(now())
Group Group @relation(fields: [groupId], references: [id])
}
+47
View File
@@ -0,0 +1,47 @@
const { PrismaClient } = require('@prisma/client')
const { PrismaPg } = require('@prisma/adapter-pg')
const { Pool } = require('pg')
const connectionString = "postgresql://admin:secure_master_pass@localhost:5432/xflow_crm_db"
const pool = new Pool({ connectionString })
const adapter = new PrismaPg(pool)
const prisma = new PrismaClient({ adapter })
async function main() {
const admins = ["ormix16@gmail.com", "almalexyz@gmail.com"]
for (const email of admins) {
const username = email.split('@')[0]
console.log(`🔧 Setting prefix as username for: ${email}...`)
const user = await prisma.user.upsert({
where: { email },
update: {
username: username,
role: 'admin'
},
create: {
id: `admin-init-${Math.random().toString(36).slice(2, 7)}`,
email: email,
username: username,
password: 'managed-auth',
role: 'admin',
profile: {
create: {
displayName: username,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${username}`
}
}
}
})
console.log(`✅ ${email} updated. URL will be: /profile/${user.username}`)
}
}
main()
.catch(console.error)
.finally(async () => {
await prisma.$disconnect()
await pool.end()
})