feat: prisma schema, groups API, deploy workflow
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user