feat: restructure into XFlow monorepo with Nuxt 4, Modular Onion Monolith, Android client, and Desktop CLI

This commit is contained in:
2026-09-28 10:39:19 +05:00
parent edcd30c829
commit be68f5e130
204 changed files with 24616 additions and 39 deletions
+47
View File
@@ -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
+22
View File
@@ -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>
+37
View File
@@ -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>
+46
View File
@@ -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>
+108
View File
@@ -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>
+51
View File
@@ -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>
+89
View File
@@ -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="Какой именно цели я хочу достичь?&#10;Какой результат я хочу получить?&#10;Почему достижение этой цели важно?" 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="Что именно я могу изменить?&#10;Какие ресурсы мне нужны?&#10;Кто мне может помочь?&#10;А что еще?" 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="Какова текущая ситуация?&#10;Какие препятствия я вижу?&#10;Что мне мешает начать?&#10;Какие у меня уже есть ресурсы?" 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="Какие самые важные действия необходимо предпринять?&#10;Что я сделаю в первую очередь? Какие сроки?" 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
}
}
+96
View File
@@ -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
}
}
+116
View File
@@ -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
}
}
+175
View File
@@ -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
}
}
+102
View File
@@ -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
}
}
+49
View File
@@ -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>
+15
View File
@@ -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')
}
})
+6
View File
@@ -0,0 +1,6 @@
export default defineNuxtRouteMiddleware((to, from) => {
const token = useCookie('xflow_token')
if (!token.value && to.path !== '/login') {
return navigateTo('/login')
}
})
+3
View File
@@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>
+3
View File
@@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>
+246
View File
@@ -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">&times;</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>
+173
View File
@@ -0,0 +1,173 @@
<template>
<div class="min-h-screen bg-slate-950 p-6">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold text-slate-100">Управление Группами</h1>
<p class="text-slate-400 mt-1">Создание и управление командами, участниками и правами</p>
</div>
<button
@click="showCreateModal = true"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition flex items-center gap-2 shadow-lg shadow-blue-900/20"
>
<span class="text-xl">+</span> Новая Группа
</button>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
<!-- Groups Grid -->
<div v-else-if="groups.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="group in groups"
:key="group.id"
class="bg-slate-900 border border-slate-800 rounded-2xl p-6 hover:border-slate-700 transition group"
>
<div class="flex items-start justify-between mb-4">
<div class="w-12 h-12 bg-blue-900/30 rounded-xl flex items-center justify-center text-blue-400 text-xl font-bold">
{{ group.name.charAt(0) }}
</div>
<div class="flex gap-2">
<span v-if="!group.isPublic" class="px-2 py-1 bg-amber-900/30 text-amber-400 text-[10px] uppercase font-bold rounded-md border border-amber-900/50">Private</span>
<span class="px-2 py-1 bg-slate-800 text-slate-400 text-[10px] uppercase font-bold rounded-md">{{ group.memberCount }} members</span>
</div>
</div>
<h3 class="text-xl font-semibold text-slate-100 mb-2">{{ group.name }}</h3>
<p class="text-slate-400 text-sm mb-6 line-clamp-2 h-10">{{ group.description || 'Нет описания' }}</p>
<div class="flex items-center justify-between pt-4 border-t border-slate-800">
<div class="flex -space-x-2">
<div v-for="i in Math.min(group.memberCount, 3)" :key="i" class="w-8 h-8 rounded-full border-2 border-slate-900 bg-slate-800 flex items-center justify-center text-[10px] text-slate-400 font-bold">
U{{ i }}
</div>
<div v-if="group.memberCount > 3" class="w-8 h-8 rounded-full border-2 border-slate-900 bg-slate-700 flex items-center justify-center text-[10px] text-slate-300 font-bold">
+{{ group.memberCount - 3 }}
</div>
</div>
<button @click="openGroupSettings(group)" class="px-4 py-1.5 bg-slate-800 hover:bg-slate-700 text-blue-400 rounded-lg text-sm font-medium transition">Управлять</button>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="bg-slate-900 border border-slate-800 border-dashed rounded-3xl p-12 text-center">
<div class="text-6xl mb-4">👥</div>
<h3 class="text-xl font-bold text-slate-200">Групп пока нет</h3>
<p class="text-slate-500 mt-2 max-w-sm mx-auto">Создайте свою первую команду, чтобы работать над проектами совместно.</p>
<button @click="showCreateModal = true" class="mt-6 px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition">Создать группу</button>
</div>
<!-- Create Group Modal -->
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm" @click.self="showCreateModal = false">
<div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md p-6 shadow-2xl">
<h2 class="text-2xl font-bold text-slate-100 mb-4 text-center">Новая Группа</h2>
<div class="space-y-4">
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Название группы</label>
<input
v-model="newGroup.name"
type="text"
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-100 focus:border-blue-500 outline-none transition"
placeholder="Например: Отдел разработки"
/>
</div>
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Описание</label>
<textarea
v-model="newGroup.description"
rows="3"
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-100 focus:border-blue-500 outline-none transition resize-none"
placeholder="Чем занимается эта группа?"
></textarea>
</div>
<div class="flex items-center gap-3 py-2 bg-slate-800/50 p-3 rounded-lg border border-slate-700/50">
<input type="checkbox" v-model="newGroup.isPublic" id="isPublic" class="w-5 h-5 rounded border-slate-700 bg-slate-800 text-blue-600 focus:ring-blue-500 cursor-pointer">
<label for="isPublic" class="text-slate-300 select-none cursor-pointer text-sm">Сделать группу публичной (доступной всем)</label>
</div>
</div>
<div class="flex gap-3 mt-8">
<button
@click="showCreateModal = false"
class="flex-1 px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl font-bold transition"
>
Отмена
</button>
<button
@click="createGroup"
class="flex-1 px-4 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition shadow-lg shadow-blue-900/40"
:disabled="creating"
>
{{ creating ? 'Создание...' : 'Создать' }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
middleware: ['auth', 'admin']
})
const auth = useAuth()
const groups = ref([])
const showCreateModal = ref(false)
const loading = ref(false)
const creating = ref(false)
const newGroup = reactive({
name: '',
description: '',
isPublic: true
})
const fetchGroups = async () => {
loading.value = true
try {
const data = await $fetch('/api/groups', {
headers: { Authorization: `Bearer ${auth.token.value}` }
})
groups.value = data.groups || []
} catch (e) {
console.error('[Groups Page] Failed to fetch groups', e)
} finally {
loading.value = false
}
}
const createGroup = async () => {
if (!newGroup.name.trim()) return
creating.value = true
try {
await $fetch('/api/groups', {
method: 'POST',
body: newGroup,
headers: { Authorization: `Bearer ${auth.token.value}` }
})
showCreateModal.value = false
newGroup.name = ''
newGroup.description = ''
await fetchGroups()
} catch (e) {
alert('Ошибка при создании группы: ' + (e.data?.message || e.message))
} finally {
creating.value = false
}
}
const openGroupSettings = (group) => {
navigateTo(`/admin/groups/${group.id}`)
}
onMounted(() => {
fetchGroups()
})
</script>
+153
View File
@@ -0,0 +1,153 @@
<template>
<div class="min-h-screen bg-slate-950 p-6">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-slate-100">XFlow Admin Panel</h1>
<div class="flex items-center gap-2 text-sm text-slate-400">
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
Online
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-blue-400">{{ stats.users }}</div>
<div class="text-sm text-slate-400 mt-1">Registered Users</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-emerald-400">{{ stats.canvases }}</div>
<div class="text-sm text-slate-400 mt-1">Canvases</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-yellow-400">{{ stats.nodes }}</div>
<div class="text-sm text-slate-400 mt-1">Total Nodes</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-purple-400">{{ stats.pushes }}</div>
<div class="text-sm text-slate-400 mt-1">Push Subscribers</div>
</div>
</div>
<!-- Quick Actions -->
<div class="flex gap-4 mb-6">
<NuxtLink
to="/admin/groups"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition flex items-center gap-2"
>
👥 Manage Groups & Teams
</NuxtLink>
<button
@click="refreshData"
class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg font-medium transition"
>
🔄 Refresh Data
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Users -->
<div class="lg:col-span-2 bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
<h2 class="text-lg font-semibold text-slate-200">Users</h2>
<button @click="refreshData" class="text-sm text-blue-400 hover:text-blue-300">Refresh</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-slate-800 text-slate-400">
<tr>
<th class="text-left p-3">Email</th>
<th class="text-left p-3">Username</th>
<th class="text-left p-3">Role</th>
<th class="text-left p-3">ID</th>
</tr>
</thead>
<tbody class="text-slate-300">
<tr v-for="user in users" :key="user.id" class="border-t border-slate-800">
<td class="p-3">{{ user.email }}</td>
<td class="p-3">{{ user.username }}</td>
<td class="p-3">
<span :class="roleBadge(user.role)">{{ user.role }}</span>
</td>
<td class="p-3 text-xs text-slate-500 font-mono">{{ user.id }}</td>
</tr>
<tr v-if="users.length === 0">
<td colspan="4" class="p-6 text-center text-slate-500">No users found</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Canvases -->
<div class="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
<div class="p-4 border-b border-slate-700">
<h2 class="text-lg font-semibold text-slate-200">Canvases</h2>
</div>
<div class="divide-y divide-slate-800">
<div v-for="canvas in canvases" :key="canvas.id" class="p-3">
<div class="flex items-center justify-between">
<NuxtLink :to="`/canvas/${canvas.id}`" class="text-blue-400 hover:text-blue-300 font-medium">
{{ canvas.name }}
</NuxtLink>
<span class="text-xs text-slate-500">{{ canvas.nodeCount }} nodes</span>
</div>
<div class="text-xs text-slate-500 mt-1">{{ formatDate(canvas.updatedAt) }}</div>
</div>
<div v-if="canvases.length === 0" class="p-6 text-center text-slate-500">
No canvases yet
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
middleware: ['auth', 'admin']
})
const users = ref([])
const canvases = ref([])
const stats = reactive({ users: 0, canvases: 0, nodes: 0, pushes: 0 })
const auth = useAuth()
const refreshData = async () => {
if (!auth.token.value) return
try {
const data = await $fetch('/api/admin/stats', {
headers: { Authorization: `Bearer ${auth.token.value}` }
})
users.value = data.users || []
canvases.value = data.canvases || []
stats.users = data.stats?.users || 0
stats.canvases = data.stats?.canvases || 0
stats.nodes = data.stats?.nodes || 0
stats.pushes = data.stats?.pushes || 0
} catch (e) {
console.error('Failed to load admin data', e)
}
}
const roleBadge = (role) => {
return role === 'admin'
? 'px-2 py-0.5 bg-red-900/50 text-red-300 rounded text-xs'
: 'px-2 py-0.5 bg-slate-700 text-slate-300 rounded text-xs'
}
const formatDate = (date) => {
if (!date) return 'N/A'
return new Date(date).toLocaleDateString('ru-RU')
}
onMounted(async () => {
// Ждем инициализации пользователя если нужно
if (!auth.user.value) {
await auth.initAuth()
}
refreshData()
})
</script>
+16
View File
@@ -0,0 +1,16 @@
<template>
<div class="h-screen w-full">
<ClientOnly>
<FlowEditor :canvas-id="canvasId" />
</ClientOnly>
</div>
</template>
<script setup>
definePageMeta({
middleware: 'auth'
})
const route = useRoute()
const canvasId = computed(() => route.params.id || 'demo')
</script>
+95
View File
@@ -0,0 +1,95 @@
<template>
<div class="min-h-screen bg-slate-950">
<div class="max-w-6xl mx-auto p-6">
<h1 class="text-3xl font-bold text-slate-100 mb-6">Канвасы</h1>
<!-- Canvas Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="canvas in canvases" :key="canvas.id"
class="bg-slate-900 border border-slate-700 rounded-xl p-6 hover:border-blue-500 transition-colors">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-slate-100">{{ canvas.name }}</h3>
<p class="text-sm text-slate-400">{{ canvas.description }}</p>
</div>
<div class="text-xs text-slate-500">
{{ new Date(canvas.updatedAt).toLocaleDateString('ru-RU') }}
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="px-2 py-1 bg-slate-800 text-slate-300 rounded text-xs">
{{ canvas.isPublic ? 'Публичный' : 'Личный' }}
</span>
<span class="px-2 py-1 bg-slate-800 text-slate-300 rounded text-xs">
{{ canvas.nodes?.length || 0 }} узлов
</span>
</div>
<NuxtLink :to="`/canvas/${canvas.id}`"
class="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm transition-colors">
Открыть
</NuxtLink>
</div>
</div>
<!-- Create New Canvas -->
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 border-dashed hover:border-blue-500 transition-colors">
<div class="flex flex-col items-center justify-center h-full min-h-[200px]">
<div class="text-4xl mb-4">+</div>
<h3 class="text-lg font-semibold text-slate-100 mb-2">Создать канвас</h3>
<p class="text-sm text-slate-400 text-center mb-4">
Создайте новую ментальную карту
</p>
<button @click="createNewCanvas"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm transition-colors">
Создать
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useAuth } from '~/composables/useAuth'
const auth = useAuth()
const router = useRouter()
// Mock canvases data - replace with actual API call
const canvases = ref([
{
id: 'xflow-project',
name: 'Обзор Проекта XFlow',
description: 'Основная ментальная карта проекта',
isPublic: true,
updatedAt: new Date().toISOString(),
nodes: 11
},
{
id: 'demo',
name: 'Демо канвас',
description: 'Пример ментальной карты',
isPublic: true,
updatedAt: new Date().toISOString(),
nodes: 5
}
])
const createNewCanvas = () => {
// Redirect to flow editor with new canvas
router.push('/canvas/new-canvas')
}
// Redirect to specific canvas if ID is provided
onMounted(() => {
if (auth.isAuthenticated.value) {
// Load user's canvases
console.log('Loading canvases for user:', auth.user.value?.email)
}
})
</script>
+440
View File
@@ -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>
+13
View File
@@ -0,0 +1,13 @@
<template>
<div class="h-screen w-full">
<ClientOnly>
<FlowEditor canvas-id="demo" />
</ClientOnly>
</div>
</template>
<script setup>
definePageMeta({
middleware: 'auth'
})
</script>
+74
View File
@@ -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>
+67
View File
@@ -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>
+356
View File
@@ -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>
+104
View File
@@ -0,0 +1,104 @@
<template>
<div class="min-h-screen bg-slate-950">
<div class="max-w-4xl mx-auto p-6">
<!-- Profile Header -->
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 mb-6">
<div class="flex items-start gap-4">
<img :src="profile.avatar" :alt="profile.displayName"
class="w-20 h-20 rounded-full bg-slate-800" />
<div class="flex-1">
<h1 class="text-2xl font-bold text-slate-100">{{ profile.displayName }}</h1>
<p class="text-slate-400">{{ profile.email }}</p>
<p class="text-sm text-slate-500 mt-2">{{ profile.bio }}</p>
</div>
<div class="text-right">
<div class="text-sm text-slate-500">Участник с</div>
<div class="text-slate-300">{{ new Date(profile.createdAt).toLocaleDateString('ru-RU') }}</div>
</div>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-2xl font-bold text-blue-400">{{ stats.canvases }}</div>
<div class="text-sm text-slate-400">Канвасов</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-2xl font-bold text-green-400">{{ stats.nodes }}</div>
<div class="text-sm text-slate-400">Узлов создано</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-2xl font-bold text-purple-400">{{ stats.groups }}</div>
<div class="text-sm text-slate-400">Групп</div>
</div>
</div>
<!-- Recent Canvases -->
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6">
<h2 class="text-xl font-semibold text-slate-100 mb-4">Последние канвасы</h2>
<div class="space-y-3">
<div v-for="canvas in recentCanvases" :key="canvas.id"
class="flex items-center justify-between p-3 bg-slate-800 rounded-lg">
<div>
<h3 class="font-medium text-slate-100">{{ canvas.name }}</h3>
<p class="text-sm text-slate-400">{{ canvas.description }}</p>
</div>
<NuxtLink :to="`/canvas/${canvas.id}`"
class="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm transition-colors">
Открыть
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useAuth } from '~/composables/useAuth'
const route = useRoute()
const auth = useAuth()
const userId = route.params.id
// Mock profile data - replace with actual API call
const profile = ref({
id: userId,
displayName: 'Пользователь XFlow',
email: 'user@example.com',
bio: 'Люблю создавать ментальные карты и организовывать идеи',
avatar: 'https://via.placeholder.com/80',
createdAt: new Date('2024-01-01').toISOString()
})
const stats = ref({
canvases: 12,
nodes: 156,
groups: 3
})
const recentCanvases = ref([
{
id: 'xflow-project',
name: 'Обзор Проекта XFlow',
description: 'Основная ментальная карта проекта'
},
{
id: 'demo',
name: 'Демо канвас',
description: 'Пример ментальной карты'
},
{
id: 'ideas',
name: 'Идеи 2024',
description: 'Сбор идей и мыслей'
}
])
onMounted(() => {
// Load profile data
console.log('Loading profile for user:', userId)
})
</script>
+208
View File
@@ -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>
+311
View File
@@ -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>
+23
View File
@@ -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)
})
}
}
})
}
})
+4
View File
@@ -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

