feat: prisma schema, groups API, deploy workflow

This commit is contained in:
2026-04-23 21:41:11 +05:00
parent 4c50036172
commit 65cf8ca01f
285 changed files with 24602 additions and 25890 deletions
+174
View File
@@ -0,0 +1,174 @@
<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>
</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>
+16
View File
@@ -0,0 +1,16 @@
<template>
<div class="h-screen w-full">
<ClientOnly>
<FlowEditor :canvas-id="canvasId" />
</ClientOnly>
</div>
</template>
<script setup>
definePageMeta({
middleware: 'auth'
})
const route = useRoute()
const canvasId = computed(() => route.params.id || 'demo')
</script>
+95
View File
@@ -0,0 +1,95 @@
<template>
<div class="min-h-screen bg-slate-950">
<div class="max-w-6xl mx-auto p-6">
<h1 class="text-3xl font-bold text-slate-100 mb-6">Канвасы</h1>
<!-- Canvas Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="canvas in canvases" :key="canvas.id"
class="bg-slate-900 border border-slate-700 rounded-xl p-6 hover:border-blue-500 transition-colors">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-slate-100">{{ canvas.name }}</h3>
<p class="text-sm text-slate-400">{{ canvas.description }}</p>
</div>
<div class="text-xs text-slate-500">
{{ new Date(canvas.updatedAt).toLocaleDateString('ru-RU') }}
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="px-2 py-1 bg-slate-800 text-slate-300 rounded text-xs">
{{ canvas.isPublic ? 'Публичный' : 'Личный' }}
</span>
<span class="px-2 py-1 bg-slate-800 text-slate-300 rounded text-xs">
{{ canvas.nodes?.length || 0 }} узлов
</span>
</div>
<NuxtLink :to="`/canvas/${canvas.id}`"
class="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm transition-colors">
Открыть
</NuxtLink>
</div>
</div>
<!-- Create New Canvas -->
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 border-dashed hover:border-blue-500 transition-colors">
<div class="flex flex-col items-center justify-center h-full min-h-[200px]">
<div class="text-4xl mb-4">+</div>
<h3 class="text-lg font-semibold text-slate-100 mb-2">Создать канвас</h3>
<p class="text-sm text-slate-400 text-center mb-4">
Создайте новую ментальную карту
</p>
<button @click="createNewCanvas"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm transition-colors">
Создать
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useAuth } from '~/composables/useAuth'
const auth = useAuth()
const router = useRouter()
// Mock canvases data - replace with actual API call
const canvases = ref([
{
id: 'xflow-project',
name: 'Обзор Проекта XFlow',
description: 'Основная ментальная карта проекта',
isPublic: true,
updatedAt: new Date().toISOString(),
nodes: 11
},
{
id: 'demo',
name: 'Демо канвас',
description: 'Пример ментальной карты',
isPublic: true,
updatedAt: new Date().toISOString(),
nodes: 5
}
])
const createNewCanvas = () => {
// Redirect to flow editor with new canvas
router.push('/canvas/new-canvas')
}
// Redirect to specific canvas if ID is provided
onMounted(() => {
if (auth.user) {
// Load user's canvases
console.log('Loading canvases for user:', auth.user.email)
}
})
</script>
+255
View File
@@ -0,0 +1,255 @@
<template>
<div class="min-h-screen bg-slate-950">
<div class="max-w-7xl mx-auto p-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-slate-100">Мои Карты</h1>
<button @click="showCreateModal = true" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition">
+ Новая Карта
</button>
</div>
<!-- Tabs -->
<div class="flex gap-1 mb-6 bg-slate-900 rounded-lg p-1 w-fit">
<button @click="activeTab = 'personal'" :class="tabClass('personal')" class="px-4 py-2 rounded text-sm font-medium transition">
Личные
</button>
<button @click="activeTab = 'shared'" :class="tabClass('shared')" class="px-4 py-2 rounded text-sm font-medium transition">
Общие
</button>
<button @click="activeTab = 'public'" :class="tabClass('public')" class="px-4 py-2 rounded text-sm font-medium transition">
Публичные
</button>
</div>
<!-- Canvas Grid -->
<div v-if="filteredCanvases.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="canvas in filteredCanvases" :key="canvas.id"
class="bg-slate-900 border border-slate-700 rounded-xl p-4 hover:border-slate-600 transition cursor-pointer group"
@click="openCanvas(canvas.id)">
<div class="flex items-start justify-between mb-3">
<div>
<h3 class="font-semibold text-slate-200 group-hover:text-blue-400 transition">{{ canvas.name }}</h3>
<p class="text-xs text-slate-500 mt-1">{{ canvas.nodeCount }} nodes • {{ formatDate(canvas.updatedAt) }}</p>
</div>
<div class="flex items-center gap-1">
<span v-if="canvas.isPublic" class="text-xs px-2 py-1 bg-emerald-900/50 text-emerald-300 rounded">Публичная</span>
<span v-else-if="canvas.groupId" class="text-xs px-2 py-1 bg-blue-900/50 text-blue-300 rounded">Общая</span>
<span v-else class="text-xs px-2 py-1 bg-slate-700 text-slate-400 rounded">Приватная</span>
</div>
</div>
<div class="text-xs text-slate-400">
<span v-if="canvas.ownerId === currentUserId">Ваша карта</span>
<span v-else>Поделился {{ getOwnerName(canvas.ownerId) }}</span>
<span v-if="canvas.groupId" class="ml-2">• {{ getGroupName(canvas.groupId) }}</span>
</div>
<div class="mt-3 pt-3 border-t border-slate-800 flex justify-between items-center">
<div class="flex gap-2">
<button @click.stop="duplicateCanvas(canvas.id)" class="text-xs text-slate-400 hover:text-blue-400 transition">
Дублировать
</button>
<button @click.stop="exportCanvas(canvas.id)" class="text-xs text-slate-400 hover:text-emerald-400 transition">
Экспорт
</button>
</div>
<button v-if="canDeleteCanvas(canvas)" @click.stop="deleteCanvas(canvas.id)"
class="text-xs text-slate-400 hover:text-red-400 transition">
Удалить
</button>
</div>
</div>
</div>
<div v-else class="text-center py-12">
<div class="text-slate-500 mb-4">Карты не найдены</div>
<button @click="showCreateModal = true" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition">
Создать первую карту
</button>
</div>
</div>
<!-- Create Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" @click.self="showCreateModal = false">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-md">
<h2 class="text-lg font-semibold text-slate-200 mb-4">Создать Новую Карту</h2>
<form @submit.prevent="createCanvas" class="space-y-4">
<div>
<label class="text-sm text-slate-400 block mb-1">Название Карты</label>
<input v-model="newCanvas.name" required class="w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white" placeholder="Моя Awesome Карта" />
</div>
<div>
<label class="text-sm text-slate-400 block mb-1">Видимость</label>
<select v-model="newCanvas.visibility" class="w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white">
<option value="private">Приватная (только вы)</option>
<option value="public">Публичная (любой может просматривать)</option>
</select>
</div>
<div v-if="userGroups.length > 0">
<label class="text-sm text-slate-400 block mb-1">Поделиться с Группой (опционально)</label>
<select v-model="newCanvas.groupId" class="w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white">
<option value="">Без группы</option>
<option v-for="group in userGroups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</div>
<div class="flex gap-3 pt-2">
<button type="submit" class="flex-1 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition">
Создать
</button>
<button type="button" @click="showCreateModal = false" class="flex-1 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg font-medium transition">
Отмена
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
middleware: 'auth'
})
const auth = useAuth()
const user = computed(() => auth.user)
const authFetch = (url, opts = {}) => $fetch(url, {
...opts,
headers: { Authorization: `Bearer ${auth.token.value}`, ...opts.headers }
})
const canvases = ref([])
const userGroups = ref([])
const showCreateModal = ref(false)
const activeTab = ref('personal')
const newCanvas = reactive({
name: '',
visibility: 'private',
groupId: ''
})
const currentUserId = computed(() => auth.user.value?.id || '')
// Load data
const loadData = async () => {
try {
const [canvasesRes, groupsRes] = await Promise.all([
authFetch('/api/canvas'),
authFetch('/api/user/groups')
])
canvases.value = canvasesRes.canvases || []
userGroups.value = groupsRes.groups || []
} catch (error) {
console.error('Failed to load data:', error)
}
}
// Filter canvases based on active tab
const filteredCanvases = computed(() => {
const userId = currentUserId.value
switch (activeTab.value) {
case 'personal':
return canvases.value.filter(c => !c.groupId)
case 'shared':
return canvases.value.filter(c =>
c.groupId && userGroups.value.some(g => g.id === c.groupId) ||
c.ownerId !== userId && c.isPublic
)
case 'public':
return canvases.value.filter(c => c.isPublic)
default:
return canvases.value
}
})
// Tab styling
const tabClass = (tab) => {
return activeTab.value === tab
? 'bg-slate-800 text-slate-200'
: 'text-slate-400 hover:text-slate-300'
}
// Canvas actions
const openCanvas = (id) => {
navigateTo(`/canvas/${id}`)
}
const createCanvas = async () => {
try {
const payload = {
name: newCanvas.name,
isPublic: newCanvas.visibility === 'public',
groupId: newCanvas.groupId || undefined
}
const result = await authFetch('/api/canvas', { method: 'POST', body: payload })
showCreateModal.value = false
newCanvas.name = ''
newCanvas.visibility = 'private'
newCanvas.groupId = ''
await loadData()
navigateTo(`/canvas/${result.canvas.id}`)
} catch (error) {
console.error('Failed to create canvas:', error)
}
}
const duplicateCanvas = async (id) => {
try {
const result = await authFetch(`/api/canvas/${id}/duplicate`, { method: 'POST' })
await loadData()
} catch (error) {
console.error('Failed to duplicate canvas:', error)
}
}
const exportCanvas = async (id) => {
try {
const canvas = await authFetch(`/api/canvas/${id}`)
const blob = new Blob([JSON.stringify(canvas, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${canvas.name || 'canvas'}.canvas`
a.click()
URL.revokeObjectURL(url)
} catch (error) {
console.error('Failed to export canvas:', error)
}
}
const canDeleteCanvas = (canvas) => {
return canvas.ownerId === currentUserId.value
}
const deleteCanvas = async (id) => {
if (!confirm('Вы уверены, что хотите удалить эту карту?')) return
try {
await authFetch(`/api/canvas/${id}`, { method: 'DELETE' })
await loadData()
} catch (error) {
console.error('Failed to delete canvas:', error)
}
}
// Helper functions
const formatDate = (date) => {
return new Date(date).toLocaleDateString('ru-RU', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const getOwnerName = (ownerId) => {
return ownerId === currentUserId.value ? 'вы' : `Пользователь ${ownerId}`
}
const getGroupName = (groupId) => {
const group = userGroups.value.find(g => g.id === groupId)
return group?.name || 'Unknown Group'
}
onMounted(loadData)
</script>
+13
View File
@@ -0,0 +1,13 @@
<template>
<div class="h-screen w-full">
<ClientOnly>
<FlowEditor canvas-id="demo" />
</ClientOnly>
</div>
</template>
<script setup>
definePageMeta({
middleware: 'auth'
})
</script>
+55
View File
@@ -0,0 +1,55 @@
<template>
<div class="min-h-[calc(100vh-3.5rem)] flex flex-col items-center justify-center p-4">
<div v-if="!isAuthenticated" class="text-center max-w-md">
<h1 class="text-4xl font-bold text-blue-400 mb-4">XFlow</h1>
<p class="text-slate-400 mb-8">Obsidian Canvas PWA. Управляй задачами визуально.</p>
<NuxtLink
to="/login"
class="inline-block px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition"
>
Войти
</NuxtLink>
</div>
<div v-else class="w-full max-w-6xl">
<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"
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>
</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>
</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>
</div>
</div>
</div>
</div>
</template>
<script setup>
const { isAuthenticated } = useAuth()
const { stats: achievementStats, loadAchievements } = useAchievements()
const completedTasks = computed(() => achievementStats.value.completedTasks)
const totalTasks = computed(() => 3) // mock total from canvas
const achievements = computed(() => achievementStats.value.total)
onMounted(() => {
if (isAuthenticated.value) {
loadAchievements()
}
})
</script>
+54
View File
@@ -0,0 +1,54 @@
<template>
<div class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-sm bg-slate-800 border border-slate-700 rounded-xl p-6 shadow-xl text-center">
<div class="mb-6">
<div class="text-5xl mb-4">🚀</div>
<h1 class="text-2xl font-bold mb-2">XFlow</h1>
<p class="text-slate-400 text-sm">Визуальный менеджер задач</p>
</div>
<button
@click="signInWithGoogle"
:disabled="loading"
class="w-full py-3 bg-white hover:bg-gray-100 text-slate-900 rounded-lg font-medium transition flex items-center justify-center gap-3 disabled:opacity-50"
>
<svg class="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
{{ loading ? 'Вход...' : 'Войти через Google' }}
</button>
<p class="mt-4 text-xs text-slate-500">
Авторизация через Firebase Google Sign-In
</p>
</div>
</div>
</template>
<script setup>
definePageMeta({
layout: 'default'
})
const { googleSignIn, initAuth } = useAuth()
const loading = ref(false)
const signInWithGoogle = async () => {
try {
loading.value = true
await googleSignIn()
} catch (error) {
console.error('Google sign-in failed:', error)
alert('Ошибка входа через Google: ' + (error.message || 'Неизвестная ошибка'))
} finally {
loading.value = false
}
}
onMounted(() => {
initAuth()
})
</script>
+334
View File
@@ -0,0 +1,334 @@
<template>
<div class="min-h-screen bg-slate-950">
<div class="max-w-4xl mx-auto p-6">
<!-- Profile Header -->
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 mb-6">
<div class="flex items-start gap-4">
<img :src="profile.avatar" :alt="profile.displayName"
class="w-20 h-20 rounded-full bg-slate-800" />
<div class="flex-1">
<h1 class="text-2xl font-bold text-slate-100">{{ profile.displayName }}</h1>
<p class="text-slate-400 mt-1">{{ profile.bio }}</p>
<div class="flex items-center gap-4 mt-3 text-sm text-slate-500">
<span>Joined {{ formatDate(profile.joinedAt) }}</span>
<span>•</span>
<span>{{ profile.canvasCount }} canvases</span>
<span>•</span>
<span>{{ profile.followerCount }} followers</span>
<span>•</span>
<span>{{ profile.followingCount }} following</span>
</div>
</div>
<button v-if="!isOwnProfile" @click="toggleFollow"
:class="followButtonClass"
class="px-4 py-2 rounded-lg font-medium transition">
{{ isFollowing ? 'Following' : 'Follow' }}
</button>
</div>
</div>
<!-- Tabs -->
<div class="flex gap-1 mb-6 bg-slate-900 rounded-lg p-1 w-fit">
<button @click="activeTab = 'canvases'" :class="tabClass('canvases')" class="px-4 py-2 rounded text-sm font-medium transition">
Canvases
</button>
<button @click="activeTab = 'activity'" :class="tabClass('activity')" class="px-4 py-2 rounded text-sm font-medium transition">
Activity
</button>
<button v-if="isOwnProfile" @click="activeTab = 'groups'" :class="tabClass('groups')" class="px-4 py-2 rounded text-sm font-medium transition">
Groups
</button>
</div>
<!-- Canvases Tab -->
<div v-if="activeTab === 'canvases'" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="canvas in userCanvases" :key="canvas.id"
class="bg-slate-900 border border-slate-700 rounded-xl p-4 hover:border-slate-600 transition cursor-pointer"
@click="openCanvas(canvas.id)">
<div class="flex items-start justify-between mb-3">
<div>
<h3 class="font-semibold text-slate-200 hover:text-blue-400 transition">{{ canvas.name }}</h3>
<p class="text-xs text-slate-500 mt-1">{{ canvas.nodeCount }} nodes • {{ formatDate(canvas.updatedAt) }}</p>
</div>
<div class="flex items-center gap-1">
<span v-if="canvas.isPublic" class="text-xs px-2 py-1 bg-emerald-900/50 text-emerald-300 rounded">Public</span>
<span v-else-if="canvas.groupId" class="text-xs px-2 py-1 bg-blue-900/50 text-blue-300 rounded">Shared</span>
<span v-else class="text-xs px-2 py-1 bg-slate-700 text-slate-400 rounded">Private</span>
</div>
</div>
<div class="text-xs text-slate-400">
{{ formatDate(canvas.updatedAt) }}
</div>
</div>
</div>
<!-- Activity Tab -->
<div v-if="activeTab === 'activity'" class="space-y-3">
<div v-for="activity in userActivities" :key="activity.id"
class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="flex items-center gap-3">
<div class="w-2 h-2 bg-blue-500 rounded-full"></div>
<div class="flex-1">
<p class="text-sm text-slate-300">
<span class="font-medium">{{ formatActivity(activity.action) }}</span>
<span v-if="activity.targetType === 'canvas'" class="text-blue-400"> canvas</span>
<span v-if="activity.targetType === 'group'" class="text-emerald-400"> group</span>
<span v-if="activity.targetType === 'user'" class="text-purple-400"> user</span>
</p>
<p class="text-xs text-slate-500 mt-1">{{ formatDate(activity.timestamp) }}</p>
</div>
</div>
</div>
</div>
<!-- Groups Tab -->
<div v-if="activeTab === 'groups' && isOwnProfile" class="space-y-6">
<!-- My Groups -->
<div>
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold text-slate-400 uppercase tracking-wider">Мои группы</h2>
<button @click="showCreateGroup = true" class="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs font-medium">
+ Создать группу
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div v-for="group in userGroups" :key="group.id"
class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="flex items-start justify-between mb-2">
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-slate-200 truncate">{{ group.name }}</h3>
<p class="text-xs text-slate-500 mt-0.5">{{ group.description }}</p>
</div>
<span :class="group.membershipRole === 'admin' ? 'bg-purple-900/50 text-purple-300' : 'bg-blue-900/50 text-blue-300'"
class="text-xs px-2 py-1 rounded ml-2 shrink-0">{{ group.membershipRole }}</span>
</div>
<div class="flex items-center justify-between mt-3">
<span class="text-xs text-slate-500">{{ group.isPublic ? '🌐 Публичная' : '🔒 Приватная' }}</span>
<button v-if="group.ownerId !== currentUserId" @click="leaveGroup(group.id)"
class="text-xs text-red-400 hover:text-red-300 transition">Выйти</button>
<button v-else @click="deleteGroup(group.id)"
class="text-xs text-red-400 hover:text-red-300 transition">Удалить</button>
</div>
</div>
<div v-if="userGroups.length === 0" class="text-slate-500 text-sm col-span-2 py-4 text-center">
Вы не состоите ни в одной группе
</div>
</div>
</div>
<!-- All Public Groups -->
<div>
<h2 class="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-3">Все публичные группы</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div v-for="group in availableGroups" :key="group.id"
class="bg-slate-900 border border-slate-700 rounded-xl p-4 flex items-center justify-between">
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-slate-200 truncate">{{ group.name }}</h3>
<p class="text-xs text-slate-500 mt-0.5">{{ group.description }}</p>
</div>
<button v-if="!isMember(group.id)" @click="joinGroup(group.id)"
class="ml-3 px-3 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs shrink-0">Вступить</button>
<span v-else class="ml-3 text-xs text-slate-500 shrink-0">✓ Участник</span>
</div>
</div>
</div>
</div>
<!-- Create Group Modal -->
<div v-if="showCreateGroup" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" @click.self="showCreateGroup = false">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-md">
<h2 class="text-lg font-semibold text-slate-200 mb-4">Создать группу</h2>
<form @submit.prevent="createGroup" class="space-y-4">
<div>
<label class="text-sm text-slate-400 block mb-1">Название *</label>
<input v-model="newGroup.name" required class="w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white" placeholder="Название группы" />
</div>
<div>
<label class="text-sm text-slate-400 block mb-1">Описание</label>
<input v-model="newGroup.description" class="w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white" placeholder="Краткое описание" />
</div>
<div class="flex items-center gap-2">
<input type="checkbox" v-model="newGroup.isPublic" id="isPublic" class="rounded" />
<label for="isPublic" class="text-sm text-slate-300">Публичная (другие могут вступить)</label>
</div>
<div class="flex gap-3 pt-2">
<button type="submit" class="flex-1 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium">Создать</button>
<button type="button" @click="showCreateGroup = false" class="flex-1 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg">Отмена</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
middleware: 'auth'
})
const auth = useAuth()
const route = useRoute()
const authFetch = (url, opts = {}) => $fetch(url, {
...opts,
headers: { Authorization: `Bearer ${auth.token.value}`, ...opts.headers }
})
const profileId = computed(() => route.params.id || auth.user.value?.id)
const currentUserId = computed(() => auth.user.value?.id || '')
const isOwnProfile = computed(() => profileId.value === currentUserId.value)
const profile = ref({
userId: profileId.value,
displayName: 'Loading...',
bio: '',
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${profileId.value}`,
joinedAt: new Date().toISOString(),
canvasCount: 0,
followerCount: 0,
followingCount: 0
})
const userCanvases = ref([])
const userActivities = ref([])
const userGroups = ref([])
const allGroups = ref([])
const isFollowing = ref(false)
const activeTab = ref('canvases')
const showCreateGroup = ref(false)
const newGroup = reactive({ name: '', description: '', isPublic: true })
const availableGroups = computed(() => allGroups.value.filter(g => g.isPublic))
const isMember = (groupId) => userGroups.value.some(g => g.id === groupId)
// Load profile data
const loadProfile = async () => {
try {
const [profileData, canvasesData, activitiesData] = await Promise.all([
authFetch(`/api/users/${profileId.value}/profile`),
authFetch(`/api/users/${profileId.value}/canvases`),
authFetch(`/api/users/${profileId.value}/activity`)
])
profile.value = profileData.profile
userCanvases.value = canvasesData.canvases
userActivities.value = activitiesData.activities
if (isOwnProfile.value) {
const [groupsData, allGroupsData] = await Promise.all([
authFetch('/api/user/groups'),
authFetch('/api/groups')
])
userGroups.value = groupsData.groups
allGroups.value = allGroupsData.groups
}
if (!isOwnProfile.value) {
try {
const followData = await authFetch(`/api/users/${profileId.value}/follow-status`)
isFollowing.value = followData.isFollowing
} catch {}
}
} catch (error) {
console.error('Failed to load profile:', error)
}
}
// Actions
const openCanvas = (id) => {
navigateTo(`/canvas/${id}`)
}
const toggleFollow = async () => {
try {
const method = isFollowing.value ? 'DELETE' : 'POST'
await authFetch(`/api/users/${profileId.value}/follow`, { method })
isFollowing.value = !isFollowing.value
profile.value.followerCount += isFollowing.value ? 1 : -1
} catch (error) {
console.error('Failed to toggle follow:', error)
}
}
const createGroup = async () => {
try {
const res = await authFetch('/api/groups', { method: 'POST', body: { ...newGroup } })
userGroups.value.push({ ...res.group, membershipRole: 'admin' })
allGroups.value.push(res.group)
showCreateGroup.value = false
newGroup.name = ''
newGroup.description = ''
newGroup.isPublic = true
} catch (e) {
console.error('Failed to create group:', e)
}
}
const joinGroup = async (groupId) => {
try {
await authFetch(`/api/groups/${groupId}/join`, { method: 'POST' })
const group = allGroups.value.find(g => g.id === groupId)
if (group) userGroups.value.push({ ...group, membershipRole: 'member' })
} catch (e) {
console.error('Failed to join group:', e)
}
}
const leaveGroup = async (groupId) => {
if (!confirm('Выйти из группы?')) return
try {
await authFetch(`/api/groups/${groupId}/leave`, { method: 'POST' })
userGroups.value = userGroups.value.filter(g => g.id !== groupId)
} catch (e) {
console.error('Failed to leave group:', e)
}
}
const deleteGroup = async (groupId) => {
if (!confirm('Удалить группу? Это действие необратимо.')) return
try {
await authFetch(`/api/groups/${groupId}`, { method: 'DELETE' })
userGroups.value = userGroups.value.filter(g => g.id !== groupId)
allGroups.value = allGroups.value.filter(g => g.id !== groupId)
} catch (e) {
console.error('Failed to delete group:', e)
}
}
// Helper functions
const formatDate = (date) => {
return new Date(date).toLocaleDateString('ru-RU', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
const formatActivity = (action) => {
const actions = {
created_canvas: 'created',
updated_canvas: 'updated',
joined_group: 'joined',
left_group: 'left',
followed_user: 'followed',
unfollowed_user: 'unfollowed'
}
return actions[action] || action
}
const tabClass = (tab) => {
return activeTab.value === tab
? 'bg-slate-800 text-slate-200'
: 'text-slate-400 hover:text-slate-300'
}
const followButtonClass = computed(() => {
return isFollowing.value
? 'bg-slate-700 hover:bg-slate-600 text-white'
: 'bg-blue-600 hover:bg-blue-500 text-white'
})
onMounted(loadProfile)
watch(profileId, loadProfile)
</script>
+104
View File
@@ -0,0 +1,104 @@
<template>
<div class="min-h-screen bg-slate-950">
<div class="max-w-4xl mx-auto p-6">
<!-- Profile Header -->
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 mb-6">
<div class="flex items-start gap-4">
<img :src="profile.avatar" :alt="profile.displayName"
class="w-20 h-20 rounded-full bg-slate-800" />
<div class="flex-1">
<h1 class="text-2xl font-bold text-slate-100">{{ profile.displayName }}</h1>
<p class="text-slate-400">{{ profile.email }}</p>
<p class="text-sm text-slate-500 mt-2">{{ profile.bio }}</p>
</div>
<div class="text-right">
<div class="text-sm text-slate-500">Участник с</div>
<div class="text-slate-300">{{ new Date(profile.createdAt).toLocaleDateString('ru-RU') }}</div>
</div>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-2xl font-bold text-blue-400">{{ stats.canvases }}</div>
<div class="text-sm text-slate-400">Канвасов</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-2xl font-bold text-green-400">{{ stats.nodes }}</div>
<div class="text-sm text-slate-400">Узлов создано</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-2xl font-bold text-purple-400">{{ stats.groups }}</div>
<div class="text-sm text-slate-400">Групп</div>
</div>
</div>
<!-- Recent Canvases -->
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6">
<h2 class="text-xl font-semibold text-slate-100 mb-4">Последние канвасы</h2>
<div class="space-y-3">
<div v-for="canvas in recentCanvases" :key="canvas.id"
class="flex items-center justify-between p-3 bg-slate-800 rounded-lg">
<div>
<h3 class="font-medium text-slate-100">{{ canvas.name }}</h3>
<p class="text-sm text-slate-400">{{ canvas.description }}</p>
</div>
<NuxtLink :to="`/canvas/${canvas.id}`"
class="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm transition-colors">
Открыть
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useAuth } from '~/composables/useAuth'
const route = useRoute()
const auth = useAuth()
const userId = route.params.id
// Mock profile data - replace with actual API call
const profile = ref({
id: userId,
displayName: 'Пользователь XFlow',
email: 'user@example.com',
bio: 'Люблю создавать ментальные карты и организовывать идеи',
avatar: 'https://via.placeholder.com/80',
createdAt: new Date('2024-01-01').toISOString()
})
const stats = ref({
canvases: 12,
nodes: 156,
groups: 3
})
const recentCanvases = ref([
{
id: 'xflow-project',
name: 'Обзор Проекта XFlow',
description: 'Основная ментальная карта проекта'
},
{
id: 'demo',
name: 'Демо канвас',
description: 'Пример ментальной карты'
},
{
id: 'ideas',
name: 'Идеи 2024',
description: 'Сбор идей и мыслей'
}
])
onMounted(() => {
// Load profile data
console.log('Loading profile for user:', userId)
})
</script>
+96
View File
@@ -0,0 +1,96 @@
<template>
<div class="max-w-xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-6">Настройки</h1>
<div class="space-y-6">
<!-- 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">
<span>🔔</span> Push-уведомления
</h2>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-slate-300">Статус поддержки</span>
<span :class="isSupported ? 'text-emerald-400' : 'text-red-400'">
{{ isSupported ? 'Доступно' : 'Недоступно' }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-slate-300">Разрешение</span>
<span :class="permissionGranted ? 'text-emerald-400' : 'text-yellow-400'">
{{ permissionGranted ? 'Разрешено' : 'Не запрошено' }}
</span>
</div>
<button
v-if="isSupported && !permissionGranted"
@click="enablePush"
class="w-full py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition"
>
Включить уведомления
</button>
<button
v-if="isSupported && permissionGranted && !subscription"
@click="subscribe"
class="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg font-medium transition"
>
Подписаться на пуши
</button>
<div v-if="subscription" class="text-sm text-emerald-400">
✅ Подписка активна
</div>
</div>
</div>
<!-- Achievements -->
<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="achievements.length === 0" class="text-slate-400 text-sm">
Пока нет разблокированных ачивок. Завершите задачи в Canvas!
</div>
<div v-else class="space-y-2">
<div
v-for="ach in achievements"
:key="ach.id"
class="flex items-center gap-3 p-3 bg-slate-700 rounded-lg"
>
<span class="text-xl">{{ ach.icon || '🏆' }}</span>
<div>
<div class="font-medium">{{ ach.title }}</div>
<div class="text-xs text-slate-400">{{ ach.description }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
middleware: 'auth'
})
const { isSupported, subscription, requestPermission, subscribe: pushSubscribe } = usePush()
const { achievements, loadAchievements } = useAchievements()
const permissionGranted = ref(false)
const enablePush = async () => {
const granted = await requestPermission()
permissionGranted.value = granted
if (granted) {
await pushSubscribe()
}
}
const subscribe = async () => {
await pushSubscribe()
}
onMounted(async () => {
permissionGranted.value = Notification.permission === 'granted'
await loadAchievements()
})
</script>