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">
|
||||
<!-- Toolbar -->
|
||||
<div class="h-10 bg-slate-900 border-b border-slate-700 flex items-center px-3 gap-2 z-20">
|
||||
<input v-model="canvasName" @change="saveCanvas" class="text-sm font-semibold text-slate-300 mr-2 bg-transparent border border-transparent hover:border-slate-600 focus:border-blue-500 focus:bg-slate-800 outline-none rounded px-2 py-1 w-64 transition" title="Переименовать карту" />
|
||||
<input v-model="canvasName" @change="saveCanvas" class="text-sm font-semibold text-slate-300 mr-2 bg-transparent border border-transparent hover:border-slate-600 focus:border-blue-500 focus:bg-slate-800 outline-none rounded px-2 py-1 w-48 transition" title="Переименовать карту" />
|
||||
|
||||
<!-- Group Selection -->
|
||||
<div class="flex items-center gap-1 bg-slate-800 border border-slate-700 rounded px-2 py-1">
|
||||
<span class="text-[10px] text-slate-500 uppercase font-bold mr-1">Группа:</span>
|
||||
<select v-model="selectedGroupId" @change="saveCanvas" class="bg-transparent text-xs text-slate-300 outline-none cursor-pointer hover:text-white transition">
|
||||
<option :value="null">Личная (нет)</option>
|
||||
<option v-for="g in groupsList" :key="g.id" :value="g.id">
|
||||
👥 {{ g.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<span v-if="isDirty" class="px-2 py-1 bg-yellow-600 text-yellow-100 rounded text-xs font-medium">
|
||||
● Несохранено
|
||||
</span>
|
||||
@@ -204,6 +216,8 @@ const authHeaders = computed(() => ({ Authorization: `Bearer ${auth.token.value}
|
||||
const elements = ref([])
|
||||
const selectedNode = ref(null)
|
||||
const canvasName = ref(props.canvasId)
|
||||
const selectedGroupId = ref(null)
|
||||
const groupsList = ref([])
|
||||
|
||||
const nodeTypes = [
|
||||
{ type: 'text', name: 'Текст', icon: '📝', description: 'Markdown заметка' },
|
||||
@@ -219,6 +233,7 @@ const loadData = async () => {
|
||||
const data = await loadCanvas(props.canvasId)
|
||||
elements.value = transformToFlowElements(data)
|
||||
canvasName.value = data.name || props.canvasId
|
||||
selectedGroupId.value = data.groupId || null
|
||||
// Mark initial state as saved
|
||||
markAsSaved(data)
|
||||
}
|
||||
@@ -639,7 +654,7 @@ const saveCanvas = async () => {
|
||||
console.log('[DEBUG saveCanvas] Sending PUT request...')
|
||||
const res = await $fetch(`/api/canvas/${props.canvasId}`, {
|
||||
method: 'PUT',
|
||||
body: { ...obsidianCanvas, name: canvasName.value },
|
||||
body: { ...obsidianCanvas, name: canvasName.value, groupId: selectedGroupId.value },
|
||||
headers: authHeaders.value
|
||||
})
|
||||
console.log('[DEBUG saveCanvas] PUT request successful!', res)
|
||||
@@ -690,6 +705,14 @@ onMounted(async () => {
|
||||
} catch (e) {
|
||||
console.error('Failed to load canvases', e)
|
||||
}
|
||||
|
||||
// Load groups for assignment
|
||||
try {
|
||||
const data = await $fetch('/api/groups')
|
||||
groupsList.value = data.groups || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load groups', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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
|
||||
email: string
|
||||
username: string
|
||||
displayName?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
<NuxtLink to="/settings" class="text-sm text-slate-400 hover:text-white transition">Настройки</NuxtLink>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<NuxtLink :to="`/profile/${user?.id}`" class="text-sm text-slate-400 hover:text-white transition">
|
||||
{{ user?.username }}
|
||||
<NuxtLink :to="`/profile/${user?.username || user?.id}`" class="text-sm text-slate-400 hover:text-white transition">
|
||||
{{ user?.displayName || user?.username }}
|
||||
</NuxtLink>
|
||||
<button @click="logout" class="text-sm text-red-400 hover:text-red-300 transition">Выйти</button>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
const token = useCookie('xflow_token')
|
||||
if (!token.value) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
if (!token.value) return navigateTo('/login')
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.value.split('.')[1]))
|
||||
if (payload.role !== 'admin') {
|
||||
return navigateTo('/')
|
||||
}
|
||||
} catch {
|
||||
const base64Url = token.value.split('.')[1]
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const payload = JSON.parse(window.atob(base64))
|
||||
|
||||
if (payload.role !== 'admin') return navigateTo('/')
|
||||
} catch (e) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
})
|
||||
|
||||
+1
-172
@@ -1,174 +1,3 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950 p-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-slate-100">XFlow Admin Panel</h1>
|
||||
<div class="flex items-center gap-2 text-sm text-slate-400">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||
Online
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-3xl font-bold text-blue-400">{{ stats.users }}</div>
|
||||
<div class="text-sm text-slate-400 mt-1">Registered Users</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-3xl font-bold text-emerald-400">{{ stats.canvases }}</div>
|
||||
<div class="text-sm text-slate-400 mt-1">Canvases</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-3xl font-bold text-yellow-400">{{ stats.nodes }}</div>
|
||||
<div class="text-sm text-slate-400 mt-1">Total Nodes</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-3xl font-bold text-purple-400">{{ stats.pushes }}</div>
|
||||
<div class="text-sm text-slate-400 mt-1">Push Subscribers</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Users -->
|
||||
<div class="lg:col-span-2 bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-slate-200">Users</h2>
|
||||
<button @click="refreshData" class="text-sm text-blue-400 hover:text-blue-300">Refresh</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-800 text-slate-400">
|
||||
<tr>
|
||||
<th class="text-left p-3">Email</th>
|
||||
<th class="text-left p-3">Username</th>
|
||||
<th class="text-left p-3">Role</th>
|
||||
<th class="text-left p-3">ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-slate-300">
|
||||
<tr v-for="user in users" :key="user.id" class="border-t border-slate-800">
|
||||
<td class="p-3">{{ user.email }}</td>
|
||||
<td class="p-3">{{ user.username }}</td>
|
||||
<td class="p-3">
|
||||
<span :class="roleBadge(user.role)">{{ user.role }}</span>
|
||||
</td>
|
||||
<td class="p-3 text-xs text-slate-500 font-mono">{{ user.id }}</td>
|
||||
</tr>
|
||||
<tr v-if="users.length === 0">
|
||||
<td colspan="4" class="p-6 text-center text-slate-500">No users found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvases -->
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-slate-200">Canvases</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-800">
|
||||
<div v-for="canvas in canvases" :key="canvas.id" class="p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<NuxtLink :to="`/canvas/${canvas.id}`" class="text-blue-400 hover:text-blue-300 font-medium">
|
||||
{{ canvas.name }}
|
||||
</NuxtLink>
|
||||
<span class="text-xs text-slate-500">{{ canvas.nodeCount }} nodes</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 mt-1">{{ formatDate(canvas.updatedAt) }}</div>
|
||||
</div>
|
||||
<div v-if="canvases.length === 0" class="p-6 text-center text-slate-500">
|
||||
No canvases yet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Script Runner -->
|
||||
<div class="mt-6 bg-slate-900 border border-slate-700 rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold text-slate-200 mb-4">Script Runner</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="script in scripts"
|
||||
:key="script.id"
|
||||
class="bg-slate-800 rounded-lg p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-medium text-slate-200">{{ script.name }}</span>
|
||||
<span class="text-xs px-2 py-1 rounded-full" :class="statusClass(script.status)">{{ script.status }}</span>
|
||||
</div>
|
||||
<button
|
||||
@click="runScript(script.id)"
|
||||
class="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-sm transition"
|
||||
:disabled="script.status === 'running'"
|
||||
>
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'admin']
|
||||
})
|
||||
|
||||
const users = ref([])
|
||||
const canvases = ref([])
|
||||
const stats = reactive({ users: 0, canvases: 0, nodes: 0, pushes: 0 })
|
||||
|
||||
const scripts = ref([
|
||||
{ id: 'sync-git', name: 'Sync Git Server', status: 'idle' },
|
||||
{ id: 'cleanup-nodes', name: 'Cleanup Orphan Nodes', status: 'idle' },
|
||||
{ id: 'export-all', name: 'Export All Canvases', status: 'idle' }
|
||||
])
|
||||
|
||||
const refreshData = async () => {
|
||||
try {
|
||||
const data = await $fetch('/api/admin/stats')
|
||||
users.value = data.users || []
|
||||
canvases.value = data.canvases || []
|
||||
stats.users = data.stats?.users || 0
|
||||
stats.canvases = data.stats?.canvases || 0
|
||||
stats.nodes = data.stats?.nodes || 0
|
||||
stats.pushes = data.stats?.pushes || 0
|
||||
} catch (e) {
|
||||
console.error('Failed to load admin data', e)
|
||||
}
|
||||
}
|
||||
|
||||
const roleBadge = (role) => {
|
||||
return role === 'admin'
|
||||
? 'px-2 py-0.5 bg-red-900/50 text-red-300 rounded text-xs'
|
||||
: 'px-2 py-0.5 bg-slate-700 text-slate-300 rounded text-xs'
|
||||
}
|
||||
|
||||
const statusClass = (status) => {
|
||||
if (status === 'running') return 'bg-yellow-900/50 text-yellow-300'
|
||||
if (status === 'completed') return 'bg-emerald-900/50 text-emerald-300'
|
||||
return 'bg-slate-700 text-slate-400'
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return 'N/A'
|
||||
return new Date(date).toLocaleDateString('ru-RU')
|
||||
}
|
||||
|
||||
const runScript = async (scriptId) => {
|
||||
const script = scripts.value.find(s => s.id === scriptId)
|
||||
if (!script || script.status === 'running') return
|
||||
script.status = 'running'
|
||||
try {
|
||||
await $fetch(`/api/admin/scripts/${scriptId}`, { method: 'POST' })
|
||||
script.status = 'completed'
|
||||
} catch (e) {
|
||||
script.status = 'failed'
|
||||
}
|
||||
setTimeout(() => { script.status = 'idle' }, 3000)
|
||||
}
|
||||
|
||||
onMounted(refreshData)
|
||||
</script>
|
||||
|
||||
+1
-148
@@ -1,150 +1,3 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950 p-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-slate-100">Team Management</h1>
|
||||
<p class="text-slate-400 mt-1">Create and manage groups, members and permissions</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition flex items-center gap-2"
|
||||
>
|
||||
<span class="text-xl">+</span> New Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Groups Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="bg-slate-900 border border-slate-800 rounded-2xl p-6 hover:border-slate-700 transition group"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="w-12 h-12 bg-blue-900/30 rounded-xl flex items-center justify-center text-blue-400 text-xl font-bold">
|
||||
{{ group.name.charAt(0) }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<span v-if="!group.isPublic" class="px-2 py-1 bg-amber-900/30 text-amber-400 text-xs rounded-md border border-amber-900/50">Private</span>
|
||||
<span class="px-2 py-1 bg-slate-800 text-slate-400 text-xs rounded-md">{{ group.memberCount }} members</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-semibold text-slate-100 mb-2">{{ group.name }}</h3>
|
||||
<p class="text-slate-400 text-sm mb-6 line-clamp-2">{{ group.description || 'No description provided' }}</p>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t border-slate-800">
|
||||
<div class="flex -space-x-2">
|
||||
<div v-for="i in Math.min(group.memberCount, 3)" :key="i" class="w-8 h-8 rounded-full border-2 border-slate-900 bg-slate-800 flex items-center justify-center text-[10px] text-slate-400 font-bold">
|
||||
U{{ i }}
|
||||
</div>
|
||||
<div v-if="group.memberCount > 3" class="w-8 h-8 rounded-full border-2 border-slate-900 bg-slate-700 flex items-center justify-center text-[10px] text-slate-300 font-bold">
|
||||
+{{ group.memberCount - 3 }}
|
||||
</div>
|
||||
</div>
|
||||
<button @click="openGroupSettings(group)" class="text-blue-400 hover:text-blue-300 text-sm font-medium">Manage</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Group Modal -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md p-6 shadow-2xl">
|
||||
<h2 class="text-2xl font-bold text-slate-100 mb-4">Create New Group</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-400 mb-1">Group Name</label>
|
||||
<input
|
||||
v-model="newGroup.name"
|
||||
type="text"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-slate-100 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
placeholder="e.g. Development Team"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-400 mb-1">Description</label>
|
||||
<textarea
|
||||
v-model="newGroup.description"
|
||||
rows="3"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-slate-100 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
placeholder="What is this group for?"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<input type="checkbox" v-model="newGroup.isPublic" id="isPublic" class="w-5 h-5 rounded border-slate-700 bg-slate-800 text-blue-600 focus:ring-blue-500">
|
||||
<label for="isPublic" class="text-slate-300 select-none">Make group public</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-8">
|
||||
<button
|
||||
@click="showCreateModal = false"
|
||||
class="flex-1 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg font-medium transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="createGroup"
|
||||
class="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ loading ? 'Creating...' : 'Create Group' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'admin']
|
||||
})
|
||||
|
||||
const groups = ref([])
|
||||
const showCreateModal = ref(false)
|
||||
const loading = ref(false)
|
||||
const newGroup = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
isPublic: true
|
||||
})
|
||||
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
const data = await $fetch('/api/groups')
|
||||
groups.value = data.groups
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch groups', e)
|
||||
}
|
||||
}
|
||||
|
||||
const createGroup = async () => {
|
||||
if (!newGroup.name.trim()) return
|
||||
loading.value = true
|
||||
try {
|
||||
await $fetch('/api/groups', {
|
||||
method: 'POST',
|
||||
body: newGroup
|
||||
})
|
||||
showCreateModal.value = false
|
||||
newGroup.name = ''
|
||||
newGroup.description = ''
|
||||
await fetchGroups()
|
||||
} catch (e) {
|
||||
alert('Failed to create group')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openGroupSettings = (group) => {
|
||||
// To be implemented: detailed group management
|
||||
navigateTo(`/admin/groups/${group.id}`)
|
||||
}
|
||||
|
||||
onMounted(fetchGroups)
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<NuxtLink
|
||||
to="/canvas"
|
||||
to="/canvases"
|
||||
class="p-6 bg-slate-800 border border-slate-700 rounded-lg hover:border-blue-500 transition group"
|
||||
>
|
||||
<div class="text-3xl mb-2">🗺️</div>
|
||||
<h3 class="font-semibold group-hover:text-blue-400 transition">Canvas</h3>
|
||||
<p class="text-sm text-slate-400">Визуальное управление задачами</p>
|
||||
<h3 class="font-semibold group-hover:text-blue-400 transition">Проекты</h3>
|
||||
<p class="text-sm text-slate-400">{{ homeStats.canvases }} канвасов доступно</p>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="p-6 bg-slate-800 border border-slate-700 rounded-lg">
|
||||
<div class="text-3xl mb-2">✅</div>
|
||||
<h3 class="font-semibold">Задачи</h3>
|
||||
<p class="text-sm text-slate-400">{{ completedTasks }} / {{ totalTasks }} выполнено</p>
|
||||
<p class="text-sm text-slate-400">{{ homeStats.tasks.completed }} / {{ homeStats.tasks.total }} выполнено</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-slate-800 border border-slate-700 rounded-lg">
|
||||
<div class="text-3xl mb-2">🏆</div>
|
||||
<h3 class="font-semibold">Ачивки</h3>
|
||||
<p class="text-sm text-slate-400">{{ achievements }} разблокировано</p>
|
||||
<p class="text-sm text-slate-400">{{ homeStats.achievements }} разблокировано</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ClientOnly>
|
||||
<InstallPWA />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { stats: achievementStats, loadAchievements } = useAchievements()
|
||||
const { isAuthenticated, token } = useAuth()
|
||||
const homeStats = ref({
|
||||
canvases: 0,
|
||||
tasks: { total: 0, completed: 0 },
|
||||
achievements: 0
|
||||
})
|
||||
const loadingStats = ref(true)
|
||||
|
||||
const completedTasks = computed(() => achievementStats.value.completedTasks)
|
||||
const totalTasks = computed(() => 3) // mock total from canvas
|
||||
const achievements = computed(() => achievementStats.value.total)
|
||||
const fetchHomeStats = async () => {
|
||||
if (!isAuthenticated.value) return
|
||||
try {
|
||||
const data = await $fetch('/api/user/stats', {
|
||||
headers: { Authorization: `Bearer ${token.value}` }
|
||||
})
|
||||
homeStats.value = data.stats
|
||||
} catch (e) {
|
||||
console.error('Failed to load home stats', e)
|
||||
} finally {
|
||||
loadingStats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isAuthenticated.value) {
|
||||
loadAchievements()
|
||||
}
|
||||
fetchHomeStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
+8
-1
@@ -33,7 +33,7 @@ definePageMeta({
|
||||
layout: 'default'
|
||||
})
|
||||
|
||||
const { googleSignIn, initAuth } = useAuth()
|
||||
const { googleSignIn, initAuth, isAuthenticated } = useAuth()
|
||||
const loading = ref(false)
|
||||
|
||||
const signInWithGoogle = async () => {
|
||||
@@ -49,6 +49,13 @@ const signInWithGoogle = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isAuthenticated.value) {
|
||||
navigateTo('/')
|
||||
}
|
||||
initAuth()
|
||||
})
|
||||
|
||||
watch(isAuthenticated, (val) => {
|
||||
if (val) navigateTo('/')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -224,6 +224,11 @@ const loadProfile = async () => {
|
||||
userCanvases.value = canvasesData.canvases
|
||||
userActivities.value = activitiesData.activities
|
||||
|
||||
// Redirect to pretty URL if using UID but username exists
|
||||
if (profile.value.user?.username && route.params.id === profile.value.userId) {
|
||||
navigateTo(`/profile/${profile.value.user.username}`, { replace: true })
|
||||
}
|
||||
|
||||
if (isOwnProfile.value) {
|
||||
const [groupsData, allGroupsData] = await Promise.all([
|
||||
authFetch('/api/user/groups'),
|
||||
|
||||
+113
-1
@@ -3,6 +3,66 @@
|
||||
<h1 class="text-2xl font-bold mb-6">Настройки</h1>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Profile Settings -->
|
||||
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span>👤</span> Личный профиль
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<img :src="profileForm.avatar" class="w-16 h-16 rounded-full bg-slate-700 object-cover border-2 border-blue-500/30" />
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500 uppercase font-bold">Ссылка на аватар</label>
|
||||
<input v-model="profileForm.avatar" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 uppercase font-bold">Имя (Display Name)</label>
|
||||
<input v-model="profileForm.displayName" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 uppercase font-bold">О себе (Bio)</label>
|
||||
<textarea v-model="profileForm.bio" rows="3" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 outline-none focus:border-blue-500 resize-none"></textarea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="saveProfile"
|
||||
:disabled="saving"
|
||||
class="w-full py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition disabled:opacity-50"
|
||||
>
|
||||
{{ saving ? 'Сохранение...' : 'Сохранить изменения' }}
|
||||
</button>
|
||||
<p v-if="saveMessage" :class="saveMessage.type === 'error' ? 'text-red-400' : 'text-emerald-400'" class="text-center text-xs mt-2">
|
||||
{{ saveMessage.text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- My Groups -->
|
||||
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span>👥</span> Мои Группы
|
||||
</h2>
|
||||
<div v-if="userGroups.length === 0" class="text-slate-500 text-sm py-4">
|
||||
Вы не состоите ни в одной группе.
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="g in userGroups" :key="g.id" class="flex items-center justify-between p-3 bg-slate-900/50 rounded-lg border border-slate-700">
|
||||
<div>
|
||||
<div class="font-medium text-slate-200">{{ g.name }}</div>
|
||||
<div class="text-xs text-slate-500">{{ g.description }}</div>
|
||||
</div>
|
||||
<span :class="g.membershipRole === 'admin' ? 'text-purple-400 bg-purple-900/20' : 'text-blue-400 bg-blue-900/20'" class="text-[10px] uppercase font-bold px-2 py-0.5 rounded border border-current/20">
|
||||
{{ g.membershipRole }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Push Notifications -->
|
||||
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
@@ -72,10 +132,59 @@ definePageMeta({
|
||||
middleware: 'auth'
|
||||
})
|
||||
|
||||
const { user } = useAuth()
|
||||
const { isSupported, subscription, requestPermission, subscribe: pushSubscribe } = usePush()
|
||||
const { achievements, loadAchievements } = useAchievements()
|
||||
|
||||
const permissionGranted = ref(false)
|
||||
const saving = ref(false)
|
||||
const saveMessage = ref(null)
|
||||
const userGroups = ref([])
|
||||
|
||||
const profileForm = reactive({
|
||||
displayName: '',
|
||||
username: '',
|
||||
bio: '',
|
||||
avatar: ''
|
||||
})
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const data = await $fetch(`/api/users/${user.value.id}/profile`, {
|
||||
headers: { Authorization: `Bearer ${useAuth().token.value}` }
|
||||
})
|
||||
const groupsData = await $fetch('/api/user/groups', {
|
||||
headers: { Authorization: `Bearer ${useAuth().token.value}` }
|
||||
})
|
||||
userGroups.value = groupsData.groups || []
|
||||
|
||||
profileForm.displayName = data.profile.displayName || ''
|
||||
profileForm.username = data.profile.user.username || ''
|
||||
profileForm.bio = data.profile.bio || ''
|
||||
profileForm.avatar = data.profile.avatar || ''
|
||||
} catch (e) {
|
||||
console.error('Failed to load profile', e)
|
||||
}
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
saving.value = true
|
||||
saveMessage.value = null
|
||||
try {
|
||||
await $fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
body: profileForm,
|
||||
headers: { Authorization: `Bearer ${useAuth().token.value}` }
|
||||
})
|
||||
saveMessage.value = { type: 'success', text: 'Профиль обновлен успешно!' }
|
||||
// Update local user data if needed
|
||||
user.value.username = profileForm.username
|
||||
} catch (e) {
|
||||
saveMessage.value = { type: 'error', text: e.data?.message || 'Ошибка при сохранении' }
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const enablePush = async () => {
|
||||
const granted = await requestPermission()
|
||||
@@ -91,6 +200,9 @@ const subscribe = async () => {
|
||||
|
||||
onMounted(async () => {
|
||||
permissionGranted.value = Notification.permission === 'granted'
|
||||
await loadAchievements()
|
||||
await Promise.all([
|
||||
loadAchievements(),
|
||||
loadProfile()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
+94
-1
@@ -5,6 +5,56 @@
|
||||
<h1 class="text-3xl font-bold text-white flex items-center gap-3">
|
||||
<span class="text-blue-500">📋</span> Мои Задачи
|
||||
</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="showCreateModal = true" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium shadow-lg shadow-blue-900/20 transition flex items-center gap-2">
|
||||
<span>➕</span> Новая задача
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Task Modal -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" @click.self="showCreateModal = false">
|
||||
<div class="bg-slate-800 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
||||
<h2 class="text-xl font-bold text-white mb-6">Создать новую задачу</h2>
|
||||
<form @submit.prevent="createTask" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 uppercase font-bold">Название задачи</label>
|
||||
<input v-model="newTask.title" required class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500" placeholder="Что нужно сделать?" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 uppercase font-bold">Проект (Канвас)</label>
|
||||
<select v-model="newTask.canvasId" required class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500">
|
||||
<option value="" disabled>Выберите проект...</option>
|
||||
<option v-for="c in availableCanvases" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 uppercase font-bold">Приоритет</label>
|
||||
<select v-model="newTask.priority" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500">
|
||||
<option value="low">Низкий</option>
|
||||
<option value="medium">Средний</option>
|
||||
<option value="high">Высокий</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 uppercase font-bold">Статус</label>
|
||||
<select v-model="newTask.state" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500">
|
||||
<option v-for="col in columns" :key="col.id" :value="col.id">{{ col.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="submit" :disabled="creating" class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition disabled:opacity-50">
|
||||
{{ creating ? 'Создание...' : 'Создать задачу' }}
|
||||
</button>
|
||||
<button type="button" @click="showCreateModal = false" class="px-6 py-3 bg-slate-700 hover:bg-slate-600 text-white rounded-xl font-bold transition">Отмена</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center h-64">
|
||||
@@ -92,6 +142,16 @@ const columnsData = reactive({
|
||||
completed: []
|
||||
})
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const creating = ref(false)
|
||||
const availableCanvases = ref([])
|
||||
const newTask = reactive({
|
||||
title: '',
|
||||
canvasId: '',
|
||||
priority: 'medium',
|
||||
state: 'todo'
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ id: 'todo', title: 'К выполнению', icon: '📋', color: 'text-slate-400' },
|
||||
{ id: 'in_progress', title: 'В работе', icon: '⏳', color: 'text-blue-400' },
|
||||
@@ -125,6 +185,36 @@ const fetchTasks = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCanvases = async () => {
|
||||
try {
|
||||
const res = await $fetch('/api/canvas')
|
||||
availableCanvases.value = res.canvases || []
|
||||
if (availableCanvases.value.length > 0) {
|
||||
newTask.canvasId = availableCanvases.value[0].id
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load canvases', e)
|
||||
}
|
||||
}
|
||||
|
||||
const createTask = async () => {
|
||||
creating.value = true
|
||||
try {
|
||||
await $fetch('/api/tasks/create', {
|
||||
method: 'POST',
|
||||
body: newTask,
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
showCreateModal.value = false
|
||||
newTask.title = ''
|
||||
await fetchTasks() // Refresh list
|
||||
} catch (e) {
|
||||
alert('Ошибка при создании задачи: ' + (e.data?.message || e.message))
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onTaskMove = async (evt, newStatus) => {
|
||||
if (evt.added) {
|
||||
const task = evt.added.element
|
||||
@@ -197,7 +287,10 @@ onMounted(async () => {
|
||||
if (!auth.user.value) {
|
||||
await auth.init()
|
||||
}
|
||||
await fetchTasks()
|
||||
await Promise.all([
|
||||
fetchTasks(),
|
||||
fetchCanvases()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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 { users, canvases, pushSubscriptions } from '~/server/utils/db'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
console.log(`[Admin API] Request from user: ${user.email}, Role: ${user.role}`)
|
||||
|
||||
if (user.role !== 'admin') {
|
||||
throw createError({ statusCode: 403, message: 'Admin access required' })
|
||||
}
|
||||
|
||||
const usersList = Array.from(users.values())
|
||||
const canvasesList = Array.from(canvases.values()).map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
nodeCount: c.nodes.length,
|
||||
updatedAt: c.updatedAt
|
||||
}))
|
||||
// 1. Получаем список всех пользователей с их профилями
|
||||
const usersList = await prisma.user.findMany({
|
||||
include: { profile: true }
|
||||
})
|
||||
|
||||
// 2. Получаем список всех канвасов
|
||||
const canvasesList = await prisma.canvas.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nodes: true,
|
||||
updatedAt: true,
|
||||
ownerId: true
|
||||
}
|
||||
})
|
||||
|
||||
// 3. Считаем общее кол-во пушей (если есть таблица)
|
||||
let pushCount = 0
|
||||
try {
|
||||
pushCount = await prisma.pushSubscription.count()
|
||||
} catch (e) {}
|
||||
|
||||
return {
|
||||
users: usersList,
|
||||
canvases: canvasesList,
|
||||
canvases: canvasesList.map(c => ({
|
||||
...c,
|
||||
nodeCount: (c.nodes as any[])?.length || 0
|
||||
})),
|
||||
stats: {
|
||||
users: usersList.length,
|
||||
canvases: canvasesList.length,
|
||||
nodes: canvasesList.reduce((sum, c) => sum + c.nodeCount, 0),
|
||||
pushes: pushSubscriptions.size
|
||||
nodes: canvasesList.reduce((sum, c) => sum + ((c.nodes as any[])?.length || 0), 0),
|
||||
pushes: pushCount
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -34,12 +34,15 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const fbUser: FirebaseUser = verifyRes.users[0]
|
||||
const email = fbUser.email
|
||||
const username = fbUser.displayName || email.split('@')[0]
|
||||
const username = email.split('@')[0]
|
||||
const userId = fbUser.localId
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: { username },
|
||||
update: {
|
||||
username
|
||||
// Мы не затираем профиль при каждом входе, но нам нужно его получить
|
||||
},
|
||||
create: {
|
||||
id: userId,
|
||||
email,
|
||||
@@ -48,10 +51,13 @@ export default defineEventHandler(async (event) => {
|
||||
role: 'user',
|
||||
profile: {
|
||||
create: {
|
||||
displayName: username,
|
||||
displayName: fbUser.displayName || username,
|
||||
avatar: fbUser.photoUrl || `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}`
|
||||
}
|
||||
}
|
||||
},
|
||||
include: {
|
||||
profile: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -59,6 +65,7 @@ export default defineEventHandler(async (event) => {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.profile?.displayName || user.username,
|
||||
role: user.role
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const adminUser = requireAuth(event)
|
||||
const groupId = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
const { email, role = 'member' } = body
|
||||
|
||||
if (!groupId || !email) {
|
||||
throw createError({ statusCode: 400, message: 'Group ID and Email are required' })
|
||||
}
|
||||
|
||||
// 1. Проверяем, является ли текущий пользователь админом группы или глобальным админом
|
||||
const group = await prisma.group.findUnique({
|
||||
where: { id: groupId }
|
||||
})
|
||||
|
||||
if (!group || (group.ownerId !== adminUser.id && adminUser.role !== 'admin')) {
|
||||
throw createError({ statusCode: 403, message: 'Only group owners or admins can add members' })
|
||||
}
|
||||
|
||||
// 2. Ищем пользователя по email
|
||||
const userToAdd = await prisma.user.findUnique({
|
||||
where: { email }
|
||||
})
|
||||
|
||||
if (!userToAdd) {
|
||||
throw createError({ statusCode: 404, message: 'User with this email not found in system' })
|
||||
}
|
||||
|
||||
// 3. Добавляем в группу
|
||||
try {
|
||||
const membership = await prisma.groupMember.upsert({
|
||||
where: {
|
||||
userId_groupId: {
|
||||
userId: userToAdd.id,
|
||||
groupId: groupId
|
||||
}
|
||||
},
|
||||
update: { role },
|
||||
create: {
|
||||
userId: userToAdd.id,
|
||||
groupId: groupId,
|
||||
role
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true, membership }
|
||||
} catch (e) {
|
||||
throw createError({ statusCode: 500, message: 'Failed to add member to group' })
|
||||
}
|
||||
})
|
||||
@@ -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 allGroups = await prisma.group.findMany({
|
||||
where: { isPublic: true },
|
||||
where: user.role === 'admin' ? {} : { isPublic: true },
|
||||
include: {
|
||||
members: { include: { user: { select: { id: true, username: true } } } },
|
||||
_count: { select: { members: true, canvases: true } }
|
||||
|
||||
@@ -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
|
||||
|
||||
if (!taskId || !canvasId || !newState) {
|
||||
throw createError({ statusCode: 400, message: 'taskId, canvasId and newState are required' })
|
||||
throw createError({ statusCode: 400, message: 'Missing parameters' })
|
||||
}
|
||||
|
||||
// 1. Найти холст
|
||||
// 1. Получаем канвас
|
||||
const canvas = await prisma.canvas.findUnique({
|
||||
where: { id: canvasId }
|
||||
})
|
||||
@@ -19,7 +19,21 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 404, message: 'Canvas not found' })
|
||||
}
|
||||
|
||||
// 2. Найти и обновить узел в JSON
|
||||
// 2. Проверяем права на редактирование
|
||||
const isOwner = canvas.ownerId === user.id
|
||||
let isMember = false
|
||||
if (canvas.groupId) {
|
||||
const membership = await prisma.groupMember.findFirst({
|
||||
where: { userId: user.id, groupId: canvas.groupId }
|
||||
})
|
||||
isMember = !!membership
|
||||
}
|
||||
|
||||
if (!isOwner && !isMember && user.role !== 'admin') {
|
||||
throw createError({ statusCode: 403, message: 'No permission to update this task' })
|
||||
}
|
||||
|
||||
// 3. Обновляем узел внутри массива nodes
|
||||
const nodes = (canvas.nodes as any[]) || []
|
||||
const nodeIdx = nodes.findIndex(n => n.id === taskId)
|
||||
|
||||
@@ -29,18 +43,15 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
// Обновляем состояние
|
||||
nodes[nodeIdx].state = newState
|
||||
if (nodes[nodeIdx].data) {
|
||||
nodes[nodeIdx].data.state = newState
|
||||
}
|
||||
|
||||
// Также обновляем дату изменения, если нужно
|
||||
nodes[nodeIdx].updatedAt = new Date().toISOString()
|
||||
|
||||
// 3. Сохранить изменения в БД
|
||||
// 4. Сохраняем обратно в БД
|
||||
await prisma.canvas.update({
|
||||
where: { id: canvasId },
|
||||
data: { nodes }
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Task ${taskId} moved to ${newState}`
|
||||
}
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
@@ -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
|
||||
const userActivities = Array.from(activities.values())
|
||||
.filter(activity => activity.userId === userId)
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.slice(0, 50) // Limit to 50 most recent
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ id: userId }, { username: userId }]
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 404, message: 'User not found' })
|
||||
}
|
||||
|
||||
// Get user's activities from database or memory
|
||||
const userActivities = await prisma.activity.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: 20
|
||||
})
|
||||
// Limit to 50 most recent
|
||||
|
||||
return { activities: userActivities }
|
||||
})
|
||||
|
||||
@@ -7,6 +7,12 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 400, message: 'User ID is required' })
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ id: userId }, { username: userId }]
|
||||
}
|
||||
})
|
||||
|
||||
// Get user's group memberships
|
||||
const userGroupIds = Array.from(userGroups.values())
|
||||
.filter(membership => membership.userId === userId)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const currentUser = requireAuth(event)
|
||||
const targetUserIdOrUsername = getRouterParam(event, 'id')
|
||||
|
||||
if (!targetUserIdOrUsername) {
|
||||
throw createError({ statusCode: 400, message: 'User ID is required' })
|
||||
}
|
||||
|
||||
// Находим целевого пользователя (по ID или username)
|
||||
const targetUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: targetUserIdOrUsername },
|
||||
{ username: targetUserIdOrUsername }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
if (!targetUser) {
|
||||
throw createError({ statusCode: 404, message: 'Target user not found' })
|
||||
}
|
||||
|
||||
try {
|
||||
const follow = await prisma.follows.findUnique({
|
||||
where: {
|
||||
followerId_followingId: {
|
||||
followerId: currentUser.id,
|
||||
followingId: targetUser.id
|
||||
}
|
||||
}
|
||||
})
|
||||
return { isFollowing: !!follow }
|
||||
} catch (e) {
|
||||
return { isFollowing: false }
|
||||
}
|
||||
})
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
const profile = await prisma.profile.findUnique({
|
||||
where: { userId },
|
||||
let profile = await prisma.profile.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userId: userId },
|
||||
{ user: { username: userId } }
|
||||
]
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
canvases: true,
|
||||
followers: true,
|
||||
following: true
|
||||
}
|
||||
select: { canvases: true, followers: true, following: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// If profile doesn't exist but user does, create it
|
||||
if (!profile) {
|
||||
// If profile doesn't exist but user might exist in Firebase,
|
||||
// we could potentially create it here or return 404
|
||||
throw createError({ statusCode: 404, message: 'Profile not found' })
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ id: userId }, { username: userId }]
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 404, message: 'User not found' })
|
||||
}
|
||||
|
||||
profile = await prisma.profile.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
displayName: user.username,
|
||||
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${user.id}`
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
_count: {
|
||||
select: { canvases: true, followers: true, following: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { H3Event, createError } from 'h3'
|
||||
|
||||
const JWT_SECRET = process.env.NUXT_JWT_SECRET || 'your-super-secret-jwt-key'
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
username: string
|
||||
displayName?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
export function generateToken(user: AuthUser): string {
|
||||
const config = useRuntimeConfig()
|
||||
const secret = config.jwtSecret || 'your-super-secret-jwt-key'
|
||||
|
||||
return jwt.sign(
|
||||
{ id: user.id, email: user.email, username: user.username, role: user.role || 'user' },
|
||||
JWT_SECRET,
|
||||
{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
role: user.role || 'user'
|
||||
},
|
||||
secret,
|
||||
{ expiresIn: '7d' }
|
||||
)
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): AuthUser {
|
||||
return jwt.verify(token, JWT_SECRET) as AuthUser
|
||||
const config = useRuntimeConfig()
|
||||
const secret = config.jwtSecret || 'your-super-secret-jwt-key'
|
||||
|
||||
return jwt.verify(token, secret) as AuthUser
|
||||
}
|
||||
|
||||
export function getAuthUser(event: H3Event): AuthUser | null {
|
||||
|
||||
+18
-38
@@ -8,52 +8,32 @@ export default defineNuxtConfig({
|
||||
'@vueuse/nuxt'
|
||||
],
|
||||
|
||||
experimental: {
|
||||
appManifest: false
|
||||
},
|
||||
|
||||
css: ['~/assets/css/main.css'],
|
||||
|
||||
tailwindcss: {
|
||||
cssPath: '~/assets/css/main.css'
|
||||
},
|
||||
|
||||
pwa: {
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,png,svg,ico}'],
|
||||
},
|
||||
client: {
|
||||
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'
|
||||
}
|
||||
app: {
|
||||
head: {
|
||||
title: 'XFlow',
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
||||
{ rel: 'manifest', href: '/manifest.json' }
|
||||
]
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
pwa: {
|
||||
injectRegister: 'script',
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: {
|
||||
enabled: false
|
||||
enabled: false,
|
||||
suppressWarnings: true
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+2
-1
@@ -1,11 +1,12 @@
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
// Призма 7 иногда требует явной загрузки .env для некоторых команд
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
url: process.env.DATABASE_URL || "postgresql://admin:secure_master_pass@localhost:5432/xflow_crm_db",
|
||||
},
|
||||
});
|
||||
|
||||
+40
-34
@@ -8,20 +8,19 @@ datasource db {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
username String @unique
|
||||
password String
|
||||
role String @default("user")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
profile Profile?
|
||||
canvases Canvas[]
|
||||
groupMembers GroupMember[]
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
username String @unique
|
||||
password String
|
||||
role String @default("user")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
activities Activity[]
|
||||
followers Follow[] @relation("following")
|
||||
following Follow[] @relation("follower")
|
||||
canvases Canvas[]
|
||||
following Follow[] @relation("follower")
|
||||
followers Follow[] @relation("following")
|
||||
groupMembers GroupMember[]
|
||||
profile Profile?
|
||||
}
|
||||
|
||||
model Profile {
|
||||
@@ -34,8 +33,7 @@ model Profile {
|
||||
followerCount Int @default(0)
|
||||
followingCount Int @default(0)
|
||||
joinedAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Canvas {
|
||||
@@ -48,22 +46,22 @@ model Canvas {
|
||||
edges Json @default("[]")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
group Group? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
||||
group Group? @relation(fields: [groupId], references: [id])
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Group {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String @default("")
|
||||
isPublic Boolean @default(true)
|
||||
description String @default("")
|
||||
isPublic Boolean @default(true)
|
||||
ownerId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
members GroupMember[]
|
||||
canvases Canvas[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
controlType String? @default("REMOTE")
|
||||
canvases Canvas[]
|
||||
members GroupMember[]
|
||||
Kiosk Kiosk[]
|
||||
}
|
||||
|
||||
model GroupMember {
|
||||
@@ -72,9 +70,8 @@ model GroupMember {
|
||||
groupId String
|
||||
role String @default("member")
|
||||
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])
|
||||
}
|
||||
@@ -87,8 +84,7 @@ model Activity {
|
||||
targetType String
|
||||
timestamp DateTime @default(now())
|
||||
metadata String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Follow {
|
||||
@@ -96,9 +92,19 @@ model Follow {
|
||||
followerId String
|
||||
followingId String
|
||||
since DateTime @default(now())
|
||||
|
||||
follower User @relation("follower", fields: [followerId], references: [id], onDelete: Cascade)
|
||||
following User @relation("following", fields: [followingId], references: [id], onDelete: Cascade)
|
||||
follower User @relation("follower", fields: [followerId], references: [id], onDelete: Cascade)
|
||||
following User @relation("following", fields: [followingId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@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