+34
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+97
View File
@@ -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 }
}
+78
View File
@@ -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'
})
+19226
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -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"
}
}
+12
View File
@@ -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",
},
});
+147
View File
@@ -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])
}
+97
View File
@@ -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
}
}
})
+47
View File
@@ -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
}
}
})
+170
View File
@@ -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'
})
}
})
+93
View File
@@ -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
}
}
})
+17
View File
@@ -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' }
}
})
+34
View File
@@ -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' }
})
+31
View File
@@ -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 }
})
+46
View File
@@ -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 }
})
+46
View File
@@ -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
}
})
+32
View File
@@ -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 }
})
+36
View File
@@ -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' })
}
})
+68
View File
@@ -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' })
}
})
+23
View File
@@ -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' })
}
})
+45
View File
@@ -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'
}
}
})
+17
View File
@@ -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' })
}
})
+30
View File
@@ -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)
}))
}
})
+29
View File
@@ -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 }
})
+26
View File
@@ -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'
})
}
})
+14
View File
@@ -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 }
})
+21
View File
@@ -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 }
})
+56
View File
@@ -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 }
})
+69
View File
@@ -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 }
})
+55
View File
@@ -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 }
})
+21
View File
@@ -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 }
})
+20
View File
@@ -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 }
})
+53
View File
@@ -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
}
}
})
+32
View File
@@ -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
}))
})
+28
View File
@@ -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 }
})
+18
View File
@@ -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