feat: stabilize admin dashboard, refactor group management, implement PWA, and fix routing
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -5,6 +5,7 @@ interface User {
|
|||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
username: string
|
username: string
|
||||||
|
displayName?: string
|
||||||
role?: string
|
role?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
|
||||||
|
|||||||
@@ -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">×</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>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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 } }
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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}`
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+17
-37
@@ -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
@@ -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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+20
-14
@@ -15,13 +15,12 @@ model User {
|
|||||||
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,7 +33,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,9 +46,8 @@ 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 {
|
||||||
@@ -61,9 +58,10 @@ model Group {
|
|||||||
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())
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([userId, groupId])
|
@@unique([userId, groupId])
|
||||||
}
|
}
|
||||||
@@ -87,7 +84,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user