feat: restructure into XFlow monorepo with Nuxt 4, Modular Onion Monolith, Android client, and Desktop CLI
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# API Keys Configuration
|
||||
# Copy this file to .env and fill in your actual keys
|
||||
|
||||
|
||||
|
||||
# DeepSeek API (recommended - cost effective)
|
||||
# Get from: https://platform.deepseek.com/api_keys
|
||||
DEEPSEEK_API_KEY=sk_your_deepseek_api_key_here
|
||||
|
||||
# OpenAI API (alternative)
|
||||
# Get from: https://platform.openai.com/api-keys
|
||||
OPENAI_API_KEY=sk_your_openai_api_key_here
|
||||
|
||||
# Anthropic API (alternative)
|
||||
# Get from: https://console.anthropic.com/
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
|
||||
# Firebase Configuration (for Nuxt runtimeConfig)
|
||||
# Get from: https://console.firebase.google.com -> Project Settings -> General
|
||||
NUXT_PUBLIC_FIREBASE_API_KEY=your_firebase_api_key
|
||||
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
|
||||
NUXT_PUBLIC_FIREBASE_PROJECT_ID=your_firebase_project_id
|
||||
NUXT_PUBLIC_FIREBASE_APP_ID=your_firebase_app_id
|
||||
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_firebase_sender_id
|
||||
|
||||
# Server-side Firebase API key (same as public, used for token verification)
|
||||
NUXT_FIREBASE_API_KEY=your_firebase_api_key
|
||||
|
||||
# Google Calendar API
|
||||
# Enable in Google Cloud Console
|
||||
GOOGLE_CLOUD_CLIENT_ID=your_google_cloud_client_id
|
||||
GOOGLE_CLOUD_CLIENT_SECRET=your_google_cloud_client_secret
|
||||
|
||||
# Database (PostgreSQL)
|
||||
# Production: postgresql://xflow:password@postgres:5432/xflow
|
||||
# Dev via SSH tunnel: postgresql://xflow:password@localhost:5432/xflow
|
||||
DATABASE_URL=postgresql://xflow:xflow_pass@localhost:5432/xflow
|
||||
|
||||
# Gitea Integration
|
||||
GITEA_URL=http://git.a-crm.controlsystem.kz
|
||||
GITEA_TOKEN=your_gitea_token
|
||||
GITEA_ORG=AVTEX
|
||||
GITEA_MINDMAP_REPO=MMAVTEX
|
||||
GITEA_PROJECT_REPO=Projects
|
||||
|
||||
# JWT Secret for session tokens
|
||||
NUXT_JWT_SECRET=your_random_secret_here
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { initAuth } = useAuth()
|
||||
initAuth()
|
||||
|
||||
useHead({
|
||||
title: 'XFlow',
|
||||
meta: [
|
||||
{ charset: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no' },
|
||||
{ name: 'theme-color', content: '#0f172a' }
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-slate-950 text-slate-100;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.vue-flow {
|
||||
@apply bg-slate-900;
|
||||
}
|
||||
|
||||
.vue-flow__node {
|
||||
@apply bg-slate-800 border border-slate-700 rounded-lg shadow-lg;
|
||||
}
|
||||
|
||||
.vue-flow__node.selected {
|
||||
@apply border-blue-500 ring-2 ring-blue-500/50;
|
||||
}
|
||||
|
||||
.vue-flow__edge-path {
|
||||
stroke: #475569;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.vue-flow__controls {
|
||||
@apply bg-slate-800 border-slate-700;
|
||||
}
|
||||
|
||||
.vue-flow__controls-button {
|
||||
@apply bg-slate-700 text-slate-200 hover:bg-slate-600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<TransitionGroup
|
||||
name="achievement"
|
||||
tag="div"
|
||||
class="fixed bottom-4 right-4 z-50 flex flex-col gap-2"
|
||||
>
|
||||
<div
|
||||
v-for="ach in visibleAchievements"
|
||||
:key="ach.id"
|
||||
class="bg-slate-800 border border-yellow-500/50 rounded-xl p-4 shadow-2xl max-w-sm flex items-start gap-3"
|
||||
>
|
||||
<div class="text-3xl">{{ ach.icon || '🏆' }}</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-yellow-400 text-sm">{{ ach.title }}</h4>
|
||||
<p class="text-xs text-slate-400 mt-0.5">{{ ach.description }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="dismiss(ach.id)"
|
||||
class="text-slate-500 hover:text-slate-300 ml-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
achievements: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const visibleAchievements = ref([])
|
||||
|
||||
watch(() => props.achievements, (newAchievements) => {
|
||||
const incoming = newAchievements.filter(a => !visibleAchievements.value.find(v => v.id === a.id))
|
||||
incoming.forEach(ach => {
|
||||
visibleAchievements.value.push(ach)
|
||||
setTimeout(() => dismiss(ach.id), 5000)
|
||||
})
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
const dismiss = (id) => {
|
||||
visibleAchievements.value = visibleAchievements.value.filter(a => a.id !== id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.achievement-enter-active,
|
||||
.achievement-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.achievement-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%) scale(0.9);
|
||||
}
|
||||
|
||||
.achievement-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%) scale(0.9);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<nav class="md:hidden fixed bottom-0 left-0 right-0 bg-slate-900/80 backdrop-blur-xl border-t border-slate-800 pb-safe z-40">
|
||||
<div class="flex justify-around items-center h-16">
|
||||
<NuxtLink to="/" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
|
||||
<span class="text-xl">🏠</span>
|
||||
<span class="text-[10px] font-medium">Главная</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/canvases" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
|
||||
<span class="text-xl">🗺️</span>
|
||||
<span class="text-[10px] font-medium">Канвасы</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/tasks" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
|
||||
<span class="text-xl">✅</span>
|
||||
<span class="text-[10px] font-medium">Задачи</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink v-if="isAdmin" to="/admin" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-red-500">
|
||||
<span class="text-xl">🛡️</span>
|
||||
<span class="text-[10px] font-medium">Админ</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/settings" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
|
||||
<span class="text-xl">⚙️</span>
|
||||
<span class="text-[10px] font-medium">Опции</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/profile" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
|
||||
<span class="text-xl">👤</span>
|
||||
<span class="text-[10px] font-medium">Профиль</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const auth = useAuth()
|
||||
const isAdmin = computed(() => auth.user.value?.role === 'admin')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="h-screen w-full relative">
|
||||
<VueFlow
|
||||
v-model="elements"
|
||||
:default-zoom="0.8"
|
||||
:min-zoom="0.2"
|
||||
:max-zoom="2"
|
||||
fit-view-on-init
|
||||
@node-double-click="onNodeDoubleClick"
|
||||
>
|
||||
<Background pattern-color="#334155" :gap="20" />
|
||||
<Controls />
|
||||
<MiniMap pannable zoomable class="bg-slate-800 border-slate-700" />
|
||||
|
||||
<template #node-text="{ data, id }">
|
||||
<MarkdownNode
|
||||
:id="id"
|
||||
:data="data"
|
||||
@update-state="updateNodeState"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #node-file="{ data, id }">
|
||||
<MarkdownNode
|
||||
:id="id"
|
||||
:data="data"
|
||||
:is-file="true"
|
||||
@update-state="updateNodeState"
|
||||
/>
|
||||
</template>
|
||||
</VueFlow>
|
||||
|
||||
<div class="absolute top-4 left-4 z-10 bg-slate-800/90 backdrop-blur border border-slate-700 rounded-lg p-3">
|
||||
<h2 class="text-sm font-semibold text-slate-200">{{ canvasName }}</h2>
|
||||
<p class="text-xs text-slate-400 mt-1">Double click to edit state</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { VueFlow } from '@vue-flow/core'
|
||||
import { Background } from '@vue-flow/background'
|
||||
import { Controls } from '@vue-flow/controls'
|
||||
import { MiniMap } from '@vue-flow/minimap'
|
||||
import '@vue-flow/core/dist/style.css'
|
||||
import '@vue-flow/core/dist/theme-default.css'
|
||||
|
||||
const props = defineProps({
|
||||
canvasId: { type: String, default: 'demo' }
|
||||
})
|
||||
|
||||
const { loadCanvas, updateNodeState: apiUpdateNodeState } = useCanvas()
|
||||
const canvasName = computed(() => props.canvasId)
|
||||
const elements = ref([])
|
||||
|
||||
const transformToFlowElements = (canvasData) => {
|
||||
const nodes = (canvasData.nodes || []).map(node => ({
|
||||
id: node.id,
|
||||
type: node.type === 'file' ? 'file' : 'text',
|
||||
position: { x: node.x, y: node.y },
|
||||
data: {
|
||||
...node,
|
||||
width: node.width,
|
||||
height: node.height
|
||||
},
|
||||
style: {
|
||||
width: `${node.width}px`,
|
||||
height: `${node.height}px`,
|
||||
background: node.style?.background || '#1e293b',
|
||||
border: `1px solid ${node.style?.border || '#334155'}`,
|
||||
borderRadius: '8px'
|
||||
}
|
||||
}))
|
||||
|
||||
const edges = (canvasData.edges || []).map(edge => ({
|
||||
id: edge.id,
|
||||
source: edge.fromNode,
|
||||
target: edge.toNode,
|
||||
sourceHandle: edge.fromSide,
|
||||
targetHandle: edge.toSide,
|
||||
animated: true,
|
||||
style: { stroke: '#475569', strokeWidth: 2 }
|
||||
}))
|
||||
|
||||
return [...nodes, ...edges]
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
const data = await loadCanvas(props.canvasId)
|
||||
elements.value = transformToFlowElements(data)
|
||||
}
|
||||
|
||||
const updateNodeState = async ({ nodeId, state }) => {
|
||||
await apiUpdateNodeState(nodeId, state)
|
||||
await loadData()
|
||||
}
|
||||
|
||||
const onNodeDoubleClick = (event) => {
|
||||
const node = event.node
|
||||
const currentState = node.data?.state || 'pending'
|
||||
const nextState = currentState === 'pending' ? 'in_progress' : currentState === 'in_progress' ? 'completed' : 'pending'
|
||||
updateNodeState({ nodeId: node.id, state: nextState })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
:class="[
|
||||
'bg-slate-800 border rounded-lg shadow-lg p-4 text-sm font-sans flex flex-col h-full w-full',
|
||||
selected ? 'ring-2 ring-blue-500' : '',
|
||||
nodeStateClass
|
||||
]"
|
||||
:style="nodeStyle"
|
||||
>
|
||||
<!-- Header with icon and label -->
|
||||
<div class="flex items-center gap-2 mb-3 pb-2 border-b border-slate-600">
|
||||
<span class="text-lg">{{ nodeIcon }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-white truncate">{{ data.label || getNodeTitle() }}</div>
|
||||
<div class="text-xs text-slate-400 uppercase tracking-wider">{{ data.type || type }}</div>
|
||||
</div>
|
||||
<div v-if="type === 'task' && data.state" :class="stateBadgeClass">
|
||||
{{ stateLabel }}
|
||||
</div>
|
||||
<div v-if="type === 'task' && data.priority" :class="priorityBadgeClass">
|
||||
{{ priorityLabel }}
|
||||
</div>
|
||||
<!-- Кнопка запуска для спец-узлов -->
|
||||
<button
|
||||
v-if="['ai', 'webhook'].includes(type)"
|
||||
@click.stop="handleExecute"
|
||||
:disabled="isExecuting"
|
||||
class="ml-auto p-1.5 hover:bg-slate-700 rounded-md transition-colors group relative"
|
||||
title="Запустить выполнение"
|
||||
>
|
||||
<span v-if="isExecuting" class="block animate-spin text-blue-400">🌀</span>
|
||||
<span v-else class="block group-hover:scale-110 transition-transform">▶️</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content preview for measurement -->
|
||||
<div class="text-slate-100 space-y-2 flex-1">
|
||||
<div v-if="data.text" class="text-slate-300 text-xs leading-relaxed whitespace-pre-wrap mb-3" v-html="formattedText"></div>
|
||||
<div v-if="data.file" class="flex items-center gap-2">
|
||||
<span class="text-sm">📄</span>
|
||||
<span class="font-mono text-sm text-blue-400">{{ data.file }}</span>
|
||||
</div>
|
||||
<div v-if="data.prompt" class="text-sm text-purple-300">
|
||||
<div class="font-medium mb-1">AI Prompt:</div>
|
||||
<div>{{ data.prompt }}</div>
|
||||
</div>
|
||||
<div v-if="data.url" class="flex items-center gap-2">
|
||||
<span class="text-sm">🔗</span>
|
||||
<a :href="data.url" target="_blank" class="text-blue-400 hover:text-blue-300 text-sm break-all">{{ data.url }}</a>
|
||||
</div>
|
||||
<div v-if="data.script_id" class="flex items-center gap-2">
|
||||
<span class="text-sm">🎭</span>
|
||||
<span class="font-mono text-sm text-green-400">{{ data.script_id }}</span>
|
||||
</div>
|
||||
<div v-if="data.assigned_to" class="flex items-center gap-2">
|
||||
<span class="text-sm">👤</span>
|
||||
<span class="text-sm text-slate-300">{{ data.assigned_to }}</span>
|
||||
</div>
|
||||
<div v-if="data.alarm" class="flex items-center gap-2">
|
||||
<span class="text-sm">⏰</span>
|
||||
<span class="text-sm text-orange-400">{{ formatAlarm(data.alarm) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection handles -->
|
||||
<Handle type="target" :position="Position.Left" class="w-3 h-3 bg-slate-600 border-2 border-slate-700" />
|
||||
<Handle type="source" :position="Position.Right" class="w-3 h-3 bg-slate-600 border-2 border-slate-700" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Handle, Position, useVueFlow } from '@vue-flow/core'
|
||||
import { computed, ref, onMounted, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, required: true },
|
||||
data: { type: Object, required: true },
|
||||
type: { type: String, default: 'text' },
|
||||
selected: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update', 'execute'])
|
||||
const isExecuting = ref(false)
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (isExecuting.value) return
|
||||
isExecuting.value = true
|
||||
try {
|
||||
emit('execute', { id: props.id, type: props.type })
|
||||
} finally {
|
||||
// Состояние загрузки сбрасывается внешним событием или по таймауту для безопасности
|
||||
setTimeout(() => { isExecuting.value = false }, 10000)
|
||||
}
|
||||
}
|
||||
|
||||
// Root element ref
|
||||
const rootEl = ref(null)
|
||||
|
||||
// Access Vue Flow API to update node dimensions
|
||||
const { updateNode } = useVueFlow()
|
||||
|
||||
// Measure and apply real height to Vue Flow node wrapper
|
||||
const applyNodeSize = () => {
|
||||
if (!rootEl.value) return
|
||||
const h = rootEl.value.scrollHeight
|
||||
if (h > 0) {
|
||||
updateNode(props.id, {
|
||||
style: { height: h + 'px' }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => setTimeout(applyNodeSize, 50))
|
||||
setTimeout(applyNodeSize, 200)
|
||||
})
|
||||
|
||||
// Node style: fixed width from data, height auto to fit content
|
||||
const nodeStyle = computed(() => {
|
||||
const baseStyle = {
|
||||
backgroundColor: props.data.style?.background || '#1e293b',
|
||||
borderColor: props.data.style?.border || '#475569',
|
||||
width: `${props.data.width || 320}px`
|
||||
}
|
||||
|
||||
if (props.data.state === 'completed') {
|
||||
baseStyle.borderColor = '#10b981' // Green
|
||||
} else if (props.data.state === 'in_progress') {
|
||||
baseStyle.borderColor = '#3b82f6' // Blue
|
||||
} else if (props.data.state === 'review') {
|
||||
baseStyle.borderColor = '#a855f7' // Purple
|
||||
} else if (props.data.state === 'blocked') {
|
||||
baseStyle.borderColor = '#ef4444' // Red
|
||||
} else if (props.data.state === 'todo') {
|
||||
baseStyle.borderColor = '#64748b' // Slate
|
||||
}
|
||||
|
||||
return baseStyle
|
||||
})
|
||||
|
||||
// Node type icon
|
||||
const nodeIcon = computed(() => {
|
||||
const icons = {
|
||||
text: '📝',
|
||||
file: '📄',
|
||||
task: '✅',
|
||||
ai: '🤖',
|
||||
webhook: '🔗',
|
||||
git: '🔄'
|
||||
}
|
||||
return icons[props.type] || '📝'
|
||||
})
|
||||
|
||||
// State handling
|
||||
const nodeStateClass = computed(() => {
|
||||
const state = props.data.state
|
||||
if (state === 'completed') return 'border-emerald-600 bg-emerald-950/20'
|
||||
if (state === 'in_progress') return 'border-blue-600 bg-blue-950/20'
|
||||
if (state === 'review') return 'border-purple-600 bg-purple-950/20'
|
||||
if (state === 'blocked') return 'border-red-600 bg-red-950/20'
|
||||
if (state === 'todo') return 'border-slate-500 bg-slate-900/50'
|
||||
return ''
|
||||
})
|
||||
|
||||
const stateBadgeClass = computed(() => {
|
||||
const state = props.data.state
|
||||
if (state === 'completed') return 'px-1.5 py-0.5 bg-emerald-800/50 text-emerald-300 rounded text-[10px] font-medium'
|
||||
if (state === 'in_progress') return 'px-1.5 py-0.5 bg-blue-800/50 text-blue-300 rounded text-[10px] font-medium'
|
||||
if (state === 'review') return 'px-1.5 py-0.5 bg-purple-800/50 text-purple-300 rounded text-[10px] font-medium'
|
||||
if (state === 'blocked') return 'px-1.5 py-0.5 bg-red-800/50 text-red-300 rounded text-[10px] font-medium'
|
||||
return 'px-1.5 py-0.5 bg-slate-700 text-slate-300 rounded text-[10px] font-medium'
|
||||
})
|
||||
|
||||
const stateLabel = computed(() => {
|
||||
const state = props.data.state
|
||||
if (state === 'completed') return '✅ Done'
|
||||
if (state === 'in_progress') return '⏳ In Progress'
|
||||
if (state === 'review') return '👀 Review'
|
||||
if (state === 'blocked') return '⛔ Blocked'
|
||||
return '📋 To Do'
|
||||
})
|
||||
|
||||
// Priority handling
|
||||
const priorityBadgeClass = computed(() => {
|
||||
const prio = props.data.priority
|
||||
if (prio === 'high') return 'px-1.5 py-0.5 bg-red-800/50 text-red-300 rounded text-[10px] font-bold'
|
||||
if (prio === 'low') return 'px-1.5 py-0.5 bg-slate-800/50 text-slate-400 rounded text-[10px] font-medium'
|
||||
return 'px-1.5 py-0.5 bg-blue-800/50 text-blue-300 rounded text-[10px] font-medium' // medium
|
||||
})
|
||||
|
||||
const priorityLabel = computed(() => {
|
||||
const prio = props.data.priority
|
||||
if (prio === 'high') return '🔥 High'
|
||||
if (prio === 'low') return '🧊 Low'
|
||||
return '⚡ Med'
|
||||
})
|
||||
|
||||
// Get node title from text content
|
||||
const getNodeTitle = () => {
|
||||
if (props.data.text) {
|
||||
const lines = props.data.text.split('\n')
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('# ')) {
|
||||
return trimmed.replace('# ', '')
|
||||
}
|
||||
}
|
||||
// If no h1 found, use first non-empty line
|
||||
const firstLine = lines.find(l => l.trim())
|
||||
return firstLine ? firstLine.trim().slice(0, 30) : 'Node'
|
||||
}
|
||||
if (props.data.file) return props.data.file
|
||||
return 'Node'
|
||||
}
|
||||
|
||||
// Format text with markdown support
|
||||
const formattedText = computed(() => {
|
||||
if (!props.data.text) return ''
|
||||
|
||||
let text = props.data.text
|
||||
|
||||
// Convert markdown headers
|
||||
text = text.replace(/^# (.*$)/gim, '<h1 class="text-lg font-bold text-white mb-2">$1</h1>')
|
||||
text = text.replace(/^## (.*$)/gim, '<h2 class="text-base font-semibold text-white mb-2">$1</h2>')
|
||||
text = text.replace(/^### (.*$)/gim, '<h3 class="text-sm font-medium text-white mb-1">$1</h3>')
|
||||
|
||||
// Convert bold text
|
||||
text = text.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold text-white">$1</strong>')
|
||||
|
||||
// Convert italic text
|
||||
text = text.replace(/\*(.*?)\*/g, '<em class="italic text-slate-200">$1</em>')
|
||||
|
||||
// Convert code blocks
|
||||
text = text.replace(/```(.*?)```/gs, '<pre class="bg-slate-900 p-2 rounded text-xs font-mono text-slate-300 mb-2 overflow-x-auto"><code>$1</code></pre>')
|
||||
|
||||
// Convert inline code
|
||||
text = text.replace(/`(.*?)`/g, '<code class="bg-slate-700 px-1 rounded text-xs font-mono text-slate-300">$1</code>')
|
||||
|
||||
// Convert lists
|
||||
text = text.replace(/^- (.*$)/gim, '<li class="ml-4 text-slate-200">• $1</li>')
|
||||
text = text.replace(/^ - (.*$)/gim, '<li class="ml-8 text-slate-300">◦ $1</li>')
|
||||
|
||||
// Convert line breaks
|
||||
text = text.replace(/\n\n/g, '</p><p class="mb-2 text-slate-200">')
|
||||
text = text.replace(/\n/g, '<br>')
|
||||
|
||||
// Wrap in paragraphs
|
||||
if (!text.includes('<p>')) {
|
||||
text = '<p class="mb-2 text-slate-200">' + text + '</p>'
|
||||
}
|
||||
|
||||
return text
|
||||
})
|
||||
|
||||
// Format alarm time
|
||||
const formatAlarm = (alarm) => {
|
||||
try {
|
||||
const date = new Date(alarm)
|
||||
return date.toLocaleDateString('ru-RU', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
} catch {
|
||||
return alarm
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #475569 #1e293b;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div v-if="deferredPrompt && !isStandalone" class="fixed bottom-6 right-6 z-50">
|
||||
<button
|
||||
@click="installApp"
|
||||
class="bg-blue-600 hover:bg-blue-500 text-white px-6 py-3 rounded-2xl font-bold shadow-2xl flex items-center gap-3 transition-all transform hover:scale-105 active:scale-95 group"
|
||||
>
|
||||
<span class="text-xl group-hover:rotate-12 transition-transform">📲</span>
|
||||
<span>Установить XFlow</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const deferredPrompt = ref(null)
|
||||
const isStandalone = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
isStandalone.value = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault()
|
||||
deferredPrompt.value = e
|
||||
})
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
deferredPrompt.value = null
|
||||
})
|
||||
})
|
||||
|
||||
const installApp = async () => {
|
||||
if (!deferredPrompt.value) {
|
||||
alert('Браузер еще не подготовил установку. Пожалуйста, подождите 10-20 секунд или проверьте вкладку Application в консоли.')
|
||||
return
|
||||
}
|
||||
|
||||
deferredPrompt.value.prompt()
|
||||
const { outcome } = await deferredPrompt.value.userChoice
|
||||
console.log(`PWA: user choice outcome: ${outcome}`)
|
||||
deferredPrompt.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes bounce-subtle {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-5px); }
|
||||
}
|
||||
.animate-bounce-subtle {
|
||||
animation: bounce-subtle 3s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div
|
||||
class="w-full h-full overflow-auto p-3 rounded-lg cursor-pointer"
|
||||
:class="stateClass"
|
||||
@click="$emit('update-state', { nodeId: id, state: nextState })"
|
||||
>
|
||||
<div v-if="data.frontmatter?.alarm" class="text-xs text-yellow-400 mb-1 flex items-center gap-1">
|
||||
<span>⏰</span>
|
||||
<span>{{ formatAlarm(data.frontmatter.alarm) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="data.frontmatter?.assigned_to" class="text-xs text-slate-400 mb-1">
|
||||
👤 {{ data.frontmatter.assigned_to }}
|
||||
</div>
|
||||
|
||||
<div v-if="data.text" class="prose prose-invert prose-sm max-w-none" v-html="renderedHtml" />
|
||||
|
||||
<div v-else-if="data.file" class="text-sm text-blue-400">
|
||||
📄 {{ data.file }}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full"
|
||||
:class="badgeClass"
|
||||
>
|
||||
{{ stateLabel }}
|
||||
</span>
|
||||
<span v-if="data.frontmatter?.script_id" class="text-xs text-purple-400">
|
||||
⚡ {{ data.frontmatter.script_id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
id: { type: String, required: true },
|
||||
data: { type: Object, required: true },
|
||||
isFile: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update-state'])
|
||||
|
||||
const { renderMarkdown } = useCanvas()
|
||||
|
||||
const renderedHtml = computed(() => {
|
||||
return renderMarkdown(props.data.text || '')
|
||||
})
|
||||
|
||||
const currentState = computed(() => props.data.state || 'pending')
|
||||
|
||||
const nextState = computed(() => {
|
||||
const s = currentState.value
|
||||
return s === 'pending' ? 'in_progress' : s === 'in_progress' ? 'completed' : 'pending'
|
||||
})
|
||||
|
||||
const stateClass = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case 'completed': return 'bg-emerald-900/40 border-emerald-700'
|
||||
case 'in_progress': return 'bg-blue-900/40 border-blue-700'
|
||||
default: return 'bg-slate-800 border-slate-700'
|
||||
}
|
||||
})
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case 'completed': return 'bg-emerald-700 text-emerald-100'
|
||||
case 'in_progress': return 'bg-blue-700 text-blue-100'
|
||||
default: return 'bg-slate-700 text-slate-300'
|
||||
}
|
||||
})
|
||||
|
||||
const stateLabel = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case 'completed': return '✅ Выполнено'
|
||||
case 'in_progress': return '🔧 В работе'
|
||||
default: return '📋 Ожидает'
|
||||
}
|
||||
})
|
||||
|
||||
const formatAlarm = (alarm) => {
|
||||
try {
|
||||
return new Date(alarm).toLocaleString('ru-RU')
|
||||
} catch {
|
||||
return alarm
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 backdrop-blur-sm p-4 font-sans">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl overflow-hidden">
|
||||
<!-- Modal Header -->
|
||||
<div class="px-6 py-4 border-b border-slate-800 flex justify-between items-center bg-slate-900/50">
|
||||
<h2 class="text-base font-bold text-slate-200 flex items-center gap-2">
|
||||
<span>🧙♂️</span> Коуч-Ассистент: Конструктор SMART & GROW
|
||||
</h2>
|
||||
<button @click="$emit('close')" class="text-slate-400 hover:text-white transition">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-slate-800 bg-slate-950/40">
|
||||
<button
|
||||
@click="wizardType = 'smart'"
|
||||
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'smart' ? 'text-indigo-400 border-indigo-500 bg-indigo-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
|
||||
>
|
||||
🎯 SMART Цель
|
||||
</button>
|
||||
<button
|
||||
@click="wizardType = 'grow'"
|
||||
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'grow' ? 'text-teal-400 border-teal-500 bg-teal-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
|
||||
>
|
||||
🌱 GROW Сессия
|
||||
</button>
|
||||
<button
|
||||
@click="wizardType = 'dilts'"
|
||||
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'dilts' ? 'text-violet-400 border-violet-500 bg-violet-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
|
||||
>
|
||||
📐 Пирамида Дилтса
|
||||
</button>
|
||||
<button
|
||||
@click="wizardType = 'eisenhower'"
|
||||
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'eisenhower' ? 'text-amber-400 border-amber-500 bg-amber-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
|
||||
>
|
||||
📊 Матрица Эйзенхауэра
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body (Forms) -->
|
||||
<div class="p-6 overflow-y-auto flex-1 space-y-4">
|
||||
<!-- S.M.A.R.T Form -->
|
||||
<div v-if="wizardType === 'smart'" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">S (Specific) — Какая конкретная задача?</label>
|
||||
<textarea v-model="smartForm.specific" rows="2" placeholder="Что именно вы хотите сделать? (например: Разработать модуль экспорта canvas)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">M (Measurable) — Как вы измерите успех?</label>
|
||||
<textarea v-model="smartForm.measurable" rows="2" placeholder="Укажите критерии готовности (каждую метрику с новой строки)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">A (Achievable) — За счет чего цель достижима?</label>
|
||||
<textarea v-model="smartForm.achievable" rows="2" placeholder="Какими ресурсами и навыками вы располагаете?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">R (Relevant) — Почему эта цель важна?</label>
|
||||
<textarea v-model="smartForm.relevant" rows="2" placeholder="Какую пользу принесет выполнение этой цели общему проекту?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">T (Time-bound) — Каков дедлайн?</label>
|
||||
<input v-model="smartForm.deadline" type="datetime-local" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- G.R.O.W Form -->
|
||||
<div v-if="wizardType === 'grow'" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">G (Goal) — Цель</label>
|
||||
<textarea v-model="growForm.goal" rows="3" placeholder="Какой именно цели я хочу достичь? Какой результат я хочу получить? Почему достижение этой цели важно?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">O (Options) — Варианты</label>
|
||||
<textarea v-model="growForm.options" rows="4" placeholder="Что именно я могу изменить? Какие ресурсы мне нужны? Кто мне может помочь? А что еще?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">R (Reality) — Реальность</label>
|
||||
<textarea v-model="growForm.reality" rows="4" placeholder="Какова текущая ситуация? Какие препятствия я вижу? Что мне мешает начать? Какие у меня уже есть ресурсы?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">W (Way Forward) — План</label>
|
||||
<textarea v-model="growForm.wayForward" rows="3" placeholder="Какие самые важные действия необходимо предпринять? Что я сделаю в первую очередь? Какие сроки?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dilts Form -->
|
||||
<div v-if="wizardType === 'dilts'" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">🌟 Миссия — Ради кого? Кому еще это важно? (БЫТЬ)</label>
|
||||
<textarea v-model="diltsForm.mission" rows="2" placeholder="Ради кого? Кому еще это важно? (БЫТЬ)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">👤 Я — Кто я?</label>
|
||||
<textarea v-model="diltsForm.identity" rows="2" placeholder="Кто я?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">💎 Ценности — Почему это важно?</label>
|
||||
<textarea v-model="diltsForm.values" rows="2" placeholder="Почему это важно?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">🧠 Навыки — Как? Каким образом?</label>
|
||||
<textarea v-model="diltsForm.skills" rows="2" placeholder="Как? Каким образом?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">🎬 Действия — Что делать? (ДЕЛАТЬ)</label>
|
||||
<textarea v-model="diltsForm.actions" rows="2" placeholder="Что делать? (ДЕЛАТЬ)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">🌍 Окружение — Где? Когда? С кем? (ИМЕТЬ)</label>
|
||||
<textarea v-model="diltsForm.environment" rows="2" placeholder="Где? Когда? С кем? (ИМЕТЬ)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Eisenhower Form -->
|
||||
<div v-if="wizardType === 'eisenhower'" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">🔥 Квадрант I: Важно и Срочно (Сделать сейчас)</label>
|
||||
<textarea v-model="eisenhowerForm.q1" rows="2" placeholder="Кризисы, горящие дедлайны, важные неотложные задачи" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">⚡ Квадрант II: Важно, но Несрочно (Запланировать)</label>
|
||||
<textarea v-model="eisenhowerForm.q2" rows="2" placeholder="Долгосрочное планирование, здоровье, обучение, стратегические цели" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">🧊 Квадрант III: Неважно, но Срочно (Делегировать)</label>
|
||||
<textarea v-model="eisenhowerForm.q3" rows="2" placeholder="Входящие рутинные звонки/письма, мелкие поручения, задачи, которые можно перепоручить" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">💤 Квадрант IV: Неважно и Несрочно (Отложить/Удалить)</label>
|
||||
<textarea v-model="eisenhowerForm.q4" rows="2" placeholder="Пожиратели времени, мелкие развлечения, неважная рутина" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="px-6 py-4 border-t border-slate-800 bg-slate-900/50 flex justify-end gap-3">
|
||||
<button @click="$emit('close')" class="px-4 py-2 border border-slate-700 hover:bg-slate-800 text-slate-300 rounded-lg text-xs transition">
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
@click="submitManual"
|
||||
:disabled="isSubmitting"
|
||||
class="px-4 py-2 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-white rounded-lg text-xs transition"
|
||||
>
|
||||
Создать вручную
|
||||
</button>
|
||||
<button
|
||||
@click="submitAI"
|
||||
:disabled="isSubmitting"
|
||||
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition flex items-center gap-1.5"
|
||||
>
|
||||
<span v-if="isSubmitting" class="animate-spin text-xs">🌀</span>
|
||||
<span v-else>✨</span>
|
||||
Оптимизировать через AI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
isSubmitting: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'close',
|
||||
'submit-smart-manual',
|
||||
'submit-smart-ai',
|
||||
'submit-grow-manual',
|
||||
'submit-grow-ai',
|
||||
'submit-dilts-manual',
|
||||
'submit-dilts-ai',
|
||||
'submit-eisenhower-manual',
|
||||
'submit-eisenhower-ai'
|
||||
])
|
||||
|
||||
const wizardType = ref('smart')
|
||||
|
||||
const smartForm = ref({
|
||||
specific: '',
|
||||
measurable: '',
|
||||
achievable: '',
|
||||
relevant: '',
|
||||
deadline: ''
|
||||
})
|
||||
|
||||
const growForm = ref({
|
||||
goal: '',
|
||||
reality: '',
|
||||
options: '',
|
||||
wayForward: ''
|
||||
})
|
||||
|
||||
const diltsForm = ref({
|
||||
mission: '',
|
||||
identity: '',
|
||||
values: '',
|
||||
skills: '',
|
||||
actions: '',
|
||||
environment: ''
|
||||
})
|
||||
|
||||
const eisenhowerForm = ref({
|
||||
q1: '',
|
||||
q2: '',
|
||||
q3: '',
|
||||
q4: ''
|
||||
})
|
||||
|
||||
const resetForms = () => {
|
||||
smartForm.value = { specific: '', measurable: '', achievable: '', relevant: '', deadline: '' }
|
||||
growForm.value = { goal: '', reality: '', options: '', wayForward: '' }
|
||||
diltsForm.value = { mission: '', identity: '', values: '', skills: '', actions: '', environment: '' }
|
||||
eisenhowerForm.value = { q1: '', q2: '', q3: '', q4: '' }
|
||||
}
|
||||
|
||||
const submitManual = () => {
|
||||
if (wizardType.value === 'smart') {
|
||||
emit('submit-smart-manual', { ...smartForm.value })
|
||||
} else if (wizardType.value === 'grow') {
|
||||
emit('submit-grow-manual', { ...growForm.value })
|
||||
} else if (wizardType.value === 'dilts') {
|
||||
emit('submit-dilts-manual', { ...diltsForm.value })
|
||||
} else if (wizardType.value === 'eisenhower') {
|
||||
emit('submit-eisenhower-manual', { ...eisenhowerForm.value })
|
||||
}
|
||||
resetForms()
|
||||
}
|
||||
|
||||
const submitAI = () => {
|
||||
if (wizardType.value === 'smart') {
|
||||
if (!smartForm.value.specific.trim()) {
|
||||
alert('Пожалуйста, введите хотя бы описание цели!')
|
||||
return
|
||||
}
|
||||
emit('submit-smart-ai', { ...smartForm.value })
|
||||
} else if (wizardType.value === 'grow') {
|
||||
if (!growForm.value.goal.trim()) {
|
||||
alert('Пожалуйста, введите хотя бы цель (Goal)!')
|
||||
return
|
||||
}
|
||||
emit('submit-grow-ai', { ...growForm.value })
|
||||
} else if (wizardType.value === 'dilts') {
|
||||
if (!diltsForm.value.mission.trim()) {
|
||||
alert('Пожалуйста, введите хотя бы описание миссии!')
|
||||
return
|
||||
}
|
||||
emit('submit-dilts-ai', { ...diltsForm.value })
|
||||
} else if (wizardType.value === 'eisenhower') {
|
||||
if (!eisenhowerForm.value.q1.trim() && !eisenhowerForm.value.q2.trim() && !eisenhowerForm.value.q3.trim() && !eisenhowerForm.value.q4.trim()) {
|
||||
alert('Пожалуйста, введите задачи хотя бы в один из квадрантов!')
|
||||
return
|
||||
}
|
||||
emit('submit-eisenhower-ai', { ...eisenhowerForm.value })
|
||||
}
|
||||
}
|
||||
|
||||
// Expose reset in case parent wants to reset from outside
|
||||
defineExpose({ resetForms })
|
||||
</script>
|
||||
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed bottom-0 left-0 right-0 z-30 transition-all duration-300 ease-in-out font-sans flex flex-col"
|
||||
:class="[isOpen ? 'h-80' : 'h-10']"
|
||||
>
|
||||
<!-- Handle / Toggle Bar -->
|
||||
<div
|
||||
@click="isOpen = !isOpen"
|
||||
class="h-10 bg-slate-900 border-t border-slate-700/80 hover:bg-slate-800 cursor-pointer flex items-center px-4 justify-between select-none transition"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-slate-200 font-bold text-xs">
|
||||
<span>📅</span>
|
||||
<span>Диаграмма Ганта</span>
|
||||
<span class="px-1.5 py-0.5 bg-slate-800 text-[10px] text-slate-400 rounded-full border border-slate-700 font-medium">
|
||||
Задач: {{ tasks.length }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-slate-400 text-xs">
|
||||
{{ isOpen ? '▼ Свернуть' : '▲ Развернуть' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Body -->
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="flex-1 bg-slate-950/95 backdrop-blur-md border-t border-slate-800 flex overflow-hidden min-w-0"
|
||||
>
|
||||
<!-- Task Titles Sidebar (Left) -->
|
||||
<div class="w-64 border-r border-slate-800 flex flex-col shrink-0">
|
||||
<div class="h-8 border-b border-slate-800 bg-slate-900/40 px-3 flex items-center text-[10px] font-bold text-slate-500 uppercase tracking-wider">
|
||||
Задача
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto divide-y divide-slate-900/60 scrollbar-thin">
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
@click="$emit('select-node', task.id)"
|
||||
class="h-10 px-3 flex items-center text-xs text-slate-300 hover:text-white hover:bg-slate-800/30 cursor-pointer transition select-none truncate font-medium"
|
||||
title="Кликните для фокусировки на холсте"
|
||||
>
|
||||
<span class="mr-1.5">{{ getStatusIcon(task.data.state) }}</span>
|
||||
<span class="truncate">{{ task.data.label || 'Без названия' }}</span>
|
||||
</div>
|
||||
<div v-if="tasks.length === 0" class="p-6 text-center text-xs text-slate-600">
|
||||
Нет задач на холсте
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Chart (Right) -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
<!-- Date Columns Header -->
|
||||
<div
|
||||
class="h-8 border-b border-slate-800 bg-slate-900/40 flex overflow-x-auto overflow-y-hidden select-none scrollbar-none"
|
||||
ref="timelineHeader"
|
||||
@scroll="syncScrollLeft"
|
||||
>
|
||||
<div
|
||||
v-for="(day, idx) in timelineDays"
|
||||
:key="idx"
|
||||
class="h-full border-r border-slate-900/40 shrink-0 flex flex-col items-center justify-center text-[9px] font-semibold text-slate-400"
|
||||
:style="{ width: `${dayWidth}px` }"
|
||||
>
|
||||
<span>{{ day.dayOfWeek }}</span>
|
||||
<span class="text-slate-500 font-normal">{{ day.dateStr }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Bars Body -->
|
||||
<div
|
||||
class="flex-1 overflow-auto divide-y divide-slate-900/60 scrollbar-thin"
|
||||
ref="timelineBody"
|
||||
@scroll="syncScrollRight"
|
||||
>
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="h-10 relative flex items-center bg-slate-950/20 hover:bg-slate-900/30 transition group"
|
||||
>
|
||||
<!-- Timeline Grid Background Lines -->
|
||||
<div class="absolute inset-0 flex pointer-events-none">
|
||||
<div
|
||||
v-for="idx in timelineDays.length"
|
||||
:key="idx"
|
||||
class="h-full border-r border-slate-900/40 shrink-0"
|
||||
:style="{ width: `${dayWidth}px` }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Task Gantt Bar -->
|
||||
<div
|
||||
v-if="hasValidDates(task)"
|
||||
@click="$emit('select-node', task.id)"
|
||||
class="absolute h-5 rounded-md border text-[10px] font-bold text-white px-2 flex items-center truncate cursor-pointer transition shadow-md hover:scale-[1.01] z-10"
|
||||
:class="getBarClasses(task)"
|
||||
:style="getBarStyles(task)"
|
||||
>
|
||||
<span class="truncate">{{ task.data.label }}</span>
|
||||
<!-- Tooltip on hover -->
|
||||
<div class="absolute bottom-6 left-0 hidden group-hover:block bg-slate-900 border border-slate-700 text-[10px] rounded p-2 text-slate-300 w-48 shadow-xl pointer-events-none whitespace-normal leading-relaxed z-20">
|
||||
<div class="font-bold text-white truncate mb-1">{{ task.data.label }}</div>
|
||||
<div>Статус: <span class="font-medium text-slate-200">{{ getStatusLabel(task.data.state) }}</span></div>
|
||||
<div>Приоритет: <span class="font-medium text-slate-200">{{ getPriorityLabel(task.data.priority) }}</span></div>
|
||||
<div v-if="task.data.assigned_to">Исполнитель: <span class="font-medium text-slate-200">{{ task.data.assigned_to }}</span></div>
|
||||
<div class="text-[9px] text-slate-500 mt-1 border-t border-slate-800 pt-1">
|
||||
С: {{ formatDate(task.data.start_date) }}<br/>
|
||||
По: {{ formatDate(task.data.alarm) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tasks.length === 0" class="h-full flex items-center justify-center text-xs text-slate-600">
|
||||
Создайте задачи и задайте им даты начала/дедлайны
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
elements: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
defineEmits(['select-node'])
|
||||
|
||||
const isOpen = ref(false)
|
||||
const dayWidth = 60 // Width of one day column in pixels
|
||||
|
||||
const timelineHeader = ref(null)
|
||||
const timelineBody = ref(null)
|
||||
|
||||
// Scroll synchronization between header and body
|
||||
const syncScrollLeft = (e) => {
|
||||
if (timelineBody.value) {
|
||||
timelineBody.value.scrollLeft = e.target.scrollLeft
|
||||
}
|
||||
}
|
||||
const syncScrollRight = (e) => {
|
||||
if (timelineHeader.value) {
|
||||
timelineHeader.value.scrollLeft = e.target.scrollLeft
|
||||
}
|
||||
}
|
||||
|
||||
// Filter tasks
|
||||
const tasks = computed(() => {
|
||||
return props.elements.filter(el => el.type === 'task')
|
||||
})
|
||||
|
||||
// Calculate overall min/max dates
|
||||
const dateRange = computed(() => {
|
||||
let minDate = new Date()
|
||||
let maxDate = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) // Default 14 days from now
|
||||
|
||||
const taskDates = []
|
||||
tasks.value.forEach(t => {
|
||||
if (t.data.start_date) taskDates.push(new Date(t.data.start_date))
|
||||
if (t.data.alarm) taskDates.push(new Date(t.data.alarm))
|
||||
})
|
||||
|
||||
if (taskDates.length > 0) {
|
||||
minDate = new Date(Math.min(...taskDates))
|
||||
maxDate = new Date(Math.max(...taskDates))
|
||||
}
|
||||
|
||||
// Add 2 days padding to sides
|
||||
minDate.setDate(minDate.getDate() - 2)
|
||||
maxDate.setDate(maxDate.getDate() + 5)
|
||||
|
||||
// Cap timeline length at 30 days to prevent rendering overflow
|
||||
const diffTime = Math.abs(maxDate - minDate)
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
if (diffDays > 30) {
|
||||
maxDate = new Date(minDate.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
// Reset to midnight
|
||||
minDate.setHours(0, 0, 0, 0)
|
||||
maxDate.setHours(23, 59, 59, 999)
|
||||
|
||||
return { minDate, maxDate }
|
||||
})
|
||||
|
||||
// Generate list of days for timeline header
|
||||
const timelineDays = computed(() => {
|
||||
const days = []
|
||||
const current = new Date(dateRange.value.minDate)
|
||||
const end = dateRange.value.maxDate
|
||||
const ruDays = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']
|
||||
|
||||
while (current <= end) {
|
||||
days.push({
|
||||
date: new Date(current),
|
||||
dateStr: `${current.getDate().toString().padStart(2, '0')}.${(current.getMonth() + 1).toString().padStart(2, '0')}`,
|
||||
dayOfWeek: ruDays[current.getDay()]
|
||||
})
|
||||
current.setDate(current.getDate() + 1)
|
||||
}
|
||||
return days
|
||||
})
|
||||
|
||||
// Check if task has valid start and end date
|
||||
const hasValidDates = (task) => {
|
||||
return !!(task.data.start_date || task.data.alarm)
|
||||
}
|
||||
|
||||
// Format single date for tooltips
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return 'Не указано'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
// Calculate absolute left offset and width in percent or pixels for Gantt Bar
|
||||
const getBarStyles = (task) => {
|
||||
const minTime = dateRange.value.minDate.getTime()
|
||||
const maxTime = dateRange.value.maxDate.getTime()
|
||||
const totalDuration = maxTime - minTime
|
||||
|
||||
let start = task.data.start_date ? new Date(task.data.start_date).getTime() : Date.now()
|
||||
let end = task.data.alarm ? new Date(task.data.alarm).getTime() : start + 2 * 24 * 60 * 60 * 1000 // 2 days default
|
||||
|
||||
if (start > end) {
|
||||
// Correct order if user reversed dates
|
||||
const tmp = start
|
||||
start = end
|
||||
end = tmp
|
||||
}
|
||||
|
||||
// Boundary checks
|
||||
start = Math.max(start, minTime)
|
||||
end = Math.min(end, maxTime)
|
||||
|
||||
const leftPercent = ((start - minTime) / totalDuration) * 100
|
||||
const widthPercent = ((end - start) / totalDuration) * 100
|
||||
|
||||
// Calculate actual pixel offsets based on timeline width
|
||||
const totalDays = timelineDays.value.length
|
||||
const totalPixels = totalDays * dayWidth
|
||||
|
||||
const leftPx = (leftPercent / 100) * totalPixels
|
||||
const widthPx = Math.max((widthPercent / 100) * totalPixels, 20) // min 20px width
|
||||
|
||||
return {
|
||||
left: `${leftPx}px`,
|
||||
width: `${widthPx}px`
|
||||
}
|
||||
}
|
||||
|
||||
// Style colors based on task status
|
||||
const getBarClasses = (task) => {
|
||||
const state = task.data.state || 'todo'
|
||||
switch (state) {
|
||||
case 'completed':
|
||||
return 'bg-emerald-600/30 border-emerald-500 hover:bg-emerald-500/40 text-emerald-300'
|
||||
case 'in_progress':
|
||||
return 'bg-amber-600/30 border-amber-500 hover:bg-amber-500/40 text-amber-300'
|
||||
case 'review':
|
||||
return 'bg-indigo-600/30 border-indigo-500 hover:bg-indigo-500/40 text-indigo-300'
|
||||
case 'blocked':
|
||||
return 'bg-red-600/30 border-red-500 hover:bg-red-500/40 text-red-300'
|
||||
default:
|
||||
return 'bg-blue-600/30 border-blue-500 hover:bg-blue-500/40 text-blue-300'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (state) => {
|
||||
switch (state) {
|
||||
case 'completed': return '✅'
|
||||
case 'in_progress': return '⏳'
|
||||
case 'review': return '👀'
|
||||
case 'blocked': return '⛔'
|
||||
default: return '📋'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = (state) => {
|
||||
switch (state) {
|
||||
case 'completed': return 'Завершено'
|
||||
case 'in_progress': return 'В работе'
|
||||
case 'review': return 'На проверке'
|
||||
case 'blocked': return 'Заблокировано'
|
||||
default: return 'К выполнению'
|
||||
}
|
||||
}
|
||||
|
||||
const getPriorityLabel = (priority) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'Высокий'
|
||||
case 'low': return 'Низкий'
|
||||
default: return 'Средний'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: #334155;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: #475569;
|
||||
}
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div v-if="userRole !== 'viewer'" class="w-56 bg-slate-900 border-r border-slate-700 flex flex-col shrink-0 z-20 font-sans">
|
||||
<div class="p-3 border-b border-slate-700">
|
||||
<h3 class="text-sm font-bold text-slate-200">Узлы</h3>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
<div v-for="nt in nodeTypes" :key="nt.type" draggable="true" @dragstart="onDragStart($event, nt)"
|
||||
class="p-2 bg-slate-800 rounded cursor-move hover:bg-slate-700 border border-slate-600 text-xs select-none">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ nt.icon }}</span>
|
||||
<div>
|
||||
<div class="font-medium text-slate-200">{{ nt.name }}</div>
|
||||
<div class="text-[10px] text-slate-500">{{ nt.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
userRole: { type: String, default: 'viewer' }
|
||||
})
|
||||
|
||||
const nodeTypes = [
|
||||
{ type: 'text', name: 'Текст', icon: '📝', description: 'Markdown заметка' },
|
||||
{ type: 'file', name: 'Файл', icon: '📄', description: 'Ссылка на файл' },
|
||||
{ type: 'task', name: 'Задача', icon: '✅', description: 'Задача с состоянием' },
|
||||
{ type: 'ai', name: 'AI Узел', icon: '🤖', description: 'LLM запрос' },
|
||||
{ type: 'webhook', name: 'Webhook', icon: '🔗', description: 'HTTP триггер' },
|
||||
{ type: 'git', name: 'Git Синхр', icon: '🔄', description: 'Git синхронизация' }
|
||||
]
|
||||
|
||||
const onDragStart = (event, nodeType) => {
|
||||
event.dataTransfer.setData('application/vueflow', JSON.stringify(nodeType))
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<div class="w-60 bg-slate-900 border-l border-slate-700 flex flex-col shrink-0 z-20 font-sans">
|
||||
<div class="p-3 border-b border-slate-700">
|
||||
<h3 class="text-sm font-bold text-slate-300">Свойства</h3>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
<div v-if="node" class="space-y-3">
|
||||
<!-- SMART & GROW AI Coaching tools -->
|
||||
<div v-if="['text', 'task'].includes(node.type)" class="p-2.5 bg-slate-950/60 border border-indigo-500/30 rounded-lg space-y-2 mb-2">
|
||||
<div class="text-[10px] uppercase font-bold text-indigo-400 flex items-center gap-1">
|
||||
<span>✨</span> AI Коуч-Ассистент
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
<button
|
||||
@click="$emit('apply-smart-ai')"
|
||||
:disabled="isCoaching"
|
||||
class="py-1.5 px-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
|
||||
title="Преобразовать цель в формат SMART при помощи ИИ"
|
||||
>
|
||||
<span v-if="isCoaching && currentCoachAction === 'smart'" class="animate-spin text-xs">🌀</span>
|
||||
<span v-else>🎯</span>
|
||||
<span>SMART</span>
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('apply-grow-ai')"
|
||||
:disabled="isCoaching"
|
||||
class="py-1.5 px-2 bg-teal-650 hover:bg-teal-600 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
|
||||
title="Построить цепочку GROW для проблемы при помощи ИИ"
|
||||
>
|
||||
<span v-if="isCoaching && currentCoachAction === 'grow'" class="animate-spin text-xs">🌀</span>
|
||||
<span v-else>🌱</span>
|
||||
<span>GROW</span>
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('apply-dilts-ai')"
|
||||
:disabled="isCoaching"
|
||||
class="py-1.5 px-2 bg-violet-600 hover:bg-violet-500 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
|
||||
title="Разложить проблему по логическим уровням Дилтса при помощи ИИ"
|
||||
>
|
||||
<span v-if="isCoaching && currentCoachAction === 'dilts'" class="animate-spin text-xs">🌀</span>
|
||||
<span v-else>📐</span>
|
||||
<span>Дилтс</span>
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('apply-eisenhower-ai')"
|
||||
:disabled="isCoaching"
|
||||
class="py-1.5 px-2 bg-amber-600 hover:bg-amber-500 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
|
||||
title="Разложить задачи по матрице Эйзенхауэра при помощи ИИ"
|
||||
>
|
||||
<span v-if="isCoaching && currentCoachAction === 'eisenhower'" class="animate-spin text-xs">🌀</span>
|
||||
<span v-else>📊</span>
|
||||
<span>Эйзенхауэр</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Goal Test Section -->
|
||||
<div v-if="['text', 'task'].includes(node.type)" class="p-2.5 bg-slate-950/60 border border-emerald-500/30 rounded-lg space-y-2 mb-2">
|
||||
<div class="text-[10px] uppercase font-bold text-emerald-400 flex items-center justify-between cursor-pointer select-none" @click="showGoalTest = !showGoalTest">
|
||||
<span class="flex items-center gap-1">⚖️ Тест истинности цели</span>
|
||||
<span>{{ showGoalTest ? '▼' : '▲' }}</span>
|
||||
</div>
|
||||
<div v-if="showGoalTest" class="space-y-2 pt-1.5 border-t border-slate-800">
|
||||
<div v-for="(q, key) in goalTestQuestions" :key="key" class="space-y-1">
|
||||
<div class="flex justify-between items-center text-[9px] text-slate-400 font-medium leading-tight">
|
||||
<span :title="q.full" class="cursor-help border-b border-dashed border-slate-600">{{ q.short }}</span>
|
||||
<span class="text-emerald-400 font-bold shrink-0 ml-1">{{ goalTestScores[key] }}/10</span>
|
||||
</div>
|
||||
<input v-model.number="goalTestScores[key]" type="range" min="1" max="10" class="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-emerald-500" />
|
||||
</div>
|
||||
|
||||
<div class="pt-2 border-t border-slate-800 flex flex-col gap-1.5">
|
||||
<div class="flex justify-between items-center text-xs">
|
||||
<span class="text-slate-400 font-semibold">Итоговый балл:</span>
|
||||
<span class="font-bold" :class="goalTestTotalScore >= 60 ? 'text-emerald-400' : 'text-red-400'">
|
||||
{{ goalTestTotalScore }} / 80
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[9px] p-1.5 rounded leading-normal border" :class="goalTestTotalScore >= 60 ? 'bg-emerald-950/40 text-emerald-300 border-emerald-800/30' : 'bg-red-950/40 text-red-300 border-red-800/30'">
|
||||
{{ goalTestTotalScore >= 60 ? '🟢 Цель истинно Ваша! Отличная мотивация.' : '🔴 Внимание! Возможны проблемы с мотивацией (менее 60 баллов).' }}
|
||||
</div>
|
||||
<button @click="writeGoalTestToNode" class="w-full py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-[10px] font-bold transition">
|
||||
✍️ Записать результат в узел
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Тип узла</label>
|
||||
<select v-model="node.type" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option v-for="nt in nodeTypes" :key="nt.type" :value="nt.type">
|
||||
{{ nt.icon }} {{ nt.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Название</label>
|
||||
<input v-model="node.data.label" @blur="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="node.data.text !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Текст</label>
|
||||
<textarea v-model="node.data.text" @blur="handleChange" rows="4" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none resize-none font-mono"></textarea>
|
||||
</div>
|
||||
<div v-if="node.data.file !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Файл</label>
|
||||
<input v-model="node.data.file" @blur="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="node.type === 'task'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Статус Задачи</label>
|
||||
<select v-model="node.data.state" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option value="todo">📋 К выполнению (To Do)</option>
|
||||
<option value="in_progress">⏳ В работе (In Progress)</option>
|
||||
<option value="review">👀 На проверке (Review)</option>
|
||||
<option value="completed">✅ Завершено (Done)</option>
|
||||
<option value="blocked">⛔ Заблокировано (Blocked)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="node.type === 'task'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Приоритет</label>
|
||||
<select v-model="node.data.priority" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option value="low">🧊 Низкий</option>
|
||||
<option value="medium">⚡ Средний</option>
|
||||
<option value="high">🔥 Высокий</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="node.type === 'task'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Исполнитель</label>
|
||||
<select v-model="node.data.assigned_to" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option value="">Не назначено</option>
|
||||
<option v-for="u in usersList" :key="u.username" :value="u.username">
|
||||
{{ u.displayName || u.username }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="node.type === 'task'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Дата начала</label>
|
||||
<input v-model="node.data.start_date" @blur="handleChange" type="datetime-local" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="node.type === 'task'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Дедлайн (Срок)</label>
|
||||
<input v-model="node.data.alarm" @blur="handleChange" type="datetime-local" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="node.type === 'webhook'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Webhook URL</label>
|
||||
<input v-model="node.data.url" @blur="handleChange" placeholder="https://api.example.com/..." class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="node.type === 'webhook'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Метод</label>
|
||||
<select v-model="node.data.method" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="node.type === 'ai'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">AI Промпт (Инструкция)</label>
|
||||
<textarea v-model="node.data.prompt" @blur="handleChange" rows="3" placeholder="Что должен сделать ИИ? (например: Отредактируй этот текст...)" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none resize-none font-mono"></textarea>
|
||||
</div>
|
||||
<div v-if="node.data.script_id !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Скрипт</label>
|
||||
<input v-model="node.data.script_id" @blur="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none font-mono" />
|
||||
</div>
|
||||
<div v-if="node.type === 'file' || node.data.file !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Ссылка на Карту</label>
|
||||
<select v-model="node.data.file" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none">
|
||||
<option value="">Не выбрано</option>
|
||||
<option v-for="c in canvasesList" :key="c.id" :value="c.id">
|
||||
🗺️ {{ c.name || c.id }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="node.data.prompt !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">AI Запрос</label>
|
||||
<textarea v-model="node.data.prompt" @blur="handleChange" rows="3" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none resize-none font-mono"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Ширина</label>
|
||||
<input v-model.number="node.data.width" @blur="handleChange" type="number" min="100" max="800" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Высота</label>
|
||||
<input v-model.number="node.data.height" @blur="handleChange" type="number" min="50" max="600" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-slate-500 text-center mt-8">
|
||||
Выберите узел для редактирования
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, default: null },
|
||||
usersList: { type: Array, default: () => [] },
|
||||
canvasesList: { type: Array, default: () => [] },
|
||||
isCoaching: { type: Boolean, default: false },
|
||||
currentCoachAction: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change', 'apply-smart-ai', 'apply-grow-ai', 'apply-dilts-ai', 'apply-eisenhower-ai'])
|
||||
|
||||
const nodeTypes = [
|
||||
{ type: 'text', name: 'Текст', icon: '📝' },
|
||||
{ type: 'file', name: 'Файл', icon: '📄' },
|
||||
{ type: 'task', name: 'Задача', icon: '✅' },
|
||||
{ type: 'ai', name: 'AI Узел', icon: '🤖' },
|
||||
{ type: 'webhook', name: 'Webhook', icon: '🔗' },
|
||||
{ type: 'git', name: 'Git Синхр', icon: '🔄' }
|
||||
]
|
||||
|
||||
const handleChange = () => {
|
||||
emit('change')
|
||||
}
|
||||
|
||||
// Goal Validation Test logic
|
||||
const showGoalTest = ref(false)
|
||||
const goalTestScores = ref({
|
||||
q1: 5, q2: 5, q3: 5, q4: 5, q5: 5, q6: 5, q7: 5, q8: 5
|
||||
})
|
||||
|
||||
const goalTestQuestions = {
|
||||
q1: { short: '1. Видение успеха', full: 'Насколько легко и с удовольствием я вижу себя, достигнувшим цель?' },
|
||||
q2: { short: '2. Страсть к процессу', full: 'Эта цель вызывает у меня страсть сама по себе? Мне нравится процесс ее достижения? (Без учета внешних наград)' },
|
||||
q3: { short: '3. Доп. польза', full: 'Достижение этой цели принесет мне дополнительную пользу в жизни/других сферах?' },
|
||||
q4: { short: '4. Баланс жизни', full: 'Смогу ли я достигнуть эту цель, не обделяя другие сферы жизни (время, силы, здоровье)?' },
|
||||
q5: { short: '5. Радость сразу', full: 'Я смогу получать радость и пользу от достижения цели сразу, по ходу процесса?' },
|
||||
q6: { short: '6. Зона контроля', full: 'Действия по достижению этой цели находятся в рамках моей личной собственной деятельности?' },
|
||||
q7: { short: '7. Поддержка окружения', full: 'Мое окружение будет поддерживать меня и мои усилия?' },
|
||||
q8: { short: '8. Ресурсы и автономия', full: 'У меня есть достаточно ресурсов для достижения? Достижима ли она без посторонней помощи?' }
|
||||
}
|
||||
|
||||
const goalTestTotalScore = computed(() => {
|
||||
return Object.values(goalTestScores.value).reduce((a, b) => a + b, 0)
|
||||
})
|
||||
|
||||
// Watch selected node and try to parse existing test scores if they exist
|
||||
watch(() => props.node?.id, () => {
|
||||
showGoalTest.value = false
|
||||
// Reset scores to default
|
||||
goalTestScores.value = { q1: 5, q2: 5, q3: 5, q4: 5, q5: 5, q6: 5, q7: 5, q8: 5 }
|
||||
|
||||
// Try to parse existing score from node text if present
|
||||
if (props.node?.data?.text) {
|
||||
const text = props.node.data.text
|
||||
const match = text.match(/Результат теста цели: (\d+)\/80/)
|
||||
if (match) {
|
||||
const qMatches = {
|
||||
q1: text.match(/Видение успеха:\s*(\d+)/),
|
||||
q2: text.match(/Страсть к процессу:\s*(\d+)/),
|
||||
q3: text.match(/Доп. польза:\s*(\d+)/) || text.match(/Дополнительная польза:\s*(\d+)/),
|
||||
q4: text.match(/Баланс жизни:\s*(\d+)/),
|
||||
q5: text.match(/Радость сразу:\s*(\d+)/),
|
||||
q6: text.match(/Зона контроля:\s*(\d+)/) || text.match(/Личная зона контроля:\s*(\d+)/),
|
||||
q7: text.match(/Поддержка окружения:\s*(\d+)/),
|
||||
q8: text.match(/Ресурсы и автономия:\s*(\d+)/) || text.match(/Хватает ресурсов:\s*(\d+)/)
|
||||
}
|
||||
for (const [key, m] of Object.entries(qMatches)) {
|
||||
if (m) goalTestScores.value[key] = parseInt(m[1])
|
||||
}
|
||||
showGoalTest.value = true
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const writeGoalTestToNode = () => {
|
||||
if (!props.node) return
|
||||
|
||||
const text = props.node.data.text || props.node.data.label || ''
|
||||
|
||||
// Strip previous test results if present
|
||||
let cleanText = text.split('\n\n---\n### ⚖️ Результат теста цели:')[0]
|
||||
cleanText = cleanText.split('\n---\n### ⚖️ Результат теста цели:')[0]
|
||||
|
||||
const verdict = goalTestTotalScore.value >= 60 ? 'Ваша цель' : 'Чужая цель / Риск потери мотивации'
|
||||
const testMarkdown = `\n\n---\n### ⚖️ Результат теста цели: ${goalTestTotalScore.value}/80 (${verdict})\n` +
|
||||
`* **Видение успеха:** ${goalTestScores.value.q1}/10\n` +
|
||||
`* **Страсть к процессу:** ${goalTestScores.value.q2}/10\n` +
|
||||
`* **Доп. польза:** ${goalTestScores.value.q3}/10\n` +
|
||||
`* **Баланс жизни:** ${goalTestScores.value.q4}/10\n` +
|
||||
`* **Радость сразу:** ${goalTestScores.value.q5}/10\n` +
|
||||
`* **Зона контроля:** ${goalTestScores.value.q6}/10\n` +
|
||||
`* **Поддержка окружения:** ${goalTestScores.value.q7}/10\n` +
|
||||
`* **Ресурсы и автономия:** ${goalTestScores.value.q8}/10`
|
||||
|
||||
props.node.data.text = cleanText + testMarkdown
|
||||
handleChange()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface Achievement {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon?: string
|
||||
unlockedAt?: string
|
||||
}
|
||||
|
||||
export const useAchievements = () => {
|
||||
const achievements = ref<Achievement[]>([])
|
||||
const stats = ref({ total: 0, completedTasks: 0 })
|
||||
const pending = ref(false)
|
||||
|
||||
const loadAchievements = async () => {
|
||||
pending.value = true
|
||||
try {
|
||||
const auth = useAuth()
|
||||
const data = await $fetch<{ achievements: Achievement[], stats: { total: number, completedTasks: number } }>('/api/achievements', {
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
achievements.value = data.achievements || []
|
||||
stats.value = data.stats || { total: 0, completedTasks: 0 }
|
||||
} catch (e) {
|
||||
console.error('Failed to load achievements:', e)
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
achievements,
|
||||
stats,
|
||||
pending,
|
||||
loadAchievements
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { jwtDecode } from 'jwt-decode'
|
||||
import { getAuth, signInWithPopup, GoogleAuthProvider, signOut, onAuthStateChanged } from 'firebase/auth'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
username: string
|
||||
displayName?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const token = useCookie<string | null>('xflow_token', {
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
sameSite: 'lax'
|
||||
})
|
||||
|
||||
const isInitializing = useState('auth_initializing', () => true)
|
||||
|
||||
const user = computed<User | null>(() => {
|
||||
if (!token.value) return null
|
||||
try {
|
||||
return jwtDecode<User>(token.value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value && !!user.value)
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
|
||||
const googleSignIn = async () => {
|
||||
const auth = getAuth()
|
||||
const provider = new GoogleAuthProvider()
|
||||
const result = await signInWithPopup(auth, provider)
|
||||
const idToken = await result.user.getIdToken()
|
||||
|
||||
const res = await $fetch<{ token: string; user: User }>('/api/auth/google', {
|
||||
method: 'POST',
|
||||
body: { idToken }
|
||||
})
|
||||
|
||||
token.value = res.token
|
||||
await navigateTo('/')
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
const auth = getAuth()
|
||||
await signOut(auth)
|
||||
} catch (e) {
|
||||
console.warn('[Firebase] Sign out failed or not initialized', e)
|
||||
} finally {
|
||||
token.value = null
|
||||
navigateTo('/login')
|
||||
}
|
||||
}
|
||||
|
||||
const initAuth = () => {
|
||||
if (process.server) return
|
||||
|
||||
const auth = getAuth()
|
||||
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
|
||||
// If user is authenticated in Firebase but we lost our backend token, try to restore
|
||||
if (firebaseUser && !token.value) {
|
||||
try {
|
||||
const idToken = await firebaseUser.getIdToken()
|
||||
const res = await $fetch<{ token: string; user: User }>('/api/auth/google', {
|
||||
method: 'POST',
|
||||
body: { idToken }
|
||||
})
|
||||
token.value = res.token
|
||||
|
||||
if (useRoute().path === '/login') {
|
||||
await navigateTo('/')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to restore backend auth from Firebase', e)
|
||||
}
|
||||
}
|
||||
isInitializing.value = false
|
||||
})
|
||||
return unsubscribe
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
isInitializing,
|
||||
googleSignIn,
|
||||
logout,
|
||||
initAuth
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
export interface CanvasNode {
|
||||
id: string
|
||||
type: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
text?: string
|
||||
file?: string
|
||||
frontmatter?: Record<string, any>
|
||||
state?: 'pending' | 'in_progress' | 'completed'
|
||||
style?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface CanvasEdge {
|
||||
id: string
|
||||
fromNode: string
|
||||
fromSide: string
|
||||
toNode: string
|
||||
toSide: string
|
||||
}
|
||||
|
||||
export interface CanvasData {
|
||||
id?: string
|
||||
name?: string
|
||||
nodes: CanvasNode[]
|
||||
edges: CanvasEdge[]
|
||||
}
|
||||
|
||||
export const useCanvas = () => {
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true
|
||||
})
|
||||
|
||||
const renderMarkdown = (text: string): string => {
|
||||
return md.render(text || '')
|
||||
}
|
||||
|
||||
const parseCanvasJson = (json: any): CanvasData => {
|
||||
if (!json) return { nodes: [], edges: [] }
|
||||
const nodes = (json.nodes || []).map((n: any) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
x: n.x,
|
||||
y: n.y,
|
||||
width: n.width || 300,
|
||||
height: n.height || 200,
|
||||
text: n.text,
|
||||
label: n.label,
|
||||
file: n.file,
|
||||
frontmatter: n.frontmatter,
|
||||
state: n.state || 'pending',
|
||||
priority: n.priority,
|
||||
assigned_to: n.assigned_to,
|
||||
alarm: n.alarm,
|
||||
script_id: n.script_id,
|
||||
prompt: n.prompt,
|
||||
url: n.url,
|
||||
method: n.method,
|
||||
repo: n.repo,
|
||||
branch: n.branch,
|
||||
message: n.message,
|
||||
style: n.style || {}
|
||||
}))
|
||||
|
||||
const edges = (json.edges || []).map((e: any) => ({
|
||||
id: e.id,
|
||||
fromNode: e.fromNode || e.source,
|
||||
fromSide: e.fromSide || 'right',
|
||||
toNode: e.toNode || e.target,
|
||||
toSide: e.toSide || 'left'
|
||||
}))
|
||||
|
||||
return {
|
||||
id: json.id,
|
||||
name: json.name,
|
||||
nodes,
|
||||
edges
|
||||
}
|
||||
}
|
||||
|
||||
const getAuthHeaders = (): Record<string, string> => {
|
||||
const auth = useAuth()
|
||||
const token = auth.token?.value
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
const loadCanvas = async (id: string): Promise<{ canvas: CanvasData, role: string }> => {
|
||||
const data = await $fetch<{ canvas: any, role: string }>(`/api/canvas/${id}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
return {
|
||||
canvas: parseCanvasJson(data.canvas),
|
||||
role: data.role
|
||||
}
|
||||
}
|
||||
|
||||
const updateNodeState = async (nodeId: string, state: string) => {
|
||||
return await $fetch(`/api/nodes/${nodeId}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: { state }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
renderMarkdown,
|
||||
parseCanvasJson,
|
||||
loadCanvas,
|
||||
updateNodeState
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Composable for Git synchronization functionality
|
||||
interface GitRepository {
|
||||
id: string
|
||||
name: string
|
||||
owner: string
|
||||
url: string
|
||||
cloneUrl: string
|
||||
isPrivate: boolean
|
||||
type: 'personal' | 'group'
|
||||
ownerId: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export const useGitSync = () => {
|
||||
const auth = useAuth()
|
||||
const user = computed(() => auth.user)
|
||||
|
||||
const apiFetch = (url: string, opts: any = {}) => $fetch(url, {
|
||||
...opts,
|
||||
headers: { Authorization: `Bearer ${auth.token.value}`, ...opts.headers }
|
||||
})
|
||||
|
||||
const isSyncing = ref(false)
|
||||
const lastSyncTime = ref<string | null>(null)
|
||||
const syncStatus = ref<'idle' | 'syncing' | 'success' | 'error'>('idle')
|
||||
const repository = ref<GitRepository | null>(null)
|
||||
|
||||
// Auto-sync canvas changes
|
||||
const autoSyncCanvas = async (canvasId: string, canvasData: any) => {
|
||||
if (!auth.user || !canvasId) return
|
||||
|
||||
try {
|
||||
syncStatus.value = 'syncing'
|
||||
isSyncing.value = true
|
||||
|
||||
const response = await apiFetch('/api/git/real-sync', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
canvasId,
|
||||
action: 'sync',
|
||||
commitMessage: `Auto-sync: ${new Date().toLocaleString('ru-RU')}`
|
||||
}
|
||||
}) as any
|
||||
|
||||
if (response.success) {
|
||||
syncStatus.value = 'success'
|
||||
lastSyncTime.value = new Date().toISOString()
|
||||
|
||||
// Show success notification
|
||||
console.log('Canvas synchronized with Git:', response.message)
|
||||
} else {
|
||||
syncStatus.value = 'error'
|
||||
console.error('Git sync failed:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
syncStatus.value = 'error'
|
||||
console.error('Auto-sync failed:', error)
|
||||
} finally {
|
||||
isSyncing.value = false
|
||||
// Reset status after delay
|
||||
setTimeout(() => {
|
||||
if (syncStatus.value === 'success') {
|
||||
syncStatus.value = 'idle'
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// Create Git repository for canvas
|
||||
const createRepository = async (canvasId: string, type: 'personal' | 'group' = 'personal') => {
|
||||
if (!auth.user || !canvasId) return
|
||||
|
||||
try {
|
||||
const response = await apiFetch('/api/git/real-sync', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
canvasId,
|
||||
action: 'init'
|
||||
}
|
||||
}) as any
|
||||
|
||||
if (response.success) {
|
||||
repository.value = response.repository
|
||||
console.log('Git repository created:', response.message)
|
||||
return response.repository
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create repository:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Manual sync
|
||||
const manualSync = async (canvasId: string, commitMessage?: string) => {
|
||||
if (!auth.user || !canvasId) return
|
||||
|
||||
try {
|
||||
syncStatus.value = 'syncing'
|
||||
isSyncing.value = true
|
||||
|
||||
const response = await apiFetch('/api/git/real-sync', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
canvasId,
|
||||
action: 'sync',
|
||||
commitMessage: commitMessage || `Ручная синхронизация: ${new Date().toLocaleString('ru-RU')}`
|
||||
}
|
||||
}) as any
|
||||
|
||||
if (response.success) {
|
||||
syncStatus.value = 'success'
|
||||
lastSyncTime.value = new Date().toISOString()
|
||||
console.log('Manual sync completed:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
syncStatus.value = 'error'
|
||||
console.error('Manual sync failed:', error)
|
||||
} finally {
|
||||
isSyncing.value = false
|
||||
setTimeout(() => {
|
||||
if (syncStatus.value === 'success') {
|
||||
syncStatus.value = 'idle'
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if canvas has repository
|
||||
const checkRepository = async (canvasId: string) => {
|
||||
if (!canvasId) return null
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/api/git/repository/${canvasId}`) as any
|
||||
repository.value = response.repository
|
||||
return response.repository
|
||||
} catch (error) {
|
||||
repository.value = null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Get sync status color
|
||||
const syncStatusColor = computed(() => {
|
||||
switch (syncStatus.value) {
|
||||
case 'syncing': return 'text-yellow-400'
|
||||
case 'success': return 'text-green-400'
|
||||
case 'error': return 'text-red-400'
|
||||
default: return 'text-slate-400'
|
||||
}
|
||||
})
|
||||
|
||||
// Get sync status icon
|
||||
const syncStatusIcon = computed(() => {
|
||||
switch (syncStatus.value) {
|
||||
case 'syncing': return '⟳'
|
||||
case 'success': return '✓'
|
||||
case 'error': return '✗'
|
||||
default: return '⚡'
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
isSyncing: readonly(isSyncing),
|
||||
lastSyncTime: readonly(lastSyncTime),
|
||||
syncStatus: readonly(syncStatus),
|
||||
repository: readonly(repository),
|
||||
syncStatusColor,
|
||||
syncStatusIcon,
|
||||
autoSyncCanvas,
|
||||
createRepository,
|
||||
manualSync,
|
||||
checkRepository
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
export const usePush = () => {
|
||||
const isSupported = ref(false)
|
||||
const subscription = ref<PushSubscription | null>(null)
|
||||
|
||||
const checkSupport = () => {
|
||||
isSupported.value = 'serviceWorker' in navigator && 'PushManager' in window
|
||||
}
|
||||
|
||||
const getRegistration = async (): Promise<ServiceWorkerRegistration | null> => {
|
||||
if (!('serviceWorker' in navigator)) return null
|
||||
return navigator.serviceWorker.ready
|
||||
}
|
||||
|
||||
const subscribe = async (): Promise<boolean> => {
|
||||
const registration = await getRegistration()
|
||||
if (!registration) return false
|
||||
|
||||
try {
|
||||
const existing = await registration.pushManager.getSubscription()
|
||||
if (existing) {
|
||||
subscription.value = existing
|
||||
await sendSubscriptionToServer(existing)
|
||||
return true
|
||||
}
|
||||
|
||||
// In production, get VAPID public key from server
|
||||
const vapidKey = await $fetch<string>('/api/push/vapid-public-key').catch(() => null)
|
||||
const applicationServerKey = vapidKey
|
||||
? (urlBase64ToUint8Array(vapidKey) as unknown as ArrayBuffer)
|
||||
: undefined
|
||||
|
||||
const sub = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey
|
||||
})
|
||||
|
||||
subscription.value = sub
|
||||
await sendSubscriptionToServer(sub)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Push subscription failed:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sendSubscriptionToServer = async (sub: PushSubscription) => {
|
||||
await $fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
body: { subscription: sub.toJSON() }
|
||||
})
|
||||
}
|
||||
|
||||
const unsubscribe = async (): Promise<boolean> => {
|
||||
const registration = await getRegistration()
|
||||
if (!registration) return false
|
||||
|
||||
const existing = await registration.pushManager.getSubscription()
|
||||
if (existing) {
|
||||
await existing.unsubscribe()
|
||||
subscription.value = null
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const urlBase64ToUint8Array = (base64String: string): Uint8Array => {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = atob(base64)
|
||||
return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)))
|
||||
}
|
||||
|
||||
const showLocalNotification = (title: string, options?: NotificationOptions) => {
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
navigator.serviceWorker.ready.then(reg => {
|
||||
reg.showNotification(title, {
|
||||
icon: '/pwa-192x192.svg',
|
||||
badge: '/favicon.svg',
|
||||
...options as any
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const requestPermission = async (): Promise<boolean> => {
|
||||
if (!('Notification' in window)) return false
|
||||
const permission = await Notification.requestPermission()
|
||||
return permission === 'granted'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkSupport()
|
||||
})
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
subscription,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
requestPermission,
|
||||
showLocalNotification
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Composable for tracking unsaved changes and preventing accidental data loss
|
||||
export const useUnsavedChanges = () => {
|
||||
const isDirty = ref(false)
|
||||
const lastSavedData = ref<string | null>(null)
|
||||
const currentData = ref<string | null>(null)
|
||||
|
||||
// Track changes by comparing current data with last saved data
|
||||
const trackChanges = (newData: any) => {
|
||||
currentData.value = JSON.stringify(newData)
|
||||
|
||||
if (lastSavedData.value && currentData.value !== lastSavedData.value) {
|
||||
isDirty.value = true
|
||||
console.log('[DEBUG Unsaved] Changed to dirty because data is different')
|
||||
} else if (!lastSavedData.value) {
|
||||
isDirty.value = true
|
||||
console.log('[DEBUG Unsaved] Changed to dirty because no lastSavedData')
|
||||
} else {
|
||||
isDirty.value = false
|
||||
console.log('[DEBUG Unsaved] Not dirty, data is identical')
|
||||
}
|
||||
}
|
||||
|
||||
// Mark data as saved
|
||||
const markAsSaved = (data: any) => {
|
||||
lastSavedData.value = JSON.stringify(data)
|
||||
currentData.value = lastSavedData.value
|
||||
isDirty.value = false
|
||||
console.log('[DEBUG Unsaved] Marked as saved, isDirty = false')
|
||||
}
|
||||
|
||||
// Reset dirty state
|
||||
const resetDirty = () => {
|
||||
isDirty.value = false
|
||||
lastSavedData.value = currentData.value
|
||||
}
|
||||
|
||||
// Warning before leaving page
|
||||
const beforeUnloadHandler = (event: BeforeUnloadEvent) => {
|
||||
if (isDirty.value) {
|
||||
event.preventDefault()
|
||||
event.returnValue = 'У вас есть несохраненные изменения. Вы уверены, что хотите уйти?'
|
||||
return event.returnValue
|
||||
}
|
||||
}
|
||||
|
||||
// Setup warning on mount
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeunload', beforeUnloadHandler)
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', beforeUnloadHandler)
|
||||
})
|
||||
|
||||
return {
|
||||
isDirty: readonly(isDirty),
|
||||
trackChanges,
|
||||
markAsSaved,
|
||||
resetDirty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950 text-slate-100">
|
||||
<nav v-if="isAuthenticated" class="border-b border-slate-800 bg-slate-900/90 backdrop-blur-xl sticky top-0 z-40">
|
||||
<div class="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-10">
|
||||
<NuxtLink to="/" class="text-2xl font-black text-blue-500 tracking-tighter hover:scale-105 transition-transform">XFlow</NuxtLink>
|
||||
|
||||
<!-- Desktop Nav -->
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
<NuxtLink to="/canvases" class="text-sm font-medium text-slate-400 hover:text-white transition flex items-center gap-2">
|
||||
<span>🗺️</span> Проекты
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/tasks" class="text-sm font-medium text-slate-400 hover:text-white transition flex items-center gap-2">
|
||||
<span>✅</span> Задачи
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="isAdmin" to="/admin" class="text-sm font-medium text-red-400/80 hover:text-red-400 transition flex items-center gap-2">
|
||||
<span>🛡️</span> Админ
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/settings" class="text-sm font-medium text-slate-400 hover:text-white transition flex items-center gap-2">
|
||||
<span>⚙️</span> Настройки
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6">
|
||||
<NuxtLink :to="`/profile/${user?.username || user?.id}`" class="flex items-center gap-2 text-sm font-medium text-slate-300 hover:text-blue-400 transition">
|
||||
<div class="w-8 h-8 bg-slate-800 rounded-full flex items-center justify-center border border-slate-700 text-xs">
|
||||
{{ (user?.displayName || user?.username || '?').charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<span class="hidden sm:inline">{{ user?.displayName || user?.username }}</span>
|
||||
</NuxtLink>
|
||||
<button @click="logout" class="p-2 text-slate-500 hover:text-red-400 transition" title="Выйти">
|
||||
<span class="text-xl">🚪</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="flex-1 pb-20 md:pb-0">
|
||||
<slot />
|
||||
</div>
|
||||
<BottomNav />
|
||||
<AchievementOverlay :achievements="userAchievements" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { user, isAuthenticated, isAdmin, logout } = useAuth()
|
||||
const { achievements: userAchievements } = useAchievements()
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
const token = useCookie('xflow_token')
|
||||
|
||||
if (!token.value) return navigateTo('/login')
|
||||
|
||||
try {
|
||||
const base64Url = token.value.split('.')[1]
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const payload = JSON.parse(window.atob(base64))
|
||||
|
||||
if (payload.role !== 'admin') return navigateTo('/')
|
||||
} catch (e) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
const token = useCookie('xflow_token')
|
||||
if (!token.value && to.path !== '/login') {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
@@ -0,0 +1,246 @@
|
||||
<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 mb-8">
|
||||
<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">
|
||||
<select
|
||||
v-if="group.ownerId === auth.user.value?.id || auth.user.value?.role === 'admin'"
|
||||
:value="m.role"
|
||||
@change="(e) => updateMemberRole(m.userId, e.target.value)"
|
||||
class="bg-transparent border-none p-0 focus:ring-0 text-blue-400 cursor-pointer outline-none"
|
||||
:disabled="m.userId === group.ownerId"
|
||||
>
|
||||
<option value="admin">Админ</option>
|
||||
<option value="editor">Редактор</option>
|
||||
<option value="viewer">Зритель</option>
|
||||
</select>
|
||||
<span v-else>{{ m.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="m.role !== 'admin'" @click="removeMember(m.userId)" class="text-slate-600 hover:text-red-400 transition text-xl">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvases 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">Карты группы ({{ group.canvases?.length || 0 }})</h2>
|
||||
<NuxtLink to="/canvases" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm font-bold transition">Управлять в Проектах</NuxtLink>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-800">
|
||||
<div v-for="c in group.canvases" :key="c.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-blue-900/20 rounded-lg flex items-center justify-center text-xl">🗺️</div>
|
||||
<div>
|
||||
<div class="text-slate-200 font-medium">{{ c.name }}</div>
|
||||
<div class="text-[10px] text-slate-500 uppercase tracking-wider">Обновлена: {{ new Date(c.updatedAt).toLocaleDateString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="unassignCanvas(c.id)" class="text-slate-600 hover:text-red-400 transition text-sm font-bold">Отвязать</button>
|
||||
</div>
|
||||
<div v-if="!group.canvases?.length" class="p-8 text-center text-slate-500 italic">
|
||||
В этой группе пока нет карт.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add Member Modal -->
|
||||
<div v-if="showAddMemberModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" @click.self="showAddMemberModal = false">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-3xl p-8 w-full max-w-md shadow-2xl">
|
||||
<h2 class="text-2xl font-bold text-white mb-6 flex items-center gap-2">
|
||||
<span>👤</span> Добавить участника
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Поиск пользователя</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@input="searchUsers"
|
||||
placeholder="Введите имя или email..."
|
||||
class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white outline-none focus:border-blue-500 transition"
|
||||
/>
|
||||
<div v-if="searching" class="absolute right-4 top-3.5">
|
||||
<div class="animate-spin h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
<div v-if="searchResults.length > 0" class="max-h-60 overflow-y-auto bg-slate-800/50 rounded-xl border border-slate-700 divide-y divide-slate-700">
|
||||
<div v-for="u in searchResults" :key="u.id" class="p-3 flex items-center justify-between hover:bg-slate-700/50 transition">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-white">{{ u.username }}</span>
|
||||
<span class="text-[10px] text-slate-500">{{ u.email }}</span>
|
||||
</div>
|
||||
<button
|
||||
@click="addMember(u.id)"
|
||||
class="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-lg transition"
|
||||
>
|
||||
Выбрать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="searchQuery.length >= 2" class="text-center py-4 text-slate-500 text-sm">
|
||||
Пользователи не найдены
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<button @click="showAddMemberModal = false" class="w-full py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl font-bold transition">
|
||||
Закрыть
|
||||
</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 searchQuery = ref('')
|
||||
const searchResults = ref([])
|
||||
const searching = ref(false)
|
||||
|
||||
const searchUsers = async () => {
|
||||
if (searchQuery.value.length < 2) {
|
||||
searchResults.value = []
|
||||
return
|
||||
}
|
||||
searching.value = true
|
||||
try {
|
||||
const data = await $fetch('/api/users/search', {
|
||||
params: { q: searchQuery.value },
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
searchResults.value = data.users || []
|
||||
} catch (e) {
|
||||
console.error('Search failed', e)
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const addMember = async (userId) => {
|
||||
try {
|
||||
await $fetch(`/api/groups/${route.params.id}/add-member`, {
|
||||
method: 'POST',
|
||||
body: { userId, role: 'member' },
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
showAddMemberModal.value = false
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
await fetchGroup()
|
||||
} catch (e) {
|
||||
alert(e.data?.message || 'Ошибка при добавлении')
|
||||
}
|
||||
}
|
||||
|
||||
const updateMemberRole = async (userId, role) => {
|
||||
try {
|
||||
await $fetch(`/api/groups/${route.params.id}/members`, {
|
||||
method: 'PATCH',
|
||||
body: { userId, role },
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
await fetchGroup()
|
||||
} catch (e) {
|
||||
alert('Ошибка при смене роли')
|
||||
}
|
||||
}
|
||||
|
||||
const removeMember = async (userId) => {
|
||||
if (!confirm('Удалить участника из группы?')) return
|
||||
try {
|
||||
await $fetch(`/api/groups/${route.params.id}/members`, {
|
||||
method: 'DELETE',
|
||||
body: { userId },
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
await fetchGroup()
|
||||
} catch (e) {
|
||||
alert('Ошибка при удалении')
|
||||
}
|
||||
}
|
||||
|
||||
const unassignCanvas = async (canvasId) => {
|
||||
if (!confirm('Отвязать карту от группы? Она станет личной картой владельца.')) return
|
||||
try {
|
||||
await $fetch(`/api/canvases/${canvasId}`, {
|
||||
method: 'PATCH',
|
||||
body: { groupId: null },
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
await fetchGroup()
|
||||
} catch (e) {
|
||||
alert('Ошибка при отвязке карты')
|
||||
}
|
||||
}
|
||||
|
||||
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.group.members || []
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch group', e)
|
||||
navigateTo('/admin/groups')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteGroup = async () => {
|
||||
if (!confirm('Вы уверены, что хотите удалить эту группу?')) return
|
||||
try {
|
||||
await $fetch(`/api/groups/${route.params.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
navigateTo('/admin/groups')
|
||||
} catch (e) {
|
||||
alert('Ошибка при удалении')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchGroup)
|
||||
</script>
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950 p-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-slate-100">Управление Группами</h1>
|
||||
<p class="text-slate-400 mt-1">Создание и управление командами, участниками и правами</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition flex items-center gap-2 shadow-lg shadow-blue-900/20"
|
||||
>
|
||||
<span class="text-xl">+</span> Новая Группа
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center h-64">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- Groups Grid -->
|
||||
<div v-else-if="groups.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="bg-slate-900 border border-slate-800 rounded-2xl p-6 hover:border-slate-700 transition group"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="w-12 h-12 bg-blue-900/30 rounded-xl flex items-center justify-center text-blue-400 text-xl font-bold">
|
||||
{{ group.name.charAt(0) }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<span v-if="!group.isPublic" class="px-2 py-1 bg-amber-900/30 text-amber-400 text-[10px] uppercase font-bold rounded-md border border-amber-900/50">Private</span>
|
||||
<span class="px-2 py-1 bg-slate-800 text-slate-400 text-[10px] uppercase font-bold rounded-md">{{ group.memberCount }} members</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-semibold text-slate-100 mb-2">{{ group.name }}</h3>
|
||||
<p class="text-slate-400 text-sm mb-6 line-clamp-2 h-10">{{ group.description || 'Нет описания' }}</p>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t border-slate-800">
|
||||
<div class="flex -space-x-2">
|
||||
<div v-for="i in Math.min(group.memberCount, 3)" :key="i" class="w-8 h-8 rounded-full border-2 border-slate-900 bg-slate-800 flex items-center justify-center text-[10px] text-slate-400 font-bold">
|
||||
U{{ i }}
|
||||
</div>
|
||||
<div v-if="group.memberCount > 3" class="w-8 h-8 rounded-full border-2 border-slate-900 bg-slate-700 flex items-center justify-center text-[10px] text-slate-300 font-bold">
|
||||
+{{ group.memberCount - 3 }}
|
||||
</div>
|
||||
</div>
|
||||
<button @click="openGroupSettings(group)" class="px-4 py-1.5 bg-slate-800 hover:bg-slate-700 text-blue-400 rounded-lg text-sm font-medium transition">Управлять</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="bg-slate-900 border border-slate-800 border-dashed rounded-3xl p-12 text-center">
|
||||
<div class="text-6xl mb-4">👥</div>
|
||||
<h3 class="text-xl font-bold text-slate-200">Групп пока нет</h3>
|
||||
<p class="text-slate-500 mt-2 max-w-sm mx-auto">Создайте свою первую команду, чтобы работать над проектами совместно.</p>
|
||||
<button @click="showCreateModal = true" class="mt-6 px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition">Создать группу</button>
|
||||
</div>
|
||||
|
||||
<!-- Create Group Modal -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm" @click.self="showCreateModal = false">
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md p-6 shadow-2xl">
|
||||
<h2 class="text-2xl font-bold text-slate-100 mb-4 text-center">Новая Группа</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Название группы</label>
|
||||
<input
|
||||
v-model="newGroup.name"
|
||||
type="text"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-100 focus:border-blue-500 outline-none transition"
|
||||
placeholder="Например: Отдел разработки"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Описание</label>
|
||||
<textarea
|
||||
v-model="newGroup.description"
|
||||
rows="3"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-100 focus:border-blue-500 outline-none transition resize-none"
|
||||
placeholder="Чем занимается эта группа?"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 py-2 bg-slate-800/50 p-3 rounded-lg border border-slate-700/50">
|
||||
<input type="checkbox" v-model="newGroup.isPublic" id="isPublic" class="w-5 h-5 rounded border-slate-700 bg-slate-800 text-blue-600 focus:ring-blue-500 cursor-pointer">
|
||||
<label for="isPublic" class="text-slate-300 select-none cursor-pointer text-sm">Сделать группу публичной (доступной всем)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-8">
|
||||
<button
|
||||
@click="showCreateModal = false"
|
||||
class="flex-1 px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl font-bold transition"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
@click="createGroup"
|
||||
class="flex-1 px-4 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition shadow-lg shadow-blue-900/40"
|
||||
:disabled="creating"
|
||||
>
|
||||
{{ creating ? 'Создание...' : 'Создать' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'admin']
|
||||
})
|
||||
|
||||
const auth = useAuth()
|
||||
const groups = ref([])
|
||||
const showCreateModal = ref(false)
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
|
||||
const newGroup = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
isPublic: true
|
||||
})
|
||||
|
||||
const fetchGroups = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await $fetch('/api/groups', {
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
groups.value = data.groups || []
|
||||
} catch (e) {
|
||||
console.error('[Groups Page] Failed to fetch groups', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createGroup = async () => {
|
||||
if (!newGroup.name.trim()) return
|
||||
creating.value = true
|
||||
try {
|
||||
await $fetch('/api/groups', {
|
||||
method: 'POST',
|
||||
body: newGroup,
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
showCreateModal.value = false
|
||||
newGroup.name = ''
|
||||
newGroup.description = ''
|
||||
await fetchGroups()
|
||||
} catch (e) {
|
||||
alert('Ошибка при создании группы: ' + (e.data?.message || e.message))
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openGroupSettings = (group) => {
|
||||
navigateTo(`/admin/groups/${group.id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchGroups()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950 p-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-slate-100">XFlow Admin Panel</h1>
|
||||
<div class="flex items-center gap-2 text-sm text-slate-400">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||
Online
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-3xl font-bold text-blue-400">{{ stats.users }}</div>
|
||||
<div class="text-sm text-slate-400 mt-1">Registered Users</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-3xl font-bold text-emerald-400">{{ stats.canvases }}</div>
|
||||
<div class="text-sm text-slate-400 mt-1">Canvases</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-3xl font-bold text-yellow-400">{{ stats.nodes }}</div>
|
||||
<div class="text-sm text-slate-400 mt-1">Total Nodes</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-3xl font-bold text-purple-400">{{ stats.pushes }}</div>
|
||||
<div class="text-sm text-slate-400 mt-1">Push Subscribers</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex gap-4 mb-6">
|
||||
<NuxtLink
|
||||
to="/admin/groups"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition flex items-center gap-2"
|
||||
>
|
||||
👥 Manage Groups & Teams
|
||||
</NuxtLink>
|
||||
<button
|
||||
@click="refreshData"
|
||||
class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg font-medium transition"
|
||||
>
|
||||
🔄 Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Users -->
|
||||
<div class="lg:col-span-2 bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-slate-200">Users</h2>
|
||||
<button @click="refreshData" class="text-sm text-blue-400 hover:text-blue-300">Refresh</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-800 text-slate-400">
|
||||
<tr>
|
||||
<th class="text-left p-3">Email</th>
|
||||
<th class="text-left p-3">Username</th>
|
||||
<th class="text-left p-3">Role</th>
|
||||
<th class="text-left p-3">ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-slate-300">
|
||||
<tr v-for="user in users" :key="user.id" class="border-t border-slate-800">
|
||||
<td class="p-3">{{ user.email }}</td>
|
||||
<td class="p-3">{{ user.username }}</td>
|
||||
<td class="p-3">
|
||||
<span :class="roleBadge(user.role)">{{ user.role }}</span>
|
||||
</td>
|
||||
<td class="p-3 text-xs text-slate-500 font-mono">{{ user.id }}</td>
|
||||
</tr>
|
||||
<tr v-if="users.length === 0">
|
||||
<td colspan="4" class="p-6 text-center text-slate-500">No users found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvases -->
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-slate-200">Canvases</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-800">
|
||||
<div v-for="canvas in canvases" :key="canvas.id" class="p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<NuxtLink :to="`/canvas/${canvas.id}`" class="text-blue-400 hover:text-blue-300 font-medium">
|
||||
{{ canvas.name }}
|
||||
</NuxtLink>
|
||||
<span class="text-xs text-slate-500">{{ canvas.nodeCount }} nodes</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 mt-1">{{ formatDate(canvas.updatedAt) }}</div>
|
||||
</div>
|
||||
<div v-if="canvases.length === 0" class="p-6 text-center text-slate-500">
|
||||
No canvases yet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'admin']
|
||||
})
|
||||
|
||||
const users = ref([])
|
||||
const canvases = ref([])
|
||||
const stats = reactive({ users: 0, canvases: 0, nodes: 0, pushes: 0 })
|
||||
|
||||
const auth = useAuth()
|
||||
|
||||
const refreshData = async () => {
|
||||
if (!auth.token.value) return
|
||||
|
||||
try {
|
||||
const data = await $fetch('/api/admin/stats', {
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
users.value = data.users || []
|
||||
canvases.value = data.canvases || []
|
||||
stats.users = data.stats?.users || 0
|
||||
stats.canvases = data.stats?.canvases || 0
|
||||
stats.nodes = data.stats?.nodes || 0
|
||||
stats.pushes = data.stats?.pushes || 0
|
||||
} catch (e) {
|
||||
console.error('Failed to load admin data', e)
|
||||
}
|
||||
}
|
||||
|
||||
const roleBadge = (role) => {
|
||||
return role === 'admin'
|
||||
? 'px-2 py-0.5 bg-red-900/50 text-red-300 rounded text-xs'
|
||||
: 'px-2 py-0.5 bg-slate-700 text-slate-300 rounded text-xs'
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return 'N/A'
|
||||
return new Date(date).toLocaleDateString('ru-RU')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Ждем инициализации пользователя если нужно
|
||||
if (!auth.user.value) {
|
||||
await auth.initAuth()
|
||||
}
|
||||
refreshData()
|
||||
})
|
||||
</script>
|
||||
@@ -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.isAuthenticated.value) {
|
||||
// Load user's canvases
|
||||
console.log('Loading canvases for user:', auth.user.value?.email)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,440 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950">
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<!-- Debug Info for Prod -->
|
||||
<div v-if="auth.user.value" class="mb-4 p-2 bg-slate-800 border border-slate-700 rounded text-[10px] font-mono text-slate-500 flex justify-between items-center">
|
||||
<span>Logged in as: <b class="text-blue-400">{{ auth.user.value.email }}</b></span>
|
||||
<span>Internal ID: <b class="text-emerald-400">{{ auth.user.value.id }}</b></span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-2xl font-bold text-slate-100">Мои Карты</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="triggerImport" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg font-medium transition">
|
||||
Импорт .canvas
|
||||
</button>
|
||||
<input ref="importInput" type="file" accept=".canvas,.json" class="hidden" @change="handleImport" />
|
||||
<button @click="openCreateModal" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-bold shadow-lg shadow-blue-900/20 transition flex items-center gap-2">
|
||||
<span>➕</span> Создать
|
||||
</button>
|
||||
</div>
|
||||
</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 = 'team'" :class="tabClass('team')" 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-3">
|
||||
<button @click.stop="duplicateCanvas(canvas.id)" class="text-xs text-slate-400 hover:text-blue-400 transition flex items-center gap-1">
|
||||
<span>📑</span> Дублировать
|
||||
</button>
|
||||
<button @click.stop="exportCanvas(canvas.id)" class="text-xs text-slate-400 hover:text-emerald-400 transition flex items-center gap-1">
|
||||
<span>💾</span> Экспорт
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<button v-if="canDeleteCanvas(canvas)" @click.stop="openSettings(canvas)"
|
||||
class="p-1.5 text-slate-500 hover:text-blue-400 hover:bg-blue-400/10 rounded transition-colors"
|
||||
title="Настройки доступа">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button v-if="canDeleteCanvas(canvas)" @click.stop="deleteCanvas(canvas.id)"
|
||||
class="p-1.5 text-slate-500 hover:text-red-400 hover:bg-red-400/10 rounded transition-colors"
|
||||
title="Удалить карту">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</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/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" @click.self="showCreateModal = false">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
||||
<h2 class="text-xl font-bold text-slate-100 mb-6 flex items-center gap-2">
|
||||
<span>✨</span> Новая Карта
|
||||
</h2>
|
||||
<form @submit.prevent="createCanvas" class="space-y-6">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Название Карты</label>
|
||||
<input v-model="newCanvas.name" required class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white focus:border-blue-500 outline-none transition" placeholder="Моя Awesome Карта" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Видимость</label>
|
||||
<select v-model="newCanvas.visibility" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white focus:border-blue-500 outline-none transition appearance-none">
|
||||
<option value="private">Приватная (только вы)</option>
|
||||
<option value="public">Публичная (любой может просматривать)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="userGroups.length > 0">
|
||||
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Поделиться с Группой (опционально)</label>
|
||||
<select v-model="newCanvas.groupId" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white focus:border-blue-500 outline-none transition appearance-none">
|
||||
<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-4">
|
||||
<button type="submit" class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition shadow-lg shadow-blue-900/20">
|
||||
Создать
|
||||
</button>
|
||||
<button type="button" @click="showCreateModal = false" class="flex-1 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl font-bold transition">
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div v-if="showSettingsModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" @click.self="closeSettings">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
||||
<h2 class="text-xl font-bold text-slate-100 mb-6 flex items-center gap-2">
|
||||
<span>⚙️</span> Настройки доступа
|
||||
</h2>
|
||||
|
||||
<form @submit.prevent="saveSettings" class="space-y-6">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Название Карты</label>
|
||||
<input
|
||||
v-model="editCanvas.name"
|
||||
required
|
||||
class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white focus:border-blue-500 outline-none transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Уровень доступа</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="editCanvas.isPublic = false"
|
||||
:class="!editCanvas.isPublic ? 'bg-blue-600 border-blue-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-400'"
|
||||
class="px-4 py-2 border rounded-xl text-sm font-medium transition"
|
||||
>
|
||||
Приватная
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="editCanvas.isPublic = true"
|
||||
:class="editCanvas.isPublic ? 'bg-emerald-600 border-emerald-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-400'"
|
||||
class="px-4 py-2 border rounded-xl text-sm font-medium transition"
|
||||
>
|
||||
Публичная
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-slate-500 mt-2">
|
||||
{{ editCanvas.isPublic ? 'Любой пользователь сможет просматривать эту карту.' : 'Карта доступна только вам и членам выбранной группы.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="userGroups.length > 0">
|
||||
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Принадлежность Группе</label>
|
||||
<select v-model="editCanvas.groupId" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white focus:border-blue-500 outline-none transition appearance-none">
|
||||
<option :value="null">Без группы (Личная)</option>
|
||||
<option v-for="group in userGroups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="submit" class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition shadow-lg shadow-blue-900/20" :disabled="saving">
|
||||
{{ saving ? 'Сохранение...' : 'Сохранить' }}
|
||||
</button>
|
||||
<button type="button" @click="closeSettings" class="flex-1 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl font-bold transition">
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
})
|
||||
|
||||
const auth = useAuth()
|
||||
const user = computed(() => auth.user.value)
|
||||
const authFetch = async (url, opts = {}) => {
|
||||
try {
|
||||
return await $fetch(url, {
|
||||
...opts,
|
||||
headers: { Authorization: `Bearer ${auth.token.value}`, ...opts.headers }
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.response?.status === 401) {
|
||||
auth.logout()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const canvases = ref([])
|
||||
const userGroups = ref([])
|
||||
const showCreateModal = ref(false)
|
||||
const importInput = ref(null)
|
||||
const activeTab = ref('personal')
|
||||
|
||||
const newCanvas = reactive({
|
||||
name: '',
|
||||
visibility: 'private',
|
||||
groupId: ''
|
||||
})
|
||||
|
||||
const openCreateModal = () => {
|
||||
newCanvas.name = ''
|
||||
newCanvas.visibility = 'private'
|
||||
newCanvas.groupId = ''
|
||||
showSettingsModal.value = false
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
const showSettingsModal = ref(false)
|
||||
const saving = ref(false)
|
||||
const editCanvas = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
isPublic: false,
|
||||
groupId: null
|
||||
})
|
||||
|
||||
const openSettings = (canvas) => {
|
||||
editCanvas.id = canvas.id
|
||||
editCanvas.name = canvas.name
|
||||
editCanvas.isPublic = canvas.isPublic
|
||||
editCanvas.groupId = canvas.groupId
|
||||
showSettingsModal.value = true
|
||||
}
|
||||
|
||||
const closeSettings = () => {
|
||||
showSettingsModal.value = false
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await authFetch(`/api/canvases/${editCanvas.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
name: editCanvas.name,
|
||||
isPublic: editCanvas.isPublic,
|
||||
groupId: editCanvas.groupId
|
||||
}
|
||||
})
|
||||
await loadData()
|
||||
closeSettings()
|
||||
} catch (error) {
|
||||
alert('Ошибка при сохранении: ' + (error.data?.message || error.message))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const currentUserId = computed(() => 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
|
||||
if (!userId) return []
|
||||
|
||||
switch (activeTab.value) {
|
||||
case 'personal':
|
||||
return canvases.value.filter(c => c.ownerId === userId && (!c.groupId || c.groupId === ''))
|
||||
case 'team':
|
||||
return canvases.value.filter(c => c.groupId && c.groupId !== '')
|
||||
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 triggerImport = () => {
|
||||
importInput.value?.click()
|
||||
}
|
||||
|
||||
const handleImport = async (event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text)
|
||||
const name = file.name.replace(/\.canvas$|\.json$/, '')
|
||||
const payload = {
|
||||
name,
|
||||
nodes: data.nodes || [],
|
||||
edges: data.edges || [],
|
||||
isPublic: false
|
||||
}
|
||||
const result = await authFetch('/api/canvas', { method: 'POST', body: payload })
|
||||
await loadData()
|
||||
navigateTo(`/canvas/${result.canvas.id}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to import canvas:', error)
|
||||
alert('Ошибка импорта: проверьте формат файла')
|
||||
} finally {
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
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(() => {
|
||||
if (auth.isAuthenticated.value) {
|
||||
loadData()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => auth.isAuthenticated.value, (isAuth) => {
|
||||
if (isAuth) 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,74 @@
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] flex flex-col items-center justify-center p-4 md:p-8">
|
||||
<div v-if="!isAuthenticated" class="text-center max-w-md px-4">
|
||||
<h1 class="text-5xl md:text-6xl font-black text-blue-500 mb-6 tracking-tight">XFlow</h1>
|
||||
<p class="text-slate-400 text-lg mb-10 leading-relaxed">Визуальное управление задачами в формате Obsidian Canvas.</p>
|
||||
<NuxtLink
|
||||
to="/login"
|
||||
class="w-full md:w-auto inline-block px-10 py-4 bg-blue-600 hover:bg-blue-500 text-white rounded-2xl font-bold text-lg shadow-xl shadow-blue-900/40 transition-all transform active:scale-95"
|
||||
>
|
||||
Начать работу
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-else class="w-full max-w-6xl">
|
||||
<div class="flex items-center justify-between mb-8 px-2">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-white">Дашборд</h1>
|
||||
<div class="md:hidden w-10 h-10 bg-slate-800 rounded-full flex items-center justify-center text-xl">✨</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<NuxtLink
|
||||
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">Проекты</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">{{ 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">{{ homeStats.achievements }} разблокировано</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ClientOnly>
|
||||
<InstallPWA />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { isAuthenticated, token } = useAuth()
|
||||
const homeStats = ref({
|
||||
canvases: 0,
|
||||
tasks: { total: 0, completed: 0 },
|
||||
achievements: 0
|
||||
})
|
||||
const loadingStats = ref(true)
|
||||
|
||||
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(() => {
|
||||
fetchHomeStats()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<!-- Splash / Loader while checking Firebase session -->
|
||||
<div v-if="isInitializing" class="text-center animate-pulse">
|
||||
<div class="text-6xl mb-4">🚀</div>
|
||||
<div class="text-slate-400 font-medium">Проверка авторизации...</div>
|
||||
</div>
|
||||
|
||||
<!-- Login UI -->
|
||||
<div v-else 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.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, isAuthenticated, isInitializing } = 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(() => {
|
||||
if (isAuthenticated.value) {
|
||||
navigateTo('/')
|
||||
}
|
||||
})
|
||||
|
||||
watch(isAuthenticated, (val) => {
|
||||
if (val) navigateTo('/')
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,356 @@
|
||||
<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 = async (url, opts = {}) => {
|
||||
try {
|
||||
return await $fetch(url, {
|
||||
...opts,
|
||||
headers: { Authorization: `Bearer ${auth.token.value}`, ...opts.headers }
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.response?.status === 401) {
|
||||
auth.logout()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Redirect to pretty URL if using UID but username exists
|
||||
if (profile.value.user?.username && route.params.id === profile.value.userId) {
|
||||
navigateTo(`/profile/${profile.value.user.username}`, { replace: true })
|
||||
}
|
||||
|
||||
if (isOwnProfile.value) {
|
||||
const [groupsData, allGroupsData] = await Promise.all([
|
||||
authFetch('/api/user/groups'),
|
||||
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(() => {
|
||||
if (auth.isAuthenticated.value) {
|
||||
loadProfile()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => auth.isAuthenticated.value, (isAuth) => {
|
||||
if (isAuth) 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,208 @@
|
||||
<template>
|
||||
<div class="max-w-xl mx-auto p-6">
|
||||
<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">
|
||||
<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 { 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()
|
||||
permissionGranted.value = granted
|
||||
if (granted) {
|
||||
await pushSubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
const subscribe = async () => {
|
||||
await pushSubscribe()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
permissionGranted.value = Notification.permission === 'granted'
|
||||
await Promise.all([
|
||||
loadAchievements(),
|
||||
loadProfile()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-900 text-slate-200">
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<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">
|
||||
<div class="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="tasks.length === 0" class="flex flex-col items-center justify-center h-64 bg-slate-800 rounded-xl border border-slate-700">
|
||||
<span class="text-4xl mb-4">📭</span>
|
||||
<h3 class="text-xl font-medium text-white mb-2">У вас пока нет задач</h3>
|
||||
<p class="text-slate-400">Вам не назначено ни одной задачи в проектах.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<!-- Kanban Columns -->
|
||||
<div v-for="col in columns" :key="col.id" class="bg-slate-800 rounded-xl border border-slate-700 flex flex-col max-h-[80vh]">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between sticky top-0 bg-slate-800 rounded-t-xl z-10">
|
||||
<h3 class="font-bold text-white flex items-center gap-2">
|
||||
<span :class="col.color">{{ col.icon }}</span> {{ col.title }}
|
||||
</h3>
|
||||
<span class="bg-slate-700 text-slate-300 text-xs px-2 py-1 rounded-full font-medium">
|
||||
{{ getTasksForColumn(col.id).length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-4 flex-1 overflow-y-auto space-y-4 custom-scrollbar min-h-[200px]">
|
||||
<ClientOnly>
|
||||
<draggable
|
||||
v-model="columnsData[col.id]"
|
||||
group="tasks"
|
||||
item-key="id"
|
||||
class="space-y-4 min-h-[200px]"
|
||||
@change="(evt) => onTaskMove(evt, col.id)"
|
||||
>
|
||||
<template #item="{ element: task }">
|
||||
<div
|
||||
class="bg-slate-900 border border-slate-600 rounded-lg p-4 shadow-sm hover:border-blue-500 transition-colors cursor-pointer group"
|
||||
@click="openTask(task)"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="text-xs font-medium px-2 py-0.5 rounded" :class="getPriorityClass(task)">
|
||||
{{ getPriorityLabel(task) }}
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 font-mono">{{ task.id.split('-')[1] || task.id }}</div>
|
||||
</div>
|
||||
|
||||
<h4 class="font-medium text-white mb-2 line-clamp-2">{{ getTaskTitle(task) }}</h4>
|
||||
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="text-xs text-slate-400 flex items-center gap-1">
|
||||
<span>🗺️</span>
|
||||
<span class="truncate max-w-[120px]">{{ task.canvasName }}</span>
|
||||
</div>
|
||||
<div v-if="task.alarm || task.data?.alarm" class="text-xs text-orange-400 flex items-center gap-1" :title="new Date(task.alarm || task.data?.alarm).toLocaleString('ru-RU')">
|
||||
<span>⏰</span>
|
||||
<span>{{ formatShortDate(task.alarm || task.data?.alarm) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</ClientOnly>
|
||||
|
||||
<div v-if="columnsData[col.id].length === 0" class="text-center py-6 border-2 border-dashed border-slate-700 rounded-lg pointer-events-none">
|
||||
<span class="text-slate-500 text-sm">Нет задач</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, reactive } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
const auth = useAuth()
|
||||
const loading = ref(true)
|
||||
const tasks = ref([])
|
||||
const columnsData = reactive({
|
||||
todo: [],
|
||||
in_progress: [],
|
||||
review: [],
|
||||
blocked: [],
|
||||
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' },
|
||||
{ id: 'review', title: 'На проверке', icon: '👀', color: 'text-purple-400' },
|
||||
{ id: 'blocked', title: 'Заблокировано', icon: '⛔', color: 'text-red-400' },
|
||||
{ id: 'completed', title: 'Завершено', icon: '✅', color: 'text-emerald-400' }
|
||||
]
|
||||
|
||||
// Fetch all tasks for current user
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
const data = await $fetch('/api/tasks', {
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
tasks.value = data.tasks || []
|
||||
|
||||
// Split tasks into columns
|
||||
columns.forEach(col => {
|
||||
columnsData[col.id] = tasks.value.filter(t => {
|
||||
const taskState = t.state || t.data?.state || 'todo'
|
||||
return taskState === col.id
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to load tasks', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCanvases = async () => {
|
||||
try {
|
||||
const data = await $fetch('/api/canvas', {
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
availableCanvases.value = data.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
|
||||
console.log(`Moving task ${task.id} to ${newStatus}`)
|
||||
|
||||
try {
|
||||
await $fetch('/api/tasks/update-status', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
taskId: task.id,
|
||||
canvasId: task.canvasId,
|
||||
newState: newStatus
|
||||
},
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to update task status', e)
|
||||
// Rollback on error? (In a production app, we should)
|
||||
alert('Не удалось обновить статус задачи на сервере')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getTasksForColumn = (state) => {
|
||||
return tasks.value.filter(t => {
|
||||
const taskState = t.state || t.data?.state || 'todo'
|
||||
return taskState === state
|
||||
})
|
||||
}
|
||||
|
||||
const getTaskTitle = (task) => {
|
||||
if (task.label) return task.label
|
||||
const text = task.text || task.data?.text
|
||||
if (text) {
|
||||
const lines = text.split('\n')
|
||||
const firstLine = lines.find(l => l.trim().startsWith('# ')) || lines.find(l => l.trim())
|
||||
return firstLine ? firstLine.replace('# ', '').trim().slice(0, 50) : 'Без названия'
|
||||
}
|
||||
return 'Задача'
|
||||
}
|
||||
|
||||
const getPriorityClass = (task) => {
|
||||
const priority = task.priority || task.data?.priority
|
||||
if (priority === 'high') return 'bg-red-900/50 text-red-300'
|
||||
if (priority === 'low') return 'bg-slate-700 text-slate-300'
|
||||
return 'bg-blue-900/50 text-blue-300' // medium
|
||||
}
|
||||
|
||||
const getPriorityLabel = (task) => {
|
||||
const priority = task.priority || task.data?.priority
|
||||
if (priority === 'high') return 'Высокий'
|
||||
if (priority === 'low') return 'Низкий'
|
||||
return 'Средний'
|
||||
}
|
||||
|
||||
const formatShortDate = (dateStr) => {
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
const openTask = (task) => {
|
||||
navigateTo(`/canvas/${task.canvasId}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.user.value) {
|
||||
await auth.init()
|
||||
}
|
||||
await Promise.all([
|
||||
fetchTasks(),
|
||||
fetchCanvases()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #475569 transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: #475569;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { initializeApp, getApps } from 'firebase/app'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: config.public.firebaseApiKey,
|
||||
authDomain: config.public.firebaseAuthDomain,
|
||||
projectId: config.public.firebaseProjectId,
|
||||
appId: config.public.firebaseAppId,
|
||||
messagingSenderId: config.public.firebaseMessagingSenderId
|
||||
}
|
||||
|
||||
if (!firebaseConfig.apiKey) {
|
||||
console.warn('[Firebase] API key not configured. Google Sign-In will not work.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!getApps().length) {
|
||||
initializeApp(firebaseConfig)
|
||||
console.log('[Firebase] Initialized')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
export default defineNuxtPlugin(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
console.log('[PWA] Service Worker ready:', registration.scope)
|
||||
})
|
||||
|
||||
// Handle push messages when app is open
|
||||
navigator.serviceWorker.addEventListener('message', event => {
|
||||
if (event.data?.type === 'PUSH_RECEIVED') {
|
||||
const { title, body } = event.data.payload || {}
|
||||
// Local alarm: vibrate and show notification
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate([200, 100, 200])
|
||||
}
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification(title || 'XFlow Alarm', {
|
||||
body: body || 'Alarm triggered',
|
||||
icon: '/pwa-192x192.svg',
|
||||
badge: '/favicon.svg',
|
||||
...({ vibrate: [200, 100, 200] } as any)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#0f172a"/>
|
||||
<text x="16" y="22" font-size="18" text-anchor="middle" fill="#3b82f6" font-family="system-ui">N</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "XFlow",
|
||||
"short_name": "XFlow",
|
||||
"description": "XFlow Obsidian Canvas PWA",
|
||||
"lang": "ru",
|
||||
"categories": ["productivity", "business"],
|
||||
"theme_color": "#0f172a",
|
||||
"background_color": "#0f172a",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/pwa-192x192.svg",
|
||||
"sizes": "192x192",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/pwa-512x512.svg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/maskable-icon-512x512.svg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" fill="#0f172a"/>
|
||||
<text x="256" y="350" font-size="280" text-anchor="middle" fill="#3b82f6" font-family="system-ui" font-weight="bold">N</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 273 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
|
||||
<rect width="192" height="192" rx="24" fill="#0f172a"/>
|
||||
<text x="96" y="128" font-size="96" text-anchor="middle" fill="#3b82f6" font-family="system-ui" font-weight="bold">N</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 279 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="64" fill="#0f172a"/>
|
||||
<text x="256" y="350" font-size="280" text-anchor="middle" fill="#3b82f6" font-family="system-ui" font-weight="bold">N</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 281 B |
@@ -0,0 +1,97 @@
|
||||
// Transform Obsidian canvas to Vue Flow elements
|
||||
export const transformToFlowElements = (canvasData: any) => {
|
||||
const nodes = (canvasData.nodes || []).map((node: any) => ({
|
||||
id: node.id,
|
||||
type: node.type === 'file' ? 'file' : node.type === 'ai' ? 'ai' : node.type === 'git' ? 'git' : node.type === 'task' ? 'task' : node.type === 'webhook' ? 'webhook' : 'text',
|
||||
position: { x: Number(node.x) || 0, y: Number(node.y) || 0 },
|
||||
data: {
|
||||
...node,
|
||||
width: node.width || 300,
|
||||
height: node.height || 200,
|
||||
label: node.label || node.text?.slice(0, 20) || node.file || 'Node',
|
||||
// Ensure specific fields exist so the properties panel shows them
|
||||
...(node.type === 'task' && {
|
||||
text: node.text ?? '',
|
||||
state: node.state ?? 'todo',
|
||||
priority: node.priority ?? 'medium',
|
||||
assigned_to: node.assigned_to ?? '',
|
||||
start_date: node.start_date ?? '',
|
||||
alarm: node.alarm ?? '',
|
||||
script_id: node.script_id ?? ''
|
||||
}),
|
||||
...(node.type === 'text' && { text: node.text ?? '' }),
|
||||
...(node.type === 'file' && { file: node.file ?? '' }),
|
||||
...(node.type === 'ai' && { prompt: node.prompt ?? '' }),
|
||||
...(node.type === 'webhook' && {
|
||||
script_id: node.script_id ?? '',
|
||||
url: node.url ?? '',
|
||||
method: node.method ?? 'POST'
|
||||
}),
|
||||
...(node.type === 'git' && {
|
||||
repo: node.repo ?? '',
|
||||
branch: node.branch ?? 'main',
|
||||
message: node.message ?? ''
|
||||
})
|
||||
},
|
||||
style: {
|
||||
width: `${node.width || 300}px`,
|
||||
height: `${node.height || 200}px`,
|
||||
background: node.style?.background || '#1e293b',
|
||||
border: `1px solid ${node.style?.border || '#334155'}`,
|
||||
borderRadius: '8px'
|
||||
}
|
||||
}))
|
||||
|
||||
const edges = (canvasData.edges || []).map((edge: any) => ({
|
||||
id: edge.id,
|
||||
source: edge.fromNode,
|
||||
target: edge.toNode,
|
||||
sourceHandle: edge.fromSide,
|
||||
targetHandle: edge.toSide,
|
||||
animated: true,
|
||||
style: { stroke: '#475569', strokeWidth: 2 }
|
||||
}))
|
||||
|
||||
return [...nodes, ...edges]
|
||||
}
|
||||
|
||||
// Transform Vue Flow elements back to Obsidian canvas
|
||||
export const transformToObsidianCanvas = (flowElements: any[]) => {
|
||||
const nodes = flowElements.filter(el => el.position !== undefined).map(node => ({
|
||||
id: node.id,
|
||||
type: node.type === 'file' ? 'file' : node.type === 'task' ? 'task' : node.type === 'ai' ? 'ai' : node.type === 'webhook' ? 'webhook' : node.type === 'git' ? 'git' : 'text',
|
||||
x: Math.round(Number(node.position.x) || 0),
|
||||
y: Math.round(Number(node.position.y) || 0),
|
||||
width: node.data.width || 300,
|
||||
height: node.data.height || 200,
|
||||
text: node.data.text,
|
||||
label: node.data.label,
|
||||
file: node.data.file,
|
||||
state: node.data.state,
|
||||
priority: node.data.priority,
|
||||
assigned_to: node.data.assigned_to,
|
||||
start_date: node.data.start_date,
|
||||
alarm: node.data.alarm,
|
||||
script_id: node.data.script_id,
|
||||
prompt: node.data.prompt,
|
||||
url: node.data.url,
|
||||
method: node.data.method,
|
||||
repo: node.data.repo,
|
||||
branch: node.data.branch,
|
||||
message: node.data.message,
|
||||
style: {
|
||||
background: node.style?.background || '#1e293b',
|
||||
border: node.style?.border?.replace('1px solid ', '') || '#334155'
|
||||
}
|
||||
}))
|
||||
|
||||
const edges = flowElements.filter(el => el.source && el.target).map(edge => ({
|
||||
id: edge.id,
|
||||
fromNode: edge.source,
|
||||
fromSide: edge.sourceHandle || 'right',
|
||||
toNode: edge.target,
|
||||
toSide: edge.targetHandle || 'left'
|
||||
}))
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
export default defineNuxtConfig({
|
||||
future: { compatibilityVersion: 4 },
|
||||
devtools: { enabled: true },
|
||||
|
||||
modules: [
|
||||
'@nuxtjs/tailwindcss',
|
||||
'@vite-pwa/nuxt',
|
||||
'@vueuse/nuxt'
|
||||
],
|
||||
|
||||
experimental: {
|
||||
appManifest: false
|
||||
},
|
||||
|
||||
css: ['~/assets/css/main.css'],
|
||||
|
||||
tailwindcss: {
|
||||
cssPath: '~/assets/css/main.css'
|
||||
},
|
||||
|
||||
app: {
|
||||
head: {
|
||||
title: 'XFlow',
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
||||
{ rel: 'manifest', href: '/manifest.json' }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
pwa: {
|
||||
injectRegister: 'script',
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
suppressWarnings: true
|
||||
}
|
||||
},
|
||||
|
||||
nitro: {
|
||||
experimental: {
|
||||
wasm: true
|
||||
},
|
||||
externals: {
|
||||
external: ['@prisma/client', '@prisma/adapter-pg', 'pg']
|
||||
}
|
||||
},
|
||||
|
||||
routeRules: {
|
||||
'/**': {
|
||||
headers: {
|
||||
'Cross-Origin-Opener-Policy': 'same-origin-allow-popups'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
jwtSecret: process.env.NUXT_JWT_SECRET || 'your-super-secret-jwt-key',
|
||||
firebaseApiKey: process.env.NUXT_FIREBASE_API_KEY || process.env.FIREBASE_API_KEY || '',
|
||||
giteaUrl: process.env.GITEA_URL || '',
|
||||
giteaToken: process.env.GITEA_TOKEN || '',
|
||||
giteaOrg: process.env.GITEA_ORG || '',
|
||||
giteaMindmapRepo: process.env.GITEA_MINDMAP_REPO || '',
|
||||
giteaProjectRepo: process.env.GITEA_PROJECT_REPO || '',
|
||||
deepseekApiKey: process.env.DEEPSEEK_API_KEY || '',
|
||||
qwenApiKey: '',
|
||||
public: {
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api',
|
||||
firebaseApiKey: process.env.NUXT_PUBLIC_FIREBASE_API_KEY || process.env.FIREBASE_API_KEY || '',
|
||||
firebaseAuthDomain: process.env.NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN || process.env.FIREBASE_AUTH_DOMAIN || '',
|
||||
firebaseProjectId: process.env.NUXT_PUBLIC_FIREBASE_PROJECT_ID || process.env.FIREBASE_PROJECT_ID || '',
|
||||
firebaseAppId: process.env.NUXT_PUBLIC_FIREBASE_APP_ID || process.env.FIREBASE_APP_ID || '',
|
||||
firebaseMessagingSenderId: process.env.NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID || process.env.FIREBASE_MESSAGING_SENDER_ID || ''
|
||||
}
|
||||
},
|
||||
|
||||
compatibilityDate: '2025-04-23'
|
||||
})
|
||||
Generated
+19226
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "xflow-nuxt",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.12.0",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@vite-pwa/nuxt": "^0.10.0",
|
||||
"@vue-flow/background": "^1.3.2",
|
||||
"@vue-flow/controls": "^1.1.2",
|
||||
"@vue-flow/core": "^1.41.0",
|
||||
"@vue-flow/minimap": "^1.5.0",
|
||||
"@vueuse/nuxt": "^10.11.0",
|
||||
"firebase": "^12.12.1",
|
||||
"firebase-admin": "^13.8.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"nuxt": "^3.16.0",
|
||||
"pg": "^8.12.0",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.5.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/markdown-it": "^14.1.0",
|
||||
"prisma": "^7.8.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
// Призма 7 иногда требует явной загрузки .env для некоторых команд
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env.DATABASE_URL || "postgresql://admin:secure_master_pass@localhost:5432/xflow_crm_db",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../node_modules/.prisma/client"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
username String @unique
|
||||
password String
|
||||
role String @default("user")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
activities Activity[]
|
||||
canvases Canvas[]
|
||||
following Follow[] @relation("follower")
|
||||
followers Follow[] @relation("following")
|
||||
groupMembers GroupMember[]
|
||||
profile Profile?
|
||||
noteLists NoteList[]
|
||||
}
|
||||
|
||||
model Profile {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
displayName String
|
||||
bio String @default("")
|
||||
avatar String @default("")
|
||||
canvasCount Int @default(0)
|
||||
followerCount Int @default(0)
|
||||
followingCount Int @default(0)
|
||||
joinedAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Canvas {
|
||||
id String @id
|
||||
name String
|
||||
ownerId String
|
||||
groupId String?
|
||||
isPublic Boolean @default(false)
|
||||
nodes Json @default("[]")
|
||||
edges Json @default("[]")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
group Group? @relation(fields: [groupId], references: [id])
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Group {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String @default("")
|
||||
isPublic Boolean @default(true)
|
||||
ownerId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
controlType String? @default("REMOTE")
|
||||
canvases Canvas[]
|
||||
members GroupMember[]
|
||||
Kiosk Kiosk[]
|
||||
}
|
||||
|
||||
model GroupMember {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
groupId String
|
||||
role String @default("member")
|
||||
joinedAt DateTime @default(now())
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, groupId])
|
||||
}
|
||||
|
||||
model Activity {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
action String
|
||||
targetId String
|
||||
targetType String
|
||||
timestamp DateTime @default(now())
|
||||
metadata String?
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Follow {
|
||||
id String @id @default(cuid())
|
||||
followerId String
|
||||
followingId String
|
||||
since DateTime @default(now())
|
||||
follower User @relation("follower", fields: [followerId], references: [id], onDelete: Cascade)
|
||||
following User @relation("following", fields: [followingId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([followerId, followingId])
|
||||
}
|
||||
|
||||
model Kiosk {
|
||||
id String @id
|
||||
name String
|
||||
location String?
|
||||
groupId String
|
||||
status String @default("offline")
|
||||
lastSeen DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
Group Group @relation(fields: [groupId], references: [id])
|
||||
}
|
||||
|
||||
|
||||
model NoteList {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
category String @default("general")
|
||||
description String @default("")
|
||||
color String?
|
||||
icon String?
|
||||
ownerId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
items ListItem[]
|
||||
|
||||
@@index([ownerId])
|
||||
@@index([category])
|
||||
}
|
||||
|
||||
model ListItem {
|
||||
id String @id @default(cuid())
|
||||
listId String
|
||||
title String
|
||||
description String @default("")
|
||||
isCompleted Boolean @default(false)
|
||||
status String @default("todo")
|
||||
priority String @default("normal")
|
||||
metadata Json?
|
||||
dueDate DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
list NoteList @relation(fields: [listId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([listId])
|
||||
@@index([status])
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { readdirSync, readFileSync } from 'fs'
|
||||
import { join, resolve } from 'path'
|
||||
import { Pool } from 'pg'
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
|
||||
const connectionString = process.env.DATABASE_URL
|
||||
const pool = new Pool({ connectionString })
|
||||
const adapter = new PrismaPg(pool)
|
||||
|
||||
const prisma = new PrismaClient({ adapter })
|
||||
|
||||
async function main() {
|
||||
console.log('Ensuring system user exists...')
|
||||
let systemUser = await prisma.user.findUnique({ where: { id: 'system' } })
|
||||
if (!systemUser) {
|
||||
systemUser = await prisma.user.create({
|
||||
data: {
|
||||
id: 'system',
|
||||
username: 'system',
|
||||
email: 'system@localhost',
|
||||
password: 'none',
|
||||
role: 'admin'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
console.log('Seeding initial maps from .canvas files...')
|
||||
|
||||
const rootDir = resolve(process.cwd())
|
||||
const files = readdirSync(rootDir).filter(f => f.endsWith('.canvas'))
|
||||
|
||||
// We'll assign these to the first admin user
|
||||
const adminUser = await prisma.user.findFirst({ where: { role: 'admin' } })
|
||||
const ownerId = adminUser?.id || '1'
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = join(rootDir, file)
|
||||
const data = JSON.parse(readFileSync(filePath, 'utf-8'))
|
||||
const id = file.replace('.canvas', '')
|
||||
|
||||
const existing = await prisma.canvas.findUnique({ where: { id } })
|
||||
|
||||
if (!existing) {
|
||||
await prisma.canvas.create({
|
||||
data: {
|
||||
id,
|
||||
name: data.name || id,
|
||||
ownerId,
|
||||
isPublic: true, // Make public so anyone can view/edit
|
||||
nodes: data.nodes || [],
|
||||
edges: data.edges || []
|
||||
}
|
||||
})
|
||||
console.log(`✅ Seeded map: ${id}`)
|
||||
} else {
|
||||
console.log(`⏩ Map ${id} already exists, skipping.`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`❌ Failed to read or seed ${file}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
// Also seed the xflow-project from db.ts manually if it's not in the files
|
||||
try {
|
||||
const { canvases } = await import('../app/server/utils/db.js')
|
||||
const xflowProject = canvases.get('xflow-project')
|
||||
if (xflowProject) {
|
||||
const existing = await prisma.canvas.findUnique({ where: { id: 'xflow-project' } })
|
||||
if (!existing) {
|
||||
await prisma.canvas.create({
|
||||
data: {
|
||||
id: xflowProject.id,
|
||||
name: xflowProject.name,
|
||||
ownerId: ownerId,
|
||||
isPublic: true,
|
||||
nodes: xflowProject.nodes as any,
|
||||
edges: xflowProject.edges as any
|
||||
}
|
||||
})
|
||||
console.log(`✅ Seeded map: xflow-project (from db.ts)`)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Could not seed xflow-project from db.ts', e)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(e => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { getUserAchievements, achievements } from '~/server/utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
|
||||
// Calculate stats
|
||||
const userAchievements = getUserAchievements(user.id)
|
||||
const totalCompleted = userAchievements.filter((a: any) => a.id?.startsWith('completed-')).length
|
||||
|
||||
return {
|
||||
achievements: userAchievements,
|
||||
stats: {
|
||||
total: userAchievements.length,
|
||||
completedTasks: totalCompleted
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
console.log(`[Admin API] Request from user: ${user.email}, Role: ${user.role}`)
|
||||
|
||||
if (user.role !== 'admin') {
|
||||
throw createError({ statusCode: 403, message: 'Admin access required' })
|
||||
}
|
||||
|
||||
// 1. Получаем список всех пользователей с их профилями
|
||||
const usersList = await prisma.user.findMany({
|
||||
include: { profile: true }
|
||||
})
|
||||
|
||||
// 2. Получаем список всех канвасов
|
||||
const canvasesList = await prisma.canvas.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nodes: true,
|
||||
updatedAt: true,
|
||||
ownerId: true
|
||||
}
|
||||
})
|
||||
|
||||
// 3. Считаем общее кол-во пушей (если есть таблица)
|
||||
let pushCount = 0
|
||||
try {
|
||||
pushCount = await prisma.pushSubscription.count()
|
||||
} catch (e) {}
|
||||
|
||||
return {
|
||||
users: usersList,
|
||||
canvases: canvasesList.map(c => ({
|
||||
...c,
|
||||
nodeCount: (c.nodes as any[])?.length || 0
|
||||
})),
|
||||
stats: {
|
||||
users: usersList.length,
|
||||
canvases: canvasesList.length,
|
||||
nodes: canvasesList.reduce((sum, c) => sum + ((c.nodes as any[])?.length || 0), 0),
|
||||
pushes: pushCount
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { generateAIResponse } from '~/server/utils/ai'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const body = await readBody(event)
|
||||
const { action, text, model } = body
|
||||
|
||||
if (!action || !text) {
|
||||
throw createError({ statusCode: 400, message: 'Action and text are required' })
|
||||
}
|
||||
|
||||
console.log(`[AI Coach] action=${action} userId=${user.id}`)
|
||||
|
||||
try {
|
||||
if (action === 'smart') {
|
||||
const systemPrompt = `You are an expert executive coach. Your job is to transform any vague goal or statement into a high-quality, fully detailed S.M.A.R.T. goal. Keep your response in Russian.
|
||||
Use Markdown headers for each element: Specific (Конкретная), Measurable (Измеримая), Achievable (Достижимая), Relevant (Значимая), Time-bound (Ограниченная во времени).
|
||||
For Measurable, include a clear checklist with markdown checkboxes (- [ ]) for metrics. At the end, write a brief, inspiring summary.`
|
||||
|
||||
const prompt = `Пожалуйста, преобразуй следующую цель в формат S.M.A.R.T.
|
||||
Исходная цель: "${text}"`
|
||||
|
||||
const result = await generateAIResponse(prompt, systemPrompt, model)
|
||||
return { success: true, result }
|
||||
|
||||
} else if (action === 'grow') {
|
||||
const systemPrompt = `You are a professional business coach. Your job is to perform a structured coaching analysis of the user's problem or goal using the G.R.O.W. framework. Keep all content in Russian.
|
||||
You must return the response strictly as a JSON object with the following keys:
|
||||
{
|
||||
"goal": "Markdown text. Description of the reformulated Goal (G) starting with # 🎯 Цель (Goal).\\n\\nAnswer these questions:\\n- Какой именно цели я хочу достичь?\\n- Какой результат я хочу получить?\\n- Почему достижение этой цели важно?",
|
||||
"options": "Markdown text. Options & Obstacles (O) starting with # 🚧 Варианты (Options).\\n\\nAnswer these questions:\\n- Что именно я могу изменить?\\n- Какие ресурсы мне нужны?\\n- Кто мне может помочь?\\n- А что еще?\\nInclude a checkbox list (- [ ]).",
|
||||
"reality": "Markdown text. Description of the current Situation/Reality (R) starting with # 🔍 Реальность (Reality).\\n\\nAnswer these questions:\\n- Какова текущая ситуация?\\n- Какие препятствия я вижу?\\n- Что мне мешает начать?\\n- Какие у меня уже есть ресурсы?",
|
||||
"wayForward": "Markdown text. Action Plan / Way Forward (W) starting with # 🏁 План действий (Way Forward).\\n\\nAnswer these questions:\\n- Какие самые важные действия необходимо предпринять?\\n- Что я сделаю в первую очередь? Какие сроки?\\nInclude a checkbox list (- [ ])."
|
||||
}
|
||||
Do NOT include any markdown code blocks (like \`\`\`json) or any extra text before or after the JSON. Return only the JSON object.`
|
||||
|
||||
const prompt = `Проведи GROW-коучинг анализ для следующей проблемы/цели: "${text}"`
|
||||
|
||||
const aiResponse = await generateAIResponse(prompt, systemPrompt, model)
|
||||
|
||||
// Robust JSON extraction
|
||||
let jsonText = aiResponse.trim()
|
||||
if (jsonText.startsWith('```json')) {
|
||||
jsonText = jsonText.substring(7)
|
||||
} else if (jsonText.startsWith('```')) {
|
||||
jsonText = jsonText.substring(3)
|
||||
}
|
||||
if (jsonText.endsWith('```')) {
|
||||
jsonText = jsonText.substring(0, jsonText.length - 3)
|
||||
}
|
||||
jsonText = jsonText.trim()
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText)
|
||||
return { success: true, result: parsed }
|
||||
} catch (parseErr) {
|
||||
console.error('Failed to parse AI G.R.O.W. JSON response:', aiResponse)
|
||||
// Fallback: if JSON parsing fails, wrap the text into fields
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
goal: `# 🎯 Цель (Goal)\n\n${text}`,
|
||||
reality: `# 🔍 Реальность (Reality)\n\nНе удалось автоматически разбить ответ AI на JSON. Вот оригинальный ответ:\n\n${aiResponse}`,
|
||||
options: `# 🚧 Варианты\n\n- Рассмотреть альтернативные пути решения вручную`,
|
||||
wayForward: `# 🏁 План действий\n\n- [ ] Детализировать шаги вручную`
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (action === 'dilts') {
|
||||
const systemPrompt = `You are a professional life and business coach. Your job is to perform a structured analysis of the user's goal or situation using Robert Dilts' Logical Levels (Пирамида логических уровней Дилтса). Keep all content in Russian.
|
||||
You must return the response strictly as a JSON object with the following keys:
|
||||
{
|
||||
"mission": "Markdown text starting with # 🌟 Миссия (Mission).\\n\\nAnswer these questions:\\n- Ради кого?\\n- Кому еще это важно?\\n(Уровень БЫТЬ)",
|
||||
"identity": "Markdown text starting with # 👤 Идентичность (Identity).\\n\\nAnswer these questions:\\n- Кто я?",
|
||||
"values": "Markdown text starting with # 💎 Ценности (Values).\\n\\nAnswer these questions:\\n- Почему это важно?",
|
||||
"skills": "Markdown text starting with # 🧠 Навыки (Skills).\\n\\nAnswer these questions:\\n- Как? Каким образом?",
|
||||
"actions": "Markdown text starting with # 🎬 Действия (Actions).\\n\\nAnswer these questions:\\n- Что делать? (Уровень ДЕЛАТЬ)",
|
||||
"environment": "Markdown text starting with # 🌍 Окружение (Environment).\\n\\nAnswer these questions:\\n- Где? Когда? С кем? (Уровень ИМЕТЬ)"
|
||||
}
|
||||
Do NOT include any markdown code blocks (like \`\`\`json) or any extra text before or after the JSON. Return only the JSON object.`
|
||||
|
||||
const prompt = `Проведи коучинговый анализ по логическим уровням Дилтса для следующего запроса: "${text}"`
|
||||
|
||||
const aiResponse = await generateAIResponse(prompt, systemPrompt, model)
|
||||
|
||||
// Robust JSON extraction
|
||||
let jsonText = aiResponse.trim()
|
||||
if (jsonText.startsWith('```json')) {
|
||||
jsonText = jsonText.substring(7)
|
||||
} else if (jsonText.startsWith('```')) {
|
||||
jsonText = jsonText.substring(3)
|
||||
}
|
||||
if (jsonText.endsWith('```')) {
|
||||
jsonText = jsonText.substring(0, jsonText.length - 3)
|
||||
}
|
||||
jsonText = jsonText.trim()
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText)
|
||||
return { success: true, result: parsed }
|
||||
} catch (parseErr) {
|
||||
console.error('Failed to parse AI Dilts JSON response:', aiResponse)
|
||||
// Fallback
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
mission: `# 🌟 Миссия (Mission)\n\nРади кого? Кому еще это важно? (Уровень БЫТЬ)\n\n${text}`,
|
||||
identity: `# 👤 Идентичность (Identity)\n\nКто я?`,
|
||||
values: `# 💎 Ценности (Values)\n\nПочему это важно?`,
|
||||
skills: `# 🧠 Навыки (Skills)\n\nКак? Каким образом?`,
|
||||
actions: `# 🎬 Действия (Actions)\n\nЧто делать? (Уровень ДЕЛАТЬ)\n\nНе удалось автоматически разбить ответ AI на JSON:\n\n${aiResponse}`,
|
||||
environment: `# 🌍 Окружение (Environment)\n\nГде? Когда? С кем? (Уровень ИМЕТЬ)`
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (action === 'eisenhower') {
|
||||
const systemPrompt = `You are a professional productivity expert. Your job is to analyze the user's tasks or list of ideas and prioritize them using the Eisenhower Matrix (Матрица Эйзенхауэра). Keep all content in Russian.
|
||||
You must return the response strictly as a JSON object with the following keys:
|
||||
{
|
||||
"q1": "Markdown text starting with # 🔥 Важно и Срочно (Do First).\\n\\nIdentify tasks that are both urgent and important. Focus on crises, deadlines, and crucial problems.",
|
||||
"q2": "Markdown text starting with # ⚡ Важно, но Несрочно (Schedule).\\n\\nIdentify tasks that are important but not urgent. Focus on long-term planning, personal growth, and relationships.",
|
||||
"q3": "Markdown text starting with # 🧊 Неважно, но Срочно (Delegate).\\n\\nIdentify tasks that are urgent but not important. Focus on interruptions, minor meetings, and tasks that can be delegated.",
|
||||
"q4": "Markdown text starting with # 💤 Неважно и Несрочно (Eliminate).\\n\\nIdentify tasks that are neither urgent nor important. Focus on time-wasting activities, distractions, and low-value tasks."
|
||||
}
|
||||
Do NOT include any markdown code blocks (like \`\`\`json) or any extra text before or after the JSON. Return only the JSON object.`
|
||||
|
||||
const prompt = `Распредели следующие задачи/цели по квадрантам Матрицы Эйзенхауэра: "${text}"`
|
||||
|
||||
const aiResponse = await generateAIResponse(prompt, systemPrompt, model)
|
||||
|
||||
// Robust JSON extraction
|
||||
let jsonText = aiResponse.trim()
|
||||
if (jsonText.startsWith('```json')) {
|
||||
jsonText = jsonText.substring(7)
|
||||
} else if (jsonText.startsWith('```')) {
|
||||
jsonText = jsonText.substring(3)
|
||||
}
|
||||
if (jsonText.endsWith('```')) {
|
||||
jsonText = jsonText.substring(0, jsonText.length - 3)
|
||||
}
|
||||
jsonText = jsonText.trim()
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText)
|
||||
return { success: true, result: parsed }
|
||||
} catch (parseErr) {
|
||||
console.error('Failed to parse AI Eisenhower JSON response:', aiResponse)
|
||||
// Fallback
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
q1: `# 🔥 Важно и Срочно (Do First)\n\n- [ ] Срочные приоритетные задачи\n\n${text}`,
|
||||
q2: `# ⚡ Важно, но Несрочно (Schedule)\n\n- [ ] Задачи планирования`,
|
||||
q3: `# 🧊 Неважно, но Срочно (Delegate)\n\n- [ ] Задачи для делегирования\n\nНе удалось разобрать ответ ИИ:\n\n${aiResponse}`,
|
||||
q4: `# 💤 Неважно и Несрочно (Eliminate)\n\n- [ ] Задачи, которые можно отложить`
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw createError({ statusCode: 400, message: `Unsupported action: ${action}` })
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[AI Coach Error]:', err)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: err.message || 'AI coach generation failed'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { generateToken } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
interface FirebaseUser {
|
||||
localId: string
|
||||
email: string
|
||||
displayName?: string
|
||||
photoUrl?: string
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const { idToken } = body
|
||||
|
||||
if (!idToken) {
|
||||
throw createError({ statusCode: 400, message: 'Firebase ID token required' })
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const apiKey = config.firebaseApiKey || config.public.firebaseApiKey
|
||||
|
||||
if (!apiKey) {
|
||||
throw createError({ statusCode: 500, message: 'Firebase API key not configured' })
|
||||
}
|
||||
|
||||
console.log('[Auth] Received idToken, length:', idToken?.length || 0)
|
||||
console.log('[Auth] Using API key (masked):', apiKey ? `${apiKey.slice(0, 6)}...${apiKey.slice(-4)}` : 'EMPTY')
|
||||
|
||||
let verifyRes: any
|
||||
try {
|
||||
verifyRes = await $fetch<any>(
|
||||
`https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=${apiKey}`,
|
||||
{ method: 'POST', body: { idToken } }
|
||||
)
|
||||
} catch (err: any) {
|
||||
console.error('[Auth] Firebase token verification failed:', err?.data || err?.message || err)
|
||||
throw createError({ statusCode: 401, message: 'Invalid Firebase token' })
|
||||
}
|
||||
|
||||
if (!verifyRes?.users?.[0]) {
|
||||
throw createError({ statusCode: 401, message: 'Invalid Firebase token' })
|
||||
}
|
||||
|
||||
const fbUser: FirebaseUser = verifyRes.users[0]
|
||||
const email = fbUser.email
|
||||
// Очищаем username: заменяем пробелы на дефисы и убираем спецсимволы
|
||||
const username = email.split('@')[0].toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-_]/g, '')
|
||||
const userId = fbUser.localId
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: {
|
||||
username
|
||||
// Мы не затираем профиль при каждом входе, но нам нужно его получить
|
||||
},
|
||||
create: {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
password: 'firebase-managed',
|
||||
role: 'user',
|
||||
profile: {
|
||||
create: {
|
||||
displayName: fbUser.displayName || username,
|
||||
avatar: fbUser.photoUrl || `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}`
|
||||
}
|
||||
}
|
||||
},
|
||||
include: {
|
||||
profile: true
|
||||
}
|
||||
})
|
||||
|
||||
const token = generateToken({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.profile?.displayName || user.username,
|
||||
role: user.role
|
||||
})
|
||||
|
||||
return {
|
||||
message: 'Google sign-in successful',
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
photoUrl: fbUser.photoUrl
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { verifyToken } from '~/server/utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const authHeader = getHeader(event, 'authorization')
|
||||
const token = authHeader?.split(' ')[1]
|
||||
|
||||
if (!token) {
|
||||
return { valid: false, error: 'No token provided' }
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = verifyToken(token)
|
||||
return { valid: true, user: decoded }
|
||||
} catch {
|
||||
return { valid: false, error: 'Invalid token' }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) throw createError({ statusCode: 400, message: 'Canvas ID is required' })
|
||||
|
||||
const canvas = await prisma.canvas.findUnique({ where: { id } })
|
||||
if (!canvas) throw createError({ statusCode: 404, message: 'Canvas not found' })
|
||||
|
||||
// 1. Проверка прав
|
||||
let canDelete = canvas.ownerId === user.id || user.role === 'admin'
|
||||
|
||||
if (!canDelete && canvas.groupId) {
|
||||
const membership = await prisma.groupMember.findUnique({
|
||||
where: {
|
||||
userId_groupId: { userId: user.id, groupId: canvas.groupId }
|
||||
}
|
||||
})
|
||||
// Только админ группы может удалять чужие карты внутри группы
|
||||
if (membership && membership.role === 'admin') {
|
||||
canDelete = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!canDelete) {
|
||||
throw createError({ statusCode: 403, message: 'Удалять карты может только владелец или администратор группы.' })
|
||||
}
|
||||
|
||||
await prisma.canvas.delete({ where: { id } })
|
||||
|
||||
return { success: true, message: 'Canvas deleted' }
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) throw createError({ statusCode: 400, message: 'Canvas ID is required' })
|
||||
|
||||
const canvas = await prisma.canvas.findUnique({ where: { id } })
|
||||
if (!canvas) throw createError({ statusCode: 404, message: 'Canvas not found' })
|
||||
|
||||
let user = { id: null, role: 'guest' } as any
|
||||
try {
|
||||
user = requireAuth(event)
|
||||
} catch (e) {}
|
||||
|
||||
// Определение роли
|
||||
let userRole = 'viewer'
|
||||
if (user.id === canvas.ownerId) userRole = 'owner'
|
||||
else if (user.role === 'admin') userRole = 'admin'
|
||||
else if (canvas.groupId) {
|
||||
const membership = user.id ? await prisma.groupMember.findUnique({
|
||||
where: { userId_groupId: { userId: user.id as string, groupId: canvas.groupId } }
|
||||
}) : null
|
||||
if (membership) userRole = membership.role
|
||||
else if (!canvas.isPublic) throw createError({ statusCode: 403, message: 'Access denied' })
|
||||
} else if (!canvas.isPublic) {
|
||||
throw createError({ statusCode: 403, message: 'Access denied' })
|
||||
}
|
||||
|
||||
return { canvas, role: userRole }
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) throw createError({ statusCode: 400, message: 'Canvas ID is required' })
|
||||
|
||||
const body = await readBody(event)
|
||||
|
||||
const existing = await prisma.canvas.findUnique({ where: { id } })
|
||||
if (!existing) throw createError({ statusCode: 404, message: 'Canvas not found' })
|
||||
|
||||
// 1. Проверка прав
|
||||
let canEdit = existing.ownerId === user.id || user.role === 'admin' || existing.isPublic
|
||||
|
||||
if (!canEdit && existing.groupId) {
|
||||
const membership = await prisma.groupMember.findUnique({
|
||||
where: {
|
||||
userId_groupId: { userId: user.id, groupId: existing.groupId }
|
||||
}
|
||||
})
|
||||
// Редакторы и админы группы могут править
|
||||
if (membership && (membership.role === 'admin' || membership.role === 'editor')) {
|
||||
canEdit = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!canEdit) {
|
||||
throw createError({ statusCode: 403, message: 'У вас недостаточно прав для редактирования этой карты' })
|
||||
}
|
||||
|
||||
const { name, nodes, edges, isPublic, groupId } = body
|
||||
const canvas = await prisma.canvas.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name !== undefined && { name }),
|
||||
...(nodes !== undefined && { nodes }),
|
||||
...(edges !== undefined && { edges }),
|
||||
...(isPublic !== undefined && { isPublic }),
|
||||
...(groupId !== undefined && { groupId: groupId || null })
|
||||
}
|
||||
})
|
||||
|
||||
return { canvas }
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { canvases } from '~/server/utils/db'
|
||||
import type { CanvasData } from '~/server/utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
if (!id) {
|
||||
throw createError({ statusCode: 400, message: 'Canvas ID is required' })
|
||||
}
|
||||
|
||||
const original = canvases.get(id)
|
||||
if (!original) {
|
||||
throw createError({ statusCode: 404, message: 'Canvas not found' })
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
if (original.ownerId !== user.id && !original.isPublic) {
|
||||
throw createError({ statusCode: 403, message: 'Access denied' })
|
||||
}
|
||||
|
||||
const newId = `canvas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const duplicate: CanvasData = {
|
||||
...original,
|
||||
id: newId,
|
||||
name: `${original.name} (Copy)`,
|
||||
ownerId: user.id,
|
||||
groupId: undefined, // Duplicates are personal
|
||||
isPublic: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
nodes: original.nodes.map(node => ({
|
||||
...node,
|
||||
id: `${node.id}-copy-${Date.now()}`
|
||||
})),
|
||||
edges: original.edges.map(edge => ({
|
||||
...edge,
|
||||
id: `${edge.id}-copy-${Date.now()}`
|
||||
}))
|
||||
}
|
||||
|
||||
canvases.set(newId, duplicate)
|
||||
|
||||
return { canvas: duplicate }
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
|
||||
const userGroups = await prisma.groupMember.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { groupId: true }
|
||||
})
|
||||
const groupIds = userGroups.map(m => m.groupId)
|
||||
|
||||
const canvasList = await prisma.canvas.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: user.id },
|
||||
{ isPublic: true },
|
||||
{ groupId: { in: groupIds } }
|
||||
]
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
ownerId: true,
|
||||
groupId: true,
|
||||
isPublic: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
nodes: true
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
})
|
||||
|
||||
const totalCount = await prisma.canvas.count()
|
||||
|
||||
const canvases = canvasList.map(c => ({
|
||||
...c,
|
||||
nodeCount: Array.isArray(c.nodes) ? (c.nodes as any[]).length : 0,
|
||||
nodes: undefined
|
||||
}))
|
||||
|
||||
return {
|
||||
canvases,
|
||||
totalCount
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const body = await readBody(event)
|
||||
const { name = 'New Canvas', nodes = [], edges = [], isPublic = false, groupId } = body
|
||||
|
||||
if (groupId) {
|
||||
const membership = await prisma.groupMember.findUnique({
|
||||
where: { userId_groupId: { userId: user.id, groupId } }
|
||||
})
|
||||
if (!membership) {
|
||||
throw createError({ statusCode: 403, message: 'Not a member of this group' })
|
||||
}
|
||||
}
|
||||
|
||||
const id = `canvas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const canvas = await prisma.canvas.create({
|
||||
data: {
|
||||
id,
|
||||
name,
|
||||
nodes,
|
||||
edges,
|
||||
isPublic,
|
||||
ownerId: user.id,
|
||||
groupId: groupId || null
|
||||
}
|
||||
})
|
||||
|
||||
return { canvas }
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const id = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
|
||||
// Проверяем права: только владелец может менять настройки доступа
|
||||
const canvas = await prisma.canvas.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!canvas) {
|
||||
throw createError({ statusCode: 404, message: 'Canvas not found' })
|
||||
}
|
||||
|
||||
if (canvas.ownerId !== user.id && user.role !== 'admin') {
|
||||
throw createError({ statusCode: 403, message: 'Only owner can change settings' })
|
||||
}
|
||||
|
||||
// Обновляем поля
|
||||
const updatedCanvas = await prisma.canvas.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: body.name !== undefined ? body.name : undefined,
|
||||
isPublic: body.isPublic !== undefined ? body.isPublic : undefined,
|
||||
groupId: body.groupId !== undefined ? body.groupId : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true, canvas: updatedCanvas }
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { saveCanvasToGitea } from '~/server/utils/giteaApi'
|
||||
import { canvases } from '~/server/utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const body = await readBody(event)
|
||||
const { canvasId, autoSync = true } = body
|
||||
|
||||
if (!canvasId) {
|
||||
throw createError({ statusCode: 400, message: 'Canvas ID is required' })
|
||||
}
|
||||
|
||||
// Check if canvas exists and user has access
|
||||
const canvas = canvases.get(canvasId)
|
||||
if (!canvas) {
|
||||
throw createError({ statusCode: 404, message: 'Canvas not found' })
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
if (canvas.ownerId !== user.id && !canvas.isPublic) {
|
||||
throw createError({ statusCode: 403, message: 'Access denied' })
|
||||
}
|
||||
|
||||
try {
|
||||
const ok = await saveCanvasToGitea(canvasId, canvas, `Auto-sync: ${canvas.name}`)
|
||||
return {
|
||||
success: ok,
|
||||
message: ok ? 'Canvas synchronized with Gitea' : 'Gitea sync failed',
|
||||
autoSyncEnabled: autoSync
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to sync canvas to Gitea:', error)
|
||||
throw createError({ statusCode: 500, message: 'Failed to sync canvas to Gitea' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { saveCanvasToGitea, loadCanvasFromGitea } from '~/server/utils/giteaApi'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const body = await readBody(event)
|
||||
const { canvasId, action, commitMessage } = body
|
||||
|
||||
if (!canvasId || !action) {
|
||||
throw createError({ statusCode: 400, message: 'Canvas ID and action are required' })
|
||||
}
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'init':
|
||||
case 'sync': {
|
||||
const canvas = await prisma.canvas.findUnique({ where: { id: canvasId } })
|
||||
if (!canvas) {
|
||||
throw createError({ statusCode: 404, message: 'Canvas not found' })
|
||||
}
|
||||
if (canvas.ownerId !== user.id && !canvas.isPublic) {
|
||||
throw createError({ statusCode: 403, message: 'Access denied' })
|
||||
}
|
||||
|
||||
const opts = { username: user.username, groupId: canvas.groupId, isPublic: canvas.isPublic }
|
||||
const ok = await saveCanvasToGitea(canvasId, canvas, opts, commitMessage)
|
||||
return {
|
||||
success: ok,
|
||||
message: ok
|
||||
? `Canvas ${canvasId} saved to Gitea`
|
||||
: `Failed to save canvas ${canvasId} to Gitea`
|
||||
}
|
||||
}
|
||||
|
||||
case 'load': {
|
||||
const existing = await prisma.canvas.findUnique({ where: { id: canvasId } })
|
||||
const opts = existing ? { username: user.username, groupId: existing.groupId, isPublic: existing.isPublic } : { username: user.username }
|
||||
const gitCanvas = await loadCanvasFromGitea(canvasId, opts)
|
||||
if (!gitCanvas) {
|
||||
return { success: false, message: 'Canvas not found in Gitea', needsInit: true }
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await prisma.canvas.update({
|
||||
where: { id: canvasId },
|
||||
data: {
|
||||
nodes: gitCanvas.nodes || [],
|
||||
edges: gitCanvas.edges || []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { success: true, canvas: gitCanvas, message: `Canvas ${canvasId} loaded from Gitea` }
|
||||
}
|
||||
|
||||
default:
|
||||
throw createError({ statusCode: 400, message: 'Invalid action' })
|
||||
}
|
||||
} catch (error: any) {
|
||||
const details = error.data || error.message || error
|
||||
console.error('Gitea sync error details:', details)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: typeof details === 'string' ? details : JSON.stringify(details)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { saveCanvasToGitea } from '~/server/utils/giteaApi'
|
||||
import { canvases, userGroups } from '~/server/utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const body = await readBody(event)
|
||||
const { canvasId, type } = body
|
||||
|
||||
if (!canvasId || !type) {
|
||||
throw createError({ statusCode: 400, message: 'Canvas ID and repository type are required' })
|
||||
}
|
||||
|
||||
// Check if canvas exists and user has access
|
||||
const canvas = canvases.get(canvasId)
|
||||
if (!canvas) {
|
||||
throw createError({ statusCode: 404, message: 'Canvas not found' })
|
||||
}
|
||||
|
||||
if (canvas.ownerId !== user.id && !canvas.isPublic) {
|
||||
// Check group access
|
||||
if (canvas.groupId) {
|
||||
const membership = Array.from(userGroups.values()).find(
|
||||
m => m.userId === user.id && m.groupId === canvas.groupId
|
||||
)
|
||||
if (!membership) {
|
||||
throw createError({ statusCode: 403, message: 'Access denied' })
|
||||
}
|
||||
} else {
|
||||
throw createError({ statusCode: 403, message: 'Access denied' })
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Initial save to Gitea creates the canvas file in the repo
|
||||
const ok = await saveCanvasToGitea(canvasId, canvas, `Init canvas ${canvasId}`)
|
||||
return {
|
||||
success: ok,
|
||||
message: ok
|
||||
? `Canvas ${canvasId} initialized in Gitea`
|
||||
: `Failed to initialize canvas ${canvasId} in Gitea`
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize canvas in Gitea:', error)
|
||||
throw createError({ statusCode: 500, message: 'Failed to initialize canvas in Gitea' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { resolveRepo } from '~/server/utils/giteaApi'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const canvasId = getRouterParam(event, 'canvasId')
|
||||
|
||||
if (!canvasId) {
|
||||
throw createError({ statusCode: 400, message: 'Canvas ID is required' })
|
||||
}
|
||||
|
||||
const canvas = await prisma.canvas.findUnique({ where: { id: canvasId } })
|
||||
|
||||
const opts = canvas
|
||||
? { username: user.username, groupId: canvas.groupId, isPublic: canvas.isPublic }
|
||||
: { username: user.username }
|
||||
|
||||
const { owner, repo } = resolveRepo(opts)
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const { giteaUrl: url, giteaToken: token } = config
|
||||
|
||||
try {
|
||||
const res = await $fetch<any>(`${url}/api/v1/repos/${owner}/${repo}`, {
|
||||
headers: {
|
||||
'Authorization': `token ${token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
repository: {
|
||||
id: res.id,
|
||||
name: res.name,
|
||||
owner: res.owner.login,
|
||||
url: res.html_url,
|
||||
cloneUrl: res.clone_url,
|
||||
isPrivate: res.private,
|
||||
type: 'personal',
|
||||
ownerId: user.id,
|
||||
createdAt: res.created_at,
|
||||
updatedAt: res.updated_at
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
return { success: false, repository: null, message: 'Repository not found' }
|
||||
}
|
||||
console.error('Failed to fetch repository:', error)
|
||||
throw createError({ statusCode: 500, message: 'Failed to fetch repository details from Gitea' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const body = await readBody(event)
|
||||
const { canvasId, action, commitMessage } = body
|
||||
|
||||
if (!canvasId || !action) {
|
||||
throw createError({ statusCode: 400, message: 'Canvas ID and action are required' })
|
||||
}
|
||||
|
||||
try {
|
||||
// Git sync logic will be implemented here
|
||||
// For now, return success response
|
||||
return {
|
||||
success: true,
|
||||
message: `Git ${action} completed for canvas ${canvasId}`,
|
||||
commitMessage: commitMessage || `Auto-sync canvas ${canvasId}`
|
||||
}
|
||||
} catch (error) {
|
||||
throw createError({ statusCode: 500, message: 'Git sync failed' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
// Test endpoint: GET /api/git/test
|
||||
// Verifies Gitea connection and repo access
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const { giteaUrl, giteaToken, giteaOrg, giteaMindmapRepo } = config
|
||||
|
||||
if (!giteaUrl || !giteaToken) {
|
||||
return { success: false, message: 'GITEA_URL or GITEA_TOKEN not set in .env' }
|
||||
}
|
||||
|
||||
try {
|
||||
// Test 1: check repo exists
|
||||
const repo = await $fetch<any>(`${giteaUrl}/api/v1/repos/${giteaOrg}/${giteaMindmapRepo}`, {
|
||||
headers: { 'Authorization': `token ${giteaToken}` }
|
||||
})
|
||||
|
||||
// Test 2: check/create canvases folder by listing contents
|
||||
let folderExists = false
|
||||
try {
|
||||
await $fetch(`${giteaUrl}/api/v1/repos/${giteaOrg}/${giteaMindmapRepo}/contents/canvases`, {
|
||||
headers: { 'Authorization': `token ${giteaToken}` }
|
||||
})
|
||||
folderExists = true
|
||||
} catch {
|
||||
folderExists = false
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
repo: repo.full_name,
|
||||
private: repo.private,
|
||||
folderExists,
|
||||
message: folderExists
|
||||
? '✅ Gitea connected, canvases/ folder exists'
|
||||
: '✅ Gitea connected, canvases/ folder will be created on first save'
|
||||
}
|
||||
} catch (e: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Gitea connection failed: ${e?.message || e}`,
|
||||
hint: 'Check GITEA_URL, GITEA_TOKEN, GITEA_ORG, GITEA_MINDMAP_REPO in .env'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const groupId = getRouterParam(event, 'id')!
|
||||
|
||||
const group = await prisma.group.findUnique({ where: { id: groupId } })
|
||||
if (!group) throw createError({ statusCode: 404, message: 'Group not found' })
|
||||
if (group.ownerId !== user.id && user.role !== 'admin') {
|
||||
throw createError({ statusCode: 403, message: 'Only owner can delete group' })
|
||||
}
|
||||
|
||||
await prisma.group.delete({ where: { id: groupId } })
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const adminUser = requireAuth(event)
|
||||
const groupId = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
const { email, userId, role = 'member' } = body
|
||||
|
||||
if (!groupId || (!email && !userId)) {
|
||||
throw createError({ statusCode: 400, message: 'Group ID and either Email or User ID are required' })
|
||||
}
|
||||
|
||||
// 1. Проверяем права
|
||||
const group = await prisma.group.findUnique({ where: { id: groupId } })
|
||||
if (!group || (group.ownerId !== adminUser.id && adminUser.role !== 'admin')) {
|
||||
throw createError({ statusCode: 403, message: 'Only group owners or admins can add members' })
|
||||
}
|
||||
|
||||
// 2. Находим пользователя
|
||||
let userToAdd = null
|
||||
if (userId) {
|
||||
userToAdd = await prisma.user.findUnique({ where: { id: userId } })
|
||||
} else if (email) {
|
||||
userToAdd = await prisma.user.findUnique({ where: { email } })
|
||||
}
|
||||
|
||||
if (!userToAdd) {
|
||||
throw createError({ statusCode: 404, message: 'User not found' })
|
||||
}
|
||||
|
||||
// 3. Добавляем в группу
|
||||
try {
|
||||
const membership = await prisma.groupMember.upsert({
|
||||
where: {
|
||||
userId_groupId: {
|
||||
userId: userToAdd.id,
|
||||
groupId: groupId
|
||||
}
|
||||
},
|
||||
update: { role },
|
||||
create: {
|
||||
userId: userToAdd.id,
|
||||
groupId: groupId,
|
||||
role
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true, membership }
|
||||
} catch (e) {
|
||||
throw createError({ statusCode: 500, message: 'Failed to add member to group' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
const group = await prisma.group.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, username: true, email: true }
|
||||
}
|
||||
}
|
||||
},
|
||||
canvases: {
|
||||
select: { id: true, name: true, updatedAt: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!group) {
|
||||
throw createError({ statusCode: 404, message: 'Group not found' })
|
||||
}
|
||||
|
||||
// Проверяем, имеет ли пользователь право видеть детали группы
|
||||
const isMember = group.members.some(m => m.userId === user.id)
|
||||
if (!group.isPublic && !isMember && user.role !== 'admin') {
|
||||
throw createError({ statusCode: 403, message: 'Access denied' })
|
||||
}
|
||||
|
||||
return { group }
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const groupId = getRouterParam(event, 'id')!
|
||||
|
||||
const group = await prisma.group.findUnique({ where: { id: groupId } })
|
||||
if (!group) throw createError({ statusCode: 404, message: 'Group not found' })
|
||||
if (!group.isPublic) throw createError({ statusCode: 403, message: 'Group is private' })
|
||||
|
||||
const existing = await prisma.groupMember.findUnique({
|
||||
where: { userId_groupId: { userId: user.id, groupId } }
|
||||
})
|
||||
if (existing) return { message: 'Already a member' }
|
||||
|
||||
await prisma.groupMember.create({
|
||||
data: { userId: user.id, groupId, role: 'member' }
|
||||
})
|
||||
|
||||
return { success: true, message: `Joined group "${group.name}"` }
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const groupId = getRouterParam(event, 'id')!
|
||||
|
||||
const group = await prisma.group.findUnique({ where: { id: groupId } })
|
||||
if (!group) throw createError({ statusCode: 404, message: 'Group not found' })
|
||||
if (group.ownerId === user.id) throw createError({ statusCode: 400, message: 'Owner cannot leave group. Delete it instead.' })
|
||||
|
||||
const membership = await prisma.groupMember.findUnique({
|
||||
where: { userId_groupId: { userId: user.id, groupId } }
|
||||
})
|
||||
if (!membership) throw createError({ statusCode: 400, message: 'Not a member' })
|
||||
|
||||
await prisma.groupMember.delete({ where: { id: membership.id } })
|
||||
|
||||
return { success: true, message: `Left group "${group.name}"` }
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const adminUser = requireAuth(event)
|
||||
const groupId = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
const { userId, role } = body
|
||||
|
||||
if (!groupId || !userId || !role) {
|
||||
throw createError({ statusCode: 400, message: 'Missing required fields' })
|
||||
}
|
||||
|
||||
// 1. Проверяем права: только владелец группы или глобальный админ может менять роли
|
||||
const group = await prisma.group.findUnique({
|
||||
where: { id: groupId }
|
||||
})
|
||||
|
||||
if (!group || (group.ownerId !== adminUser.id && adminUser.role !== 'admin')) {
|
||||
throw createError({ statusCode: 403, message: 'Permission denied' })
|
||||
}
|
||||
|
||||
// 2. Нельзя менять роль самому владельцу (он всегда админ)
|
||||
if (userId === group.ownerId) {
|
||||
throw createError({ statusCode: 400, message: 'Cannot change owner role' })
|
||||
}
|
||||
|
||||
// 3. Обновляем роль
|
||||
try {
|
||||
const updated = await prisma.groupMember.update({
|
||||
where: {
|
||||
userId_groupId: { userId, groupId }
|
||||
},
|
||||
data: { role }
|
||||
})
|
||||
|
||||
return { success: true, updated }
|
||||
} catch (e) {
|
||||
throw createError({ statusCode: 500, message: 'Failed to update member role' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
|
||||
const allGroups = await prisma.group.findMany({
|
||||
where: user.role === 'admin' ? {} : { isPublic: true },
|
||||
include: {
|
||||
members: { include: { user: { select: { id: true, username: true } } } },
|
||||
_count: { select: { members: true, canvases: true } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
|
||||
return {
|
||||
groups: allGroups.map(g => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
description: g.description,
|
||||
isPublic: g.isPublic,
|
||||
ownerId: g.ownerId,
|
||||
createdAt: g.createdAt,
|
||||
updatedAt: g.updatedAt,
|
||||
memberCount: g._count.members,
|
||||
canvasCount: g._count.canvases,
|
||||
isMember: g.members.some(m => m.userId === user.id)
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const body = await readBody(event)
|
||||
const { name, description = '', isPublic = true } = body
|
||||
|
||||
if (!name?.trim()) {
|
||||
throw createError({ statusCode: 400, message: 'Group name is required' })
|
||||
}
|
||||
|
||||
const group = await prisma.group.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
description,
|
||||
isPublic,
|
||||
ownerId: user.id,
|
||||
members: {
|
||||
create: {
|
||||
userId: user.id,
|
||||
role: 'admin'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { group }
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { nodes, addAchievement } from '~/server/utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const id = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
|
||||
const existing = nodes.get(id || '') || { id, state: 'pending', assigned_to: user.id }
|
||||
|
||||
// Update state
|
||||
const updated = { ...existing, ...body, updatedAt: new Date().toISOString() }
|
||||
nodes.set(id || '', updated)
|
||||
|
||||
// Gamification: achievement on completion
|
||||
if (body.state === 'completed' && existing.state !== 'completed') {
|
||||
addAchievement(user.id, {
|
||||
id: `completed-${id}`,
|
||||
title: 'Задача выполнена',
|
||||
description: `Нода ${id} завершена`,
|
||||
icon: '✅'
|
||||
})
|
||||
}
|
||||
|
||||
return { node: updated }
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
import { generateAIResponse } from '~/server/utils/ai'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const nodeId = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
const { canvasId, context } = body
|
||||
|
||||
console.log(`[Execute] nodeId=${nodeId} canvasId=${canvasId} userId=${user.id}`)
|
||||
|
||||
if (!nodeId || !canvasId) {
|
||||
throw createError({ statusCode: 400, message: 'Node ID and Canvas ID are required' })
|
||||
}
|
||||
|
||||
// 1. Найти холст и убедиться в правах доступа
|
||||
const canvas = await prisma.canvas.findUnique({
|
||||
where: { id: canvasId }
|
||||
})
|
||||
|
||||
console.log(`[Execute] Canvas found: ${!!canvas}, nodes count: ${(canvas?.nodes as any[])?.length}`)
|
||||
|
||||
if (!canvas) {
|
||||
throw createError({ statusCode: 404, message: 'Canvas not found' })
|
||||
}
|
||||
|
||||
// Проверка прав (владелец или админ)
|
||||
if (canvas.ownerId !== user.id && user.role !== 'admin') {
|
||||
throw createError({ statusCode: 403, message: 'Access denied' })
|
||||
}
|
||||
|
||||
// 2. Найти конкретный узел в JSON холста
|
||||
const nodes = (canvas.nodes as any[]) || []
|
||||
const nodeIdx = nodes.findIndex(n => n.id === nodeId)
|
||||
|
||||
console.log(`[Execute] Node found at index: ${nodeIdx}`)
|
||||
|
||||
if (nodeIdx === -1) {
|
||||
throw createError({ statusCode: 404, message: `Node ${nodeId} not found in canvas ${canvasId}` })
|
||||
}
|
||||
|
||||
const node = nodes[nodeIdx]
|
||||
console.log(`[Execute] Node type: ${node.type}`)
|
||||
|
||||
try {
|
||||
let result = ''
|
||||
|
||||
if (node.type === 'ai') {
|
||||
// Формируем промпт с учетом контекста от соседей
|
||||
let fullPrompt = node.prompt || node.text || 'Analyze the provided context.'
|
||||
|
||||
if (context && context.length > 0) {
|
||||
const contextText = context.map((c: any) => `[Node ${c.id}]: ${c.text}`).join('\n\n')
|
||||
fullPrompt = `CONTEXT INFORMATION:\n${contextText}\n\nUSER REQUEST/INSTRUCTION:\n${fullPrompt}`
|
||||
}
|
||||
|
||||
// Модель можно задать прямо в узле: node.model = 'qwen_coder' | 'llama' | 'gemma' | 'laguna' | 'nemotron'
|
||||
const model = node.model || undefined
|
||||
console.log(`[AI Execute] model=${model || 'default'} nodeId=${nodeId}`)
|
||||
result = await generateAIResponse(fullPrompt, 'You are a helpful assistant.', model)
|
||||
|
||||
// Сохраняем результат в текст узла
|
||||
nodes[nodeIdx].text = result
|
||||
|
||||
} else if (node.type === 'webhook') {
|
||||
const url = node.url
|
||||
const method = node.method || 'GET'
|
||||
|
||||
if (!url) throw new Error('Webhook URL is not configured')
|
||||
|
||||
console.log(`[Webhook Execute] Calling ${method} ${url}...`)
|
||||
|
||||
const response = await $fetch<any>(url, {
|
||||
method,
|
||||
...(method === 'POST' ? { body: { context, timestamp: new Date() } } : {})
|
||||
})
|
||||
|
||||
result = typeof response === 'string' ? response : JSON.stringify(response, null, 2)
|
||||
nodes[nodeIdx].text = `### Response from ${url}\n\n\`\`\`json\n${result}\n\`\`\``
|
||||
|
||||
} else {
|
||||
throw createError({ statusCode: 400, message: `Execution not supported for type: ${node.type}` })
|
||||
}
|
||||
|
||||
// 3. Сохраняем обновленный холст
|
||||
await prisma.canvas.update({
|
||||
where: { id: canvasId },
|
||||
data: { nodes }
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result,
|
||||
message: 'Node executed and canvas updated'
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Node execution error:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: error.message || 'Execution failed'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { nodes } from '~/server/utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
|
||||
// RBAC: filter nodes by assigned_to or role
|
||||
const allNodes = Array.from(nodes.values())
|
||||
const filtered = user.role === 'admin'
|
||||
? allNodes
|
||||
: allNodes.filter((n: any) => n.assigned_to === user.id || n.assigned_to === 'all')
|
||||
|
||||
return { nodes: filtered }
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { requireRole } from '~/server/utils/auth'
|
||||
import { pushSubscriptions } from '~/server/utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireRole(event, 'admin')
|
||||
const body = await readBody(event)
|
||||
const { title, message, userId } = body
|
||||
|
||||
const targets = userId
|
||||
? [pushSubscriptions.get(userId)].filter(Boolean)
|
||||
: Array.from(pushSubscriptions.values())
|
||||
|
||||
// In production, use web-push library with VAPID keys
|
||||
const results = targets.map((sub: any) => ({
|
||||
userId: sub.userId,
|
||||
sent: true,
|
||||
notification: { title, message, timestamp: new Date().toISOString() }
|
||||
}))
|
||||
|
||||
return { sent: results.length, results }
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { pushSubscriptions } from '~/server/utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const body = await readBody(event)
|
||||
const { subscription } = body
|
||||
|
||||
if (!subscription?.endpoint) {
|
||||
throw createError({ statusCode: 400, message: 'Invalid subscription' })
|
||||
}
|
||||
|
||||
pushSubscriptions.set(user.id, { userId: user.id, subscription, createdAt: new Date().toISOString() })
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
|
||||
const userGroups = await prisma.groupMember.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { groupId: true }
|
||||
})
|
||||
const groupIds = userGroups.map(m => m.groupId)
|
||||
|
||||
// Get all canvases user has access to
|
||||
const canvasList = await prisma.canvas.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: user.id },
|
||||
{ isPublic: true },
|
||||
{ groupId: { in: groupIds } }
|
||||
]
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nodes: true
|
||||
}
|
||||
})
|
||||
|
||||
// Extract tasks from all canvases
|
||||
const allTasks = []
|
||||
|
||||
for (const canvas of canvasList) {
|
||||
const nodes = Array.isArray(canvas.nodes) ? canvas.nodes :
|
||||
(typeof canvas.nodes === 'string' ? JSON.parse(canvas.nodes) : [])
|
||||
|
||||
if (!Array.isArray(nodes)) continue
|
||||
|
||||
const canvasTasks = nodes.filter(n => {
|
||||
const type = n.type || n.data?.type
|
||||
if (type !== 'task') return false
|
||||
|
||||
// Temporary: Show ALL tasks regardless of assignee so you can see them!
|
||||
return true
|
||||
})
|
||||
|
||||
for (const t of canvasTasks) {
|
||||
allTasks.push({
|
||||
...t,
|
||||
canvasId: canvas.id,
|
||||
canvasName: canvas.name
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { tasks: allTasks }
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const body = await readBody(event)
|
||||
const { canvasId, title, priority, state } = body
|
||||
|
||||
if (!canvasId || !title) {
|
||||
throw createError({ statusCode: 400, message: 'Canvas ID and Title are required' })
|
||||
}
|
||||
|
||||
// 1. Получаем канвас
|
||||
const canvas = await prisma.canvas.findUnique({
|
||||
where: { id: canvasId }
|
||||
})
|
||||
|
||||
if (!canvas) {
|
||||
throw createError({ statusCode: 404, message: 'Canvas not found' })
|
||||
}
|
||||
|
||||
// 2. Проверяем права
|
||||
const isOwner = canvas.ownerId === user.id
|
||||
let isMember = false
|
||||
if (canvas.groupId) {
|
||||
const membership = await prisma.groupMember.findFirst({
|
||||
where: { userId: user.id, groupId: canvas.groupId }
|
||||
})
|
||||
isMember = !!membership
|
||||
}
|
||||
|
||||
if (!isOwner && !isMember && user.role !== 'admin') {
|
||||
throw createError({ statusCode: 403, message: 'No permission to add tasks to this canvas' })
|
||||
}
|
||||
|
||||
// 3. Создаем новый узел задачи
|
||||
const nodes = (canvas.nodes as any[]) || []
|
||||
|
||||
// Генерируем позицию (просто в центре или скраю)
|
||||
const lastNode = nodes[nodes.length - 1]
|
||||
const x = lastNode?.position?.x || 100
|
||||
const y = (lastNode?.position?.y || 100) + 100
|
||||
|
||||
const newTaskNode = {
|
||||
id: `task-${Date.now()}`,
|
||||
type: 'task',
|
||||
position: { x, y },
|
||||
data: {
|
||||
text: `# ${title}`,
|
||||
priority: priority || 'medium',
|
||||
state: state || 'todo',
|
||||
createdAt: new Date().toISOString()
|
||||
},
|
||||
// Дублируем поля для удобства фильтрации на бэкенде
|
||||
priority: priority || 'medium',
|
||||
state: state || 'todo',
|
||||
label: title
|
||||
}
|
||||
|
||||
nodes.push(newTaskNode)
|
||||
|
||||
// 4. Сохраняем в БД
|
||||
await prisma.canvas.update({
|
||||
where: { id: canvasId },
|
||||
data: { nodes }
|
||||
})
|
||||
|
||||
return { success: true, task: newTaskNode }
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
|
||||
// 1. Получаем группы пользователя
|
||||
const userGroups = await prisma.groupMember.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { groupId: true }
|
||||
})
|
||||
const groupIds = userGroups.map(g => g.groupId)
|
||||
|
||||
// 2. Получаем все доступные канвасы
|
||||
const canvases = await prisma.canvas.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: user.id },
|
||||
{ groupId: { in: groupIds } },
|
||||
{ isPublic: true }
|
||||
]
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nodes: true
|
||||
}
|
||||
})
|
||||
|
||||
// 3. Собираем задачи из всех канвасов
|
||||
const allTasks: any[] = []
|
||||
|
||||
for (const canvas of canvases) {
|
||||
const nodes = (canvas.nodes as any[]) || []
|
||||
const taskNodes = nodes.filter(node => node.type === 'task')
|
||||
|
||||
taskNodes.forEach(task => {
|
||||
allTasks.push({
|
||||
...task,
|
||||
canvasId: canvas.id,
|
||||
canvasName: canvas.name
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Сортируем: сначала высокий приоритет, потом дата
|
||||
allTasks.sort((a, b) => {
|
||||
const pPriority = { high: 3, medium: 2, low: 1 }
|
||||
const prioA = pPriority[a.priority || a.data?.priority] || 0
|
||||
const prioB = pPriority[b.priority || b.data?.priority] || 0
|
||||
return prioB - prioA
|
||||
})
|
||||
|
||||
return { tasks: allTasks }
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const body = await readBody(event)
|
||||
const { taskId, canvasId, newState } = body
|
||||
|
||||
if (!taskId || !canvasId || !newState) {
|
||||
throw createError({ statusCode: 400, message: 'Missing parameters' })
|
||||
}
|
||||
|
||||
// 1. Получаем канвас
|
||||
const canvas = await prisma.canvas.findUnique({
|
||||
where: { id: canvasId }
|
||||
})
|
||||
|
||||
if (!canvas) {
|
||||
throw createError({ statusCode: 404, message: 'Canvas not found' })
|
||||
}
|
||||
|
||||
// 2. Проверяем права на редактирование
|
||||
const isOwner = canvas.ownerId === user.id
|
||||
let isMember = false
|
||||
if (canvas.groupId) {
|
||||
const membership = await prisma.groupMember.findFirst({
|
||||
where: { userId: user.id, groupId: canvas.groupId }
|
||||
})
|
||||
isMember = !!membership
|
||||
}
|
||||
|
||||
if (!isOwner && !isMember && user.role !== 'admin') {
|
||||
throw createError({ statusCode: 403, message: 'No permission to update this task' })
|
||||
}
|
||||
|
||||
// 3. Обновляем узел внутри массива nodes
|
||||
const nodes = (canvas.nodes as any[]) || []
|
||||
const nodeIdx = nodes.findIndex(n => n.id === taskId)
|
||||
|
||||
if (nodeIdx === -1) {
|
||||
throw createError({ statusCode: 404, message: 'Task node not found in canvas' })
|
||||
}
|
||||
|
||||
// Обновляем состояние
|
||||
nodes[nodeIdx].state = newState
|
||||
if (nodes[nodeIdx].data) {
|
||||
nodes[nodeIdx].data.state = newState
|
||||
}
|
||||
|
||||
// 4. Сохраняем обратно в БД
|
||||
await prisma.canvas.update({
|
||||
where: { id: canvasId },
|
||||
data: { nodes }
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
|
||||
// Находим группы, где пользователь либо владелец, либо участник
|
||||
const groups = await prisma.group.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: user.id },
|
||||
{ members: { some: { userId: user.id } } }
|
||||
]
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
}
|
||||
})
|
||||
|
||||
return { groups }
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const body = await readBody(event)
|
||||
const { displayName, bio, avatar } = body
|
||||
|
||||
// Обновляем данные в таблице Profile
|
||||
const updatedProfile = await prisma.profile.update({
|
||||
where: { userId: user.id },
|
||||
data: {
|
||||
...(displayName !== undefined && { displayName }),
|
||||
...(bio !== undefined && { bio }),
|
||||
...(avatar !== undefined && { avatar })
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true, profile: updatedProfile }
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
|
||||
// 1. Считаем канвасы (личные и где участник)
|
||||
const canvasCount = await prisma.canvas.count({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: user.id },
|
||||
{ groupId: { not: null } } // Тут можно усложнить, но для начала так
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 2. Считаем задачи во всех доступных канвасах
|
||||
const canvases = await prisma.canvas.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: user.id },
|
||||
{ groupId: { not: null } }
|
||||
]
|
||||
},
|
||||
select: { nodes: true }
|
||||
})
|
||||
|
||||
let totalTasks = 0
|
||||
let completedTasks = 0
|
||||
|
||||
for (const canvas of canvases) {
|
||||
const nodes = canvas.nodes
|
||||
if (Array.isArray(nodes)) {
|
||||
const tasks = nodes.filter(n => n.type === 'task')
|
||||
totalTasks += tasks.length
|
||||
completedTasks += tasks.filter(t => (t.data?.state === 'completed' || t.state === 'completed')).length
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Ачивки (пока возвращаем 0, так как модели нет в схеме)
|
||||
const achievementCount = 0
|
||||
|
||||
return {
|
||||
stats: {
|
||||
canvases: canvasCount,
|
||||
tasks: {
|
||||
total: totalTasks,
|
||||
completed: completedTasks
|
||||
},
|
||||
achievements: achievementCount
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { activities } from '~/server/utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getRouterParam(event, 'id')
|
||||
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 400, message: 'User ID is required' })
|
||||
}
|
||||
|
||||
// Get user's recent activities
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ id: userId }, { username: userId }]
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 404, message: 'User not found' })
|
||||
}
|
||||
|
||||
// Get user's activities from database or memory
|
||||
const userActivities = await prisma.activity.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: 20
|
||||
})
|
||||
// Limit to 50 most recent
|
||||
|
||||
return { activities: userActivities }
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { canvases, userGroups } from '~/server/utils/db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getRouterParam(event, 'id')
|
||||
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 400, message: 'User ID is required' })
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ id: userId }, { username: userId }]
|
||||
}
|
||||
})
|
||||
|
||||
// Get user's group memberships
|
||||
const userGroupIds = Array.from(userGroups.values())
|
||||
.filter(membership => membership.userId === userId)
|
||||
.map(membership => membership.groupId)
|
||||
|
||||
// Get canvases owned by user or shared with user's groups
|
||||
const userCanvases = Array.from(canvases.values()).filter(canvas => {
|
||||
// User's own canvases
|
||||
if (canvas.ownerId === userId) return true
|
||||
// Group canvases
|
||||
if (canvas.groupId && userGroupIds.includes(canvas.groupId)) return true
|
||||
return false
|
||||
})
|
||||
|
||||
const canvasList = userCanvases.map(canvas => ({
|
||||
id: canvas.id,
|
||||
name: canvas.name,
|
||||
ownerId: canvas.ownerId,
|
||||
createdAt: canvas.createdAt,
|
||||
updatedAt: canvas.updatedAt,
|
||||
isPublic: canvas.isPublic,
|
||||
groupId: canvas.groupId,
|
||||
nodeCount: canvas.nodes.length
|
||||
}))
|
||||
|
||||
return { canvases: canvasList }
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const currentUser = requireAuth(event)
|
||||
const targetUserIdOrUsername = getRouterParam(event, 'id')
|
||||
|
||||
if (!targetUserIdOrUsername) {
|
||||
throw createError({ statusCode: 400, message: 'User ID is required' })
|
||||
}
|
||||
|
||||
// Находим целевого пользователя (по ID или username)
|
||||
const targetUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: targetUserIdOrUsername },
|
||||
{ username: targetUserIdOrUsername }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
if (!targetUser) {
|
||||
throw createError({ statusCode: 404, message: 'Target user not found' })
|
||||
}
|
||||
|
||||
try {
|
||||
const follow = await prisma.follows.findUnique({
|
||||
where: {
|
||||
followerId_followingId: {
|
||||
followerId: currentUser.id,
|
||||
followingId: targetUser.id
|
||||
}
|
||||
}
|
||||
})
|
||||
return { isFollowing: !!follow }
|
||||
} catch (e) {
|
||||
return { isFollowing: false }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const currentUser = requireAuth(event)
|
||||
const targetIdOrUsername = getRouterParam(event, 'id')
|
||||
|
||||
const targetUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: targetIdOrUsername },
|
||||
{ username: targetIdOrUsername }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
if (!targetUser) {
|
||||
throw createError({ statusCode: 404, message: 'User not found' })
|
||||
}
|
||||
|
||||
if (targetUser.id === currentUser.id) {
|
||||
throw createError({ statusCode: 400, message: 'You cannot follow yourself' })
|
||||
}
|
||||
|
||||
await prisma.follows.upsert({
|
||||
where: {
|
||||
followerId_followingId: {
|
||||
followerId: currentUser.id,
|
||||
followingId: targetUser.id
|
||||
}
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
followerId: currentUser.id,
|
||||
followingId: targetUser.id
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getRouterParam(event, 'id')
|
||||
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 400, message: 'User ID is required' })
|
||||
}
|
||||
|
||||
let profile = await prisma.profile.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userId: userId },
|
||||
{ user: { username: userId } }
|
||||
]
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
_count: {
|
||||
select: { canvases: true, followers: true, following: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// If profile doesn't exist but user does, create it
|
||||
if (!profile) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ id: userId }, { username: userId }]
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 404, message: 'User not found' })
|
||||
}
|
||||
|
||||
profile = await prisma.profile.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
displayName: user.username,
|
||||
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${user.id}`
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
_count: {
|
||||
select: { canvases: true, followers: true, following: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
profile: {
|
||||
...profile,
|
||||
canvasCount: profile.user._count.canvases,
|
||||
followerCount: profile.user._count.followers,
|
||||
followingCount: profile.user._count.following
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineEventHandler } from 'h3'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireAuth(event)
|
||||
if (!user) return []
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
profile: {
|
||||
select: {
|
||||
displayName: true,
|
||||
avatar: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return users.map(u => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
displayName: u.profile?.displayName || u.username,
|
||||
avatar: u.profile?.avatar
|
||||
}))
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAuth(event)
|
||||
const query = getQuery(event).q as string
|
||||
|
||||
if (!query || query.length < 2) {
|
||||
return { users: [] }
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ username: { contains: query, mode: 'insensitive' } },
|
||||
{ email: { contains: query, mode: 'insensitive' } }
|
||||
]
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true
|
||||
},
|
||||
take: 10
|
||||
})
|
||||
|
||||
return { users }
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getListsUseCase } from '../../../modules/notes-lists';
|
||||
import { getAuthUser } from '../../../utils/auth';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = getAuthUser(event);
|
||||
const ownerId = user?.id || (getQuery(event).userId as string) || 'default-user';
|
||||
const category = getQuery(event).category as string | undefined;
|
||||
|
||||
const result = await getListsUseCase.execute({ ownerId, category });
|
||||
if (result.isFailure) {
|
||||
throw createError({ statusCode: 400, message: result.getError().message });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
lists: result.getValue().map(l => l.toJSON())
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { addListItemUseCase } from '../../../modules/notes-lists';
|
||||
import { getAuthUser } from '../../../utils/auth';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = getAuthUser(event);
|
||||
const body = await readBody(event);
|
||||
|
||||
const ownerId = user?.id || body.ownerId || 'default-user';
|
||||
const { title, listId, listTitle, category, description, priority, metadata } = body;
|
||||
|
||||
if (!title) {
|
||||
throw createError({ statusCode: 400, message: 'Item title is required' });
|
||||
}
|
||||
|
||||
const result = await addListItemUseCase.execute({
|
||||
title,
|
||||
listId,
|
||||
listTitle,
|
||||
category,
|
||||
description,
|
||||
priority,
|
||||
metadata,
|
||||
ownerId
|
||||
});
|
||||
|
||||
if (result.isFailure) {
|
||||
const error = result.getError();
|
||||
throw createError({ statusCode: error.statusCode || 400, message: error.message });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
item: result.getValue().toJSON()
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { toggleListItemUseCase } from '../../../../modules/notes-lists';
|
||||
import { getAuthUser } from '../../../../utils/auth';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const itemId = getRouterParam(event, 'id');
|
||||
if (!itemId) {
|
||||
throw createError({ statusCode: 400, message: 'Item ID is required' });
|
||||
}
|
||||
|
||||
const user = getAuthUser(event);
|
||||
const body = await readBody(event).catch(() => ({}));
|
||||
const ownerId = user?.id || body.ownerId || 'default-user';
|
||||
|
||||
const result = await toggleListItemUseCase.execute({
|
||||
itemId,
|
||||
status: body.status,
|
||||
ownerId
|
||||
});
|
||||
|
||||
if (result.isFailure) {
|
||||
const error = result.getError();
|
||||
throw createError({ statusCode: error.statusCode || 400, message: error.message });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
item: result.getValue().toJSON()
|
||||
};
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user