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

This commit is contained in:
2026-05-03 19:51:18 +05:00
parent 622ca04170
commit 0598e07823
36 changed files with 1387 additions and 467 deletions
+1 -172
View File
@@ -1,174 +1,3 @@
<template>
<div class="min-h-screen bg-slate-950 p-6">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-slate-100">XFlow Admin Panel</h1>
<div class="flex items-center gap-2 text-sm text-slate-400">
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
Online
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-blue-400">{{ stats.users }}</div>
<div class="text-sm text-slate-400 mt-1">Registered Users</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-emerald-400">{{ stats.canvases }}</div>
<div class="text-sm text-slate-400 mt-1">Canvases</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-yellow-400">{{ stats.nodes }}</div>
<div class="text-sm text-slate-400 mt-1">Total Nodes</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-purple-400">{{ stats.pushes }}</div>
<div class="text-sm text-slate-400 mt-1">Push Subscribers</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Users -->
<div class="lg:col-span-2 bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
<h2 class="text-lg font-semibold text-slate-200">Users</h2>
<button @click="refreshData" class="text-sm text-blue-400 hover:text-blue-300">Refresh</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-slate-800 text-slate-400">
<tr>
<th class="text-left p-3">Email</th>
<th class="text-left p-3">Username</th>
<th class="text-left p-3">Role</th>
<th class="text-left p-3">ID</th>
</tr>
</thead>
<tbody class="text-slate-300">
<tr v-for="user in users" :key="user.id" class="border-t border-slate-800">
<td class="p-3">{{ user.email }}</td>
<td class="p-3">{{ user.username }}</td>
<td class="p-3">
<span :class="roleBadge(user.role)">{{ user.role }}</span>
</td>
<td class="p-3 text-xs text-slate-500 font-mono">{{ user.id }}</td>
</tr>
<tr v-if="users.length === 0">
<td colspan="4" class="p-6 text-center text-slate-500">No users found</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Canvases -->
<div class="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
<div class="p-4 border-b border-slate-700">
<h2 class="text-lg font-semibold text-slate-200">Canvases</h2>
</div>
<div class="divide-y divide-slate-800">
<div v-for="canvas in canvases" :key="canvas.id" class="p-3">
<div class="flex items-center justify-between">
<NuxtLink :to="`/canvas/${canvas.id}`" class="text-blue-400 hover:text-blue-300 font-medium">
{{ canvas.name }}
</NuxtLink>
<span class="text-xs text-slate-500">{{ canvas.nodeCount }} nodes</span>
</div>
<div class="text-xs text-slate-500 mt-1">{{ formatDate(canvas.updatedAt) }}</div>
</div>
<div v-if="canvases.length === 0" class="p-6 text-center text-slate-500">
No canvases yet
</div>
</div>
</div>
</div>
<!-- Script Runner -->
<div class="mt-6 bg-slate-900 border border-slate-700 rounded-xl p-6">
<h2 class="text-lg font-semibold text-slate-200 mb-4">Script Runner</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
v-for="script in scripts"
:key="script.id"
class="bg-slate-800 rounded-lg p-4"
>
<div class="flex items-center justify-between mb-2">
<span class="font-medium text-slate-200">{{ script.name }}</span>
<span class="text-xs px-2 py-1 rounded-full" :class="statusClass(script.status)">{{ script.status }}</span>
</div>
<button
@click="runScript(script.id)"
class="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-sm transition"
:disabled="script.status === 'running'"
>
Run
</button>
</div>
</div>
</div>
</div>
</div>
<NuxtPage />
</template>
<script setup>
definePageMeta({
middleware: ['auth', 'admin']
})
const users = ref([])
const canvases = ref([])
const stats = reactive({ users: 0, canvases: 0, nodes: 0, pushes: 0 })
const scripts = ref([
{ id: 'sync-git', name: 'Sync Git Server', status: 'idle' },
{ id: 'cleanup-nodes', name: 'Cleanup Orphan Nodes', status: 'idle' },
{ id: 'export-all', name: 'Export All Canvases', status: 'idle' }
])
const refreshData = async () => {
try {
const data = await $fetch('/api/admin/stats')
users.value = data.users || []
canvases.value = data.canvases || []
stats.users = data.stats?.users || 0
stats.canvases = data.stats?.canvases || 0
stats.nodes = data.stats?.nodes || 0
stats.pushes = data.stats?.pushes || 0
} catch (e) {
console.error('Failed to load admin data', e)
}
}
const roleBadge = (role) => {
return role === 'admin'
? 'px-2 py-0.5 bg-red-900/50 text-red-300 rounded text-xs'
: 'px-2 py-0.5 bg-slate-700 text-slate-300 rounded text-xs'
}
const statusClass = (status) => {
if (status === 'running') return 'bg-yellow-900/50 text-yellow-300'
if (status === 'completed') return 'bg-emerald-900/50 text-emerald-300'
return 'bg-slate-700 text-slate-400'
}
const formatDate = (date) => {
if (!date) return 'N/A'
return new Date(date).toLocaleDateString('ru-RU')
}
const runScript = async (scriptId) => {
const script = scripts.value.find(s => s.id === scriptId)
if (!script || script.status === 'running') return
script.status = 'running'
try {
await $fetch(`/api/admin/scripts/${scriptId}`, { method: 'POST' })
script.status = 'completed'
} catch (e) {
script.status = 'failed'
}
setTimeout(() => { script.status = 'idle' }, 3000)
}
onMounted(refreshData)
</script>
+1 -148
View File
@@ -1,150 +1,3 @@
<template>
<div class="min-h-screen bg-slate-950 p-6">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold text-slate-100">Team Management</h1>
<p class="text-slate-400 mt-1">Create and manage groups, members and permissions</p>
</div>
<button
@click="showCreateModal = true"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition flex items-center gap-2"
>
<span class="text-xl">+</span> New Group
</button>
</div>
<!-- Groups Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="group in groups"
:key="group.id"
class="bg-slate-900 border border-slate-800 rounded-2xl p-6 hover:border-slate-700 transition group"
>
<div class="flex items-start justify-between mb-4">
<div class="w-12 h-12 bg-blue-900/30 rounded-xl flex items-center justify-center text-blue-400 text-xl font-bold">
{{ group.name.charAt(0) }}
</div>
<div class="flex gap-2">
<span v-if="!group.isPublic" class="px-2 py-1 bg-amber-900/30 text-amber-400 text-xs rounded-md border border-amber-900/50">Private</span>
<span class="px-2 py-1 bg-slate-800 text-slate-400 text-xs rounded-md">{{ group.memberCount }} members</span>
</div>
</div>
<h3 class="text-xl font-semibold text-slate-100 mb-2">{{ group.name }}</h3>
<p class="text-slate-400 text-sm mb-6 line-clamp-2">{{ group.description || 'No description provided' }}</p>
<div class="flex items-center justify-between pt-4 border-t border-slate-800">
<div class="flex -space-x-2">
<div v-for="i in Math.min(group.memberCount, 3)" :key="i" class="w-8 h-8 rounded-full border-2 border-slate-900 bg-slate-800 flex items-center justify-center text-[10px] text-slate-400 font-bold">
U{{ i }}
</div>
<div v-if="group.memberCount > 3" class="w-8 h-8 rounded-full border-2 border-slate-900 bg-slate-700 flex items-center justify-center text-[10px] text-slate-300 font-bold">
+{{ group.memberCount - 3 }}
</div>
</div>
<button @click="openGroupSettings(group)" class="text-blue-400 hover:text-blue-300 text-sm font-medium">Manage</button>
</div>
</div>
</div>
<!-- Create Group Modal -->
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm">
<div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md p-6 shadow-2xl">
<h2 class="text-2xl font-bold text-slate-100 mb-4">Create New Group</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-400 mb-1">Group Name</label>
<input
v-model="newGroup.name"
type="text"
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-slate-100 focus:ring-2 focus:ring-blue-500 outline-none"
placeholder="e.g. Development Team"
/>
</div>
<div>
<label class="block text-sm font-medium text-slate-400 mb-1">Description</label>
<textarea
v-model="newGroup.description"
rows="3"
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2 text-slate-100 focus:ring-2 focus:ring-blue-500 outline-none"
placeholder="What is this group for?"
></textarea>
</div>
<div class="flex items-center gap-3 py-2">
<input type="checkbox" v-model="newGroup.isPublic" id="isPublic" class="w-5 h-5 rounded border-slate-700 bg-slate-800 text-blue-600 focus:ring-blue-500">
<label for="isPublic" class="text-slate-300 select-none">Make group public</label>
</div>
</div>
<div class="flex gap-3 mt-8">
<button
@click="showCreateModal = false"
class="flex-1 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg font-medium transition"
>
Cancel
</button>
<button
@click="createGroup"
class="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition"
:disabled="loading"
>
{{ loading ? 'Creating...' : 'Create Group' }}
</button>
</div>
</div>
</div>
</div>
</div>
<NuxtPage />
</template>
<script setup>
definePageMeta({
middleware: ['auth', 'admin']
})
const groups = ref([])
const showCreateModal = ref(false)
const loading = ref(false)
const newGroup = reactive({
name: '',
description: '',
isPublic: true
})
const fetchGroups = async () => {
try {
const data = await $fetch('/api/groups')
groups.value = data.groups
} catch (e) {
console.error('Failed to fetch groups', e)
}
}
const createGroup = async () => {
if (!newGroup.name.trim()) return
loading.value = true
try {
await $fetch('/api/groups', {
method: 'POST',
body: newGroup
})
showCreateModal.value = false
newGroup.name = ''
newGroup.description = ''
await fetchGroups()
} catch (e) {
alert('Failed to create group')
} finally {
loading.value = false
}
}
const openGroupSettings = (group) => {
// To be implemented: detailed group management
navigateTo(`/admin/groups/${group.id}`)
}
onMounted(fetchGroups)
</script>
+81
View File
@@ -0,0 +1,81 @@
<template>
<div class="min-h-screen bg-slate-950 p-6">
<div class="max-w-4xl mx-auto" v-if="group">
<div class="flex items-center gap-4 mb-8 text-sm">
<NuxtLink to="/admin/groups" class="text-slate-500 hover:text-white transition">Группы</NuxtLink>
<span class="text-slate-700">/</span>
<span class="text-slate-300">{{ group.name }}</span>
</div>
<div class="bg-slate-900 border border-slate-800 rounded-3xl p-8 mb-8">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold text-white">{{ group.name }}</h1>
<button @click="deleteGroup" class="px-4 py-2 bg-red-900/20 text-red-400 hover:bg-red-900/40 rounded-lg text-sm transition">Удалить группу</button>
</div>
<p class="text-slate-400">{{ group.description || 'Нет описания' }}</p>
</div>
<!-- Members Section -->
<div class="bg-slate-900 border border-slate-800 rounded-3xl overflow-hidden">
<div class="p-6 border-b border-slate-800 flex items-center justify-between">
<h2 class="text-xl font-bold text-white">Участники ({{ members.length }})</h2>
<button @click="showAddMemberModal = true" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-bold transition">+ Добавить</button>
</div>
<div class="divide-y divide-slate-800">
<div v-for="m in members" :key="m.id" class="p-4 flex items-center justify-between hover:bg-slate-800/30 transition">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-slate-800 rounded-full flex items-center justify-center font-bold text-slate-400">
{{ m.user.username.charAt(0).toUpperCase() }}
</div>
<div>
<div class="text-slate-200 font-medium">{{ m.user.username }}</div>
<div class="text-xs text-slate-500">{{ m.role }}</div>
</div>
</div>
<button v-if="m.role !== 'admin'" @click="removeMember(m.userId)" class="text-slate-600 hover:text-red-400 transition text-xl">&times;</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
middleware: ['auth', 'admin']
})
const route = useRoute()
const auth = useAuth()
const group = ref(null)
const members = ref([])
const showAddMemberModal = ref(false)
const fetchGroup = async () => {
try {
const data = await $fetch(`/api/groups/${route.params.id}`, {
headers: { Authorization: `Bearer ${auth.token.value}` }
})
group.value = data.group
members.value = data.members || []
} catch (e) {
console.error('Failed to fetch group', e)
navigateTo('/admin/groups')
}
}
const deleteGroup = async () => {
if (!confirm('Вы уверены, что хотите удалить эту группу?')) return
try {
await $fetch(`/api/groups/${route.params.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${auth.token.value}` }
})
navigateTo('/admin/groups')
} catch (e) {
alert('Ошибка при удалении')
}
}
onMounted(fetchGroup)
</script>
+173
View File
@@ -0,0 +1,173 @@
<template>
<div class="min-h-screen bg-slate-950 p-6">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold text-slate-100">Управление Группами</h1>
<p class="text-slate-400 mt-1">Создание и управление командами, участниками и правами</p>
</div>
<button
@click="showCreateModal = true"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition flex items-center gap-2 shadow-lg shadow-blue-900/20"
>
<span class="text-xl">+</span> Новая Группа
</button>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
<!-- Groups Grid -->
<div v-else-if="groups.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="group in groups"
:key="group.id"
class="bg-slate-900 border border-slate-800 rounded-2xl p-6 hover:border-slate-700 transition group"
>
<div class="flex items-start justify-between mb-4">
<div class="w-12 h-12 bg-blue-900/30 rounded-xl flex items-center justify-center text-blue-400 text-xl font-bold">
{{ group.name.charAt(0) }}
</div>
<div class="flex gap-2">
<span v-if="!group.isPublic" class="px-2 py-1 bg-amber-900/30 text-amber-400 text-[10px] uppercase font-bold rounded-md border border-amber-900/50">Private</span>
<span class="px-2 py-1 bg-slate-800 text-slate-400 text-[10px] uppercase font-bold rounded-md">{{ group.memberCount }} members</span>
</div>
</div>
<h3 class="text-xl font-semibold text-slate-100 mb-2">{{ group.name }}</h3>
<p class="text-slate-400 text-sm mb-6 line-clamp-2 h-10">{{ group.description || 'Нет описания' }}</p>
<div class="flex items-center justify-between pt-4 border-t border-slate-800">
<div class="flex -space-x-2">
<div v-for="i in Math.min(group.memberCount, 3)" :key="i" class="w-8 h-8 rounded-full border-2 border-slate-900 bg-slate-800 flex items-center justify-center text-[10px] text-slate-400 font-bold">
U{{ i }}
</div>
<div v-if="group.memberCount > 3" class="w-8 h-8 rounded-full border-2 border-slate-900 bg-slate-700 flex items-center justify-center text-[10px] text-slate-300 font-bold">
+{{ group.memberCount - 3 }}
</div>
</div>
<button @click="openGroupSettings(group)" class="px-4 py-1.5 bg-slate-800 hover:bg-slate-700 text-blue-400 rounded-lg text-sm font-medium transition">Управлять</button>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="bg-slate-900 border border-slate-800 border-dashed rounded-3xl p-12 text-center">
<div class="text-6xl mb-4">👥</div>
<h3 class="text-xl font-bold text-slate-200">Групп пока нет</h3>
<p class="text-slate-500 mt-2 max-w-sm mx-auto">Создайте свою первую команду, чтобы работать над проектами совместно.</p>
<button @click="showCreateModal = true" class="mt-6 px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition">Создать группу</button>
</div>
<!-- Create Group Modal -->
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm" @click.self="showCreateModal = false">
<div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md p-6 shadow-2xl">
<h2 class="text-2xl font-bold text-slate-100 mb-4 text-center">Новая Группа</h2>
<div class="space-y-4">
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Название группы</label>
<input
v-model="newGroup.name"
type="text"
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-100 focus:border-blue-500 outline-none transition"
placeholder="Например: Отдел разработки"
/>
</div>
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Описание</label>
<textarea
v-model="newGroup.description"
rows="3"
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-100 focus:border-blue-500 outline-none transition resize-none"
placeholder="Чем занимается эта группа?"
></textarea>
</div>
<div class="flex items-center gap-3 py-2 bg-slate-800/50 p-3 rounded-lg border border-slate-700/50">
<input type="checkbox" v-model="newGroup.isPublic" id="isPublic" class="w-5 h-5 rounded border-slate-700 bg-slate-800 text-blue-600 focus:ring-blue-500 cursor-pointer">
<label for="isPublic" class="text-slate-300 select-none cursor-pointer text-sm">Сделать группу публичной (доступной всем)</label>
</div>
</div>
<div class="flex gap-3 mt-8">
<button
@click="showCreateModal = false"
class="flex-1 px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl font-bold transition"
>
Отмена
</button>
<button
@click="createGroup"
class="flex-1 px-4 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition shadow-lg shadow-blue-900/40"
:disabled="creating"
>
{{ creating ? 'Создание...' : 'Создать' }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
middleware: ['auth', 'admin']
})
const auth = useAuth()
const groups = ref([])
const showCreateModal = ref(false)
const loading = ref(false)
const creating = ref(false)
const newGroup = reactive({
name: '',
description: '',
isPublic: true
})
const fetchGroups = async () => {
loading.value = true
try {
const data = await $fetch('/api/groups', {
headers: { Authorization: `Bearer ${auth.token.value}` }
})
groups.value = data.groups || []
} catch (e) {
console.error('[Groups Page] Failed to fetch groups', e)
} finally {
loading.value = false
}
}
const createGroup = async () => {
if (!newGroup.name.trim()) return
creating.value = true
try {
await $fetch('/api/groups', {
method: 'POST',
body: newGroup,
headers: { Authorization: `Bearer ${auth.token.value}` }
})
showCreateModal.value = false
newGroup.name = ''
newGroup.description = ''
await fetchGroups()
} catch (e) {
alert('Ошибка при создании группы: ' + (e.data?.message || e.message))
} finally {
creating.value = false
}
}
const openGroupSettings = (group) => {
navigateTo(`/admin/groups/${group.id}`)
}
onMounted(() => {
fetchGroups()
})
</script>
+153
View File
@@ -0,0 +1,153 @@
<template>
<div class="min-h-screen bg-slate-950 p-6">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-slate-100">XFlow Admin Panel</h1>
<div class="flex items-center gap-2 text-sm text-slate-400">
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
Online
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-blue-400">{{ stats.users }}</div>
<div class="text-sm text-slate-400 mt-1">Registered Users</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-emerald-400">{{ stats.canvases }}</div>
<div class="text-sm text-slate-400 mt-1">Canvases</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-yellow-400">{{ stats.nodes }}</div>
<div class="text-sm text-slate-400 mt-1">Total Nodes</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-purple-400">{{ stats.pushes }}</div>
<div class="text-sm text-slate-400 mt-1">Push Subscribers</div>
</div>
</div>
<!-- Quick Actions -->
<div class="flex gap-4 mb-6">
<NuxtLink
to="/admin/groups"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition flex items-center gap-2"
>
👥 Manage Groups & Teams
</NuxtLink>
<button
@click="refreshData"
class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg font-medium transition"
>
🔄 Refresh Data
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Users -->
<div class="lg:col-span-2 bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
<h2 class="text-lg font-semibold text-slate-200">Users</h2>
<button @click="refreshData" class="text-sm text-blue-400 hover:text-blue-300">Refresh</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-slate-800 text-slate-400">
<tr>
<th class="text-left p-3">Email</th>
<th class="text-left p-3">Username</th>
<th class="text-left p-3">Role</th>
<th class="text-left p-3">ID</th>
</tr>
</thead>
<tbody class="text-slate-300">
<tr v-for="user in users" :key="user.id" class="border-t border-slate-800">
<td class="p-3">{{ user.email }}</td>
<td class="p-3">{{ user.username }}</td>
<td class="p-3">
<span :class="roleBadge(user.role)">{{ user.role }}</span>
</td>
<td class="p-3 text-xs text-slate-500 font-mono">{{ user.id }}</td>
</tr>
<tr v-if="users.length === 0">
<td colspan="4" class="p-6 text-center text-slate-500">No users found</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Canvases -->
<div class="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
<div class="p-4 border-b border-slate-700">
<h2 class="text-lg font-semibold text-slate-200">Canvases</h2>
</div>
<div class="divide-y divide-slate-800">
<div v-for="canvas in canvases" :key="canvas.id" class="p-3">
<div class="flex items-center justify-between">
<NuxtLink :to="`/canvas/${canvas.id}`" class="text-blue-400 hover:text-blue-300 font-medium">
{{ canvas.name }}
</NuxtLink>
<span class="text-xs text-slate-500">{{ canvas.nodeCount }} nodes</span>
</div>
<div class="text-xs text-slate-500 mt-1">{{ formatDate(canvas.updatedAt) }}</div>
</div>
<div v-if="canvases.length === 0" class="p-6 text-center text-slate-500">
No canvases yet
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
middleware: ['auth', 'admin']
})
const users = ref([])
const canvases = ref([])
const stats = reactive({ users: 0, canvases: 0, nodes: 0, pushes: 0 })
const auth = useAuth()
const refreshData = async () => {
if (!auth.token.value) return
try {
const data = await $fetch('/api/admin/stats', {
headers: { Authorization: `Bearer ${auth.token.value}` }
})
users.value = data.users || []
canvases.value = data.canvases || []
stats.users = data.stats?.users || 0
stats.canvases = data.stats?.canvases || 0
stats.nodes = data.stats?.nodes || 0
stats.pushes = data.stats?.pushes || 0
} catch (e) {
console.error('Failed to load admin data', e)
}
}
const roleBadge = (role) => {
return role === 'admin'
? 'px-2 py-0.5 bg-red-900/50 text-red-300 rounded text-xs'
: 'px-2 py-0.5 bg-slate-700 text-slate-300 rounded text-xs'
}
const formatDate = (date) => {
if (!date) return 'N/A'
return new Date(date).toLocaleDateString('ru-RU')
}
onMounted(async () => {
// Ждем инициализации пользователя если нужно
if (!auth.user.value) {
await auth.initAuth()
}
refreshData()
})
</script>
+29 -13
View File
@@ -15,41 +15,57 @@
<h1 class="text-2xl font-bold mb-6">Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<NuxtLink
to="/canvas"
to="/canvases"
class="p-6 bg-slate-800 border border-slate-700 rounded-lg hover:border-blue-500 transition group"
>
<div class="text-3xl mb-2">🗺️</div>
<h3 class="font-semibold group-hover:text-blue-400 transition">Canvas</h3>
<p class="text-sm text-slate-400">Визуальное управление задачами</p>
<h3 class="font-semibold group-hover:text-blue-400 transition">Проекты</h3>
<p class="text-sm text-slate-400">{{ homeStats.canvases }} канвасов доступно</p>
</NuxtLink>
<div class="p-6 bg-slate-800 border border-slate-700 rounded-lg">
<div class="text-3xl mb-2">✅</div>
<h3 class="font-semibold">Задачи</h3>
<p class="text-sm text-slate-400">{{ completedTasks }} / {{ totalTasks }} выполнено</p>
<p class="text-sm text-slate-400">{{ homeStats.tasks.completed }} / {{ homeStats.tasks.total }} выполнено</p>
</div>
<div class="p-6 bg-slate-800 border border-slate-700 rounded-lg">
<div class="text-3xl mb-2">🏆</div>
<h3 class="font-semibold">Ачивки</h3>
<p class="text-sm text-slate-400">{{ achievements }} разблокировано</p>
<p class="text-sm text-slate-400">{{ homeStats.achievements }} разблокировано</p>
</div>
</div>
</div>
<ClientOnly>
<InstallPWA />
</ClientOnly>
</div>
</template>
<script setup>
const { isAuthenticated } = useAuth()
const { stats: achievementStats, loadAchievements } = useAchievements()
const { isAuthenticated, token } = useAuth()
const homeStats = ref({
canvases: 0,
tasks: { total: 0, completed: 0 },
achievements: 0
})
const loadingStats = ref(true)
const completedTasks = computed(() => achievementStats.value.completedTasks)
const totalTasks = computed(() => 3) // mock total from canvas
const achievements = computed(() => achievementStats.value.total)
const fetchHomeStats = async () => {
if (!isAuthenticated.value) return
try {
const data = await $fetch('/api/user/stats', {
headers: { Authorization: `Bearer ${token.value}` }
})
homeStats.value = data.stats
} catch (e) {
console.error('Failed to load home stats', e)
} finally {
loadingStats.value = false
}
}
onMounted(() => {
if (isAuthenticated.value) {
loadAchievements()
}
fetchHomeStats()
})
</script>
+8 -1
View File
@@ -33,7 +33,7 @@ definePageMeta({
layout: 'default'
})
const { googleSignIn, initAuth } = useAuth()
const { googleSignIn, initAuth, isAuthenticated } = useAuth()
const loading = ref(false)
const signInWithGoogle = async () => {
@@ -49,6 +49,13 @@ const signInWithGoogle = async () => {
}
onMounted(() => {
if (isAuthenticated.value) {
navigateTo('/')
}
initAuth()
})
watch(isAuthenticated, (val) => {
if (val) navigateTo('/')
})
</script>
+5
View File
@@ -223,6 +223,11 @@ const loadProfile = async () => {
profile.value = profileData.profile
userCanvases.value = canvasesData.canvases
userActivities.value = activitiesData.activities
// Redirect to pretty URL if using UID but username exists
if (profile.value.user?.username && route.params.id === profile.value.userId) {
navigateTo(`/profile/${profile.value.user.username}`, { replace: true })
}
if (isOwnProfile.value) {
const [groupsData, allGroupsData] = await Promise.all([
+113 -1
View File
@@ -3,6 +3,66 @@
<h1 class="text-2xl font-bold mb-6">Настройки</h1>
<div class="space-y-6">
<!-- Profile Settings -->
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span>👤</span> Личный профиль
</h2>
<div class="space-y-4">
<div class="flex items-center gap-4 mb-4">
<img :src="profileForm.avatar" class="w-16 h-16 rounded-full bg-slate-700 object-cover border-2 border-blue-500/30" />
<div class="flex-1">
<label class="text-xs text-slate-500 uppercase font-bold">Ссылка на аватар</label>
<input v-model="profileForm.avatar" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 outline-none focus:border-blue-500" />
</div>
</div>
<div class="grid grid-cols-1 gap-4">
<div>
<label class="text-xs text-slate-500 uppercase font-bold">Имя (Display Name)</label>
<input v-model="profileForm.displayName" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 outline-none focus:border-blue-500" />
</div>
</div>
<div>
<label class="text-xs text-slate-500 uppercase font-bold">О себе (Bio)</label>
<textarea v-model="profileForm.bio" rows="3" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 outline-none focus:border-blue-500 resize-none"></textarea>
</div>
<button
@click="saveProfile"
:disabled="saving"
class="w-full py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition disabled:opacity-50"
>
{{ saving ? 'Сохранение...' : 'Сохранить изменения' }}
</button>
<p v-if="saveMessage" :class="saveMessage.type === 'error' ? 'text-red-400' : 'text-emerald-400'" class="text-center text-xs mt-2">
{{ saveMessage.text }}
</p>
</div>
</div>
<!-- My Groups -->
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span>👥</span> Мои Группы
</h2>
<div v-if="userGroups.length === 0" class="text-slate-500 text-sm py-4">
Вы не состоите ни в одной группе.
</div>
<div v-else class="space-y-3">
<div v-for="g in userGroups" :key="g.id" class="flex items-center justify-between p-3 bg-slate-900/50 rounded-lg border border-slate-700">
<div>
<div class="font-medium text-slate-200">{{ g.name }}</div>
<div class="text-xs text-slate-500">{{ g.description }}</div>
</div>
<span :class="g.membershipRole === 'admin' ? 'text-purple-400 bg-purple-900/20' : 'text-blue-400 bg-blue-900/20'" class="text-[10px] uppercase font-bold px-2 py-0.5 rounded border border-current/20">
{{ g.membershipRole }}
</span>
</div>
</div>
</div>
<!-- Push Notifications -->
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
@@ -72,10 +132,59 @@ definePageMeta({
middleware: 'auth'
})
const { user } = useAuth()
const { isSupported, subscription, requestPermission, subscribe: pushSubscribe } = usePush()
const { achievements, loadAchievements } = useAchievements()
const permissionGranted = ref(false)
const saving = ref(false)
const saveMessage = ref(null)
const userGroups = ref([])
const profileForm = reactive({
displayName: '',
username: '',
bio: '',
avatar: ''
})
const loadProfile = async () => {
try {
const data = await $fetch(`/api/users/${user.value.id}/profile`, {
headers: { Authorization: `Bearer ${useAuth().token.value}` }
})
const groupsData = await $fetch('/api/user/groups', {
headers: { Authorization: `Bearer ${useAuth().token.value}` }
})
userGroups.value = groupsData.groups || []
profileForm.displayName = data.profile.displayName || ''
profileForm.username = data.profile.user.username || ''
profileForm.bio = data.profile.bio || ''
profileForm.avatar = data.profile.avatar || ''
} catch (e) {
console.error('Failed to load profile', e)
}
}
const saveProfile = async () => {
saving.value = true
saveMessage.value = null
try {
await $fetch('/api/user/profile', {
method: 'PUT',
body: profileForm,
headers: { Authorization: `Bearer ${useAuth().token.value}` }
})
saveMessage.value = { type: 'success', text: 'Профиль обновлен успешно!' }
// Update local user data if needed
user.value.username = profileForm.username
} catch (e) {
saveMessage.value = { type: 'error', text: e.data?.message || 'Ошибка при сохранении' }
} finally {
saving.value = false
}
}
const enablePush = async () => {
const granted = await requestPermission()
@@ -91,6 +200,9 @@ const subscribe = async () => {
onMounted(async () => {
permissionGranted.value = Notification.permission === 'granted'
await loadAchievements()
await Promise.all([
loadAchievements(),
loadProfile()
])
})
</script>
+94 -1
View File
@@ -5,6 +5,56 @@
<h1 class="text-3xl font-bold text-white flex items-center gap-3">
<span class="text-blue-500">📋</span> Мои Задачи
</h1>
<div class="flex items-center gap-3">
<button @click="showCreateModal = true" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium shadow-lg shadow-blue-900/20 transition flex items-center gap-2">
<span>➕</span> Новая задача
</button>
</div>
</div>
<!-- Create Task Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" @click.self="showCreateModal = false">
<div class="bg-slate-800 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
<h2 class="text-xl font-bold text-white mb-6">Создать новую задачу</h2>
<form @submit.prevent="createTask" class="space-y-4">
<div>
<label class="text-xs text-slate-500 uppercase font-bold">Название задачи</label>
<input v-model="newTask.title" required class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500" placeholder="Что нужно сделать?" />
</div>
<div>
<label class="text-xs text-slate-500 uppercase font-bold">Проект (Канвас)</label>
<select v-model="newTask.canvasId" required class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500">
<option value="" disabled>Выберите проект...</option>
<option v-for="c in availableCanvases" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-xs text-slate-500 uppercase font-bold">Приоритет</label>
<select v-model="newTask.priority" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500">
<option value="low">Низкий</option>
<option value="medium">Средний</option>
<option value="high">Высокий</option>
</select>
</div>
<div>
<label class="text-xs text-slate-500 uppercase font-bold">Статус</label>
<select v-model="newTask.state" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500">
<option v-for="col in columns" :key="col.id" :value="col.id">{{ col.title }}</option>
</select>
</div>
</div>
<div class="flex gap-3 pt-4">
<button type="submit" :disabled="creating" class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition disabled:opacity-50">
{{ creating ? 'Создание...' : 'Создать задачу' }}
</button>
<button type="button" @click="showCreateModal = false" class="px-6 py-3 bg-slate-700 hover:bg-slate-600 text-white rounded-xl font-bold transition">Отмена</button>
</div>
</form>
</div>
</div>
<div v-if="loading" class="flex items-center justify-center h-64">
@@ -92,6 +142,16 @@ const columnsData = reactive({
completed: []
})
const showCreateModal = ref(false)
const creating = ref(false)
const availableCanvases = ref([])
const newTask = reactive({
title: '',
canvasId: '',
priority: 'medium',
state: 'todo'
})
const columns = [
{ id: 'todo', title: 'К выполнению', icon: '📋', color: 'text-slate-400' },
{ id: 'in_progress', title: 'В работе', icon: '⏳', color: 'text-blue-400' },
@@ -125,6 +185,36 @@ const fetchTasks = async () => {
}
}
const fetchCanvases = async () => {
try {
const res = await $fetch('/api/canvas')
availableCanvases.value = res.canvases || []
if (availableCanvases.value.length > 0) {
newTask.canvasId = availableCanvases.value[0].id
}
} catch (e) {
console.error('Failed to load canvases', e)
}
}
const createTask = async () => {
creating.value = true
try {
await $fetch('/api/tasks/create', {
method: 'POST',
body: newTask,
headers: { Authorization: `Bearer ${auth.token.value}` }
})
showCreateModal.value = false
newTask.title = ''
await fetchTasks() // Refresh list
} catch (e) {
alert('Ошибка при создании задачи: ' + (e.data?.message || e.message))
} finally {
creating.value = false
}
}
const onTaskMove = async (evt, newStatus) => {
if (evt.added) {
const task = evt.added.element
@@ -197,7 +287,10 @@ onMounted(async () => {
if (!auth.user.value) {
await auth.init()
}
await fetchTasks()
await Promise.all([
fetchTasks(),
fetchCanvases()
])
})
</script>