feat: prisma schema, groups API, deploy workflow
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<TransitionGroup
|
||||
name="achievement"
|
||||
tag="div"
|
||||
class="fixed bottom-4 right-4 z-50 flex flex-col gap-2"
|
||||
>
|
||||
<div
|
||||
v-for="ach in visibleAchievements"
|
||||
:key="ach.id"
|
||||
class="bg-slate-800 border border-yellow-500/50 rounded-xl p-4 shadow-2xl max-w-sm flex items-start gap-3"
|
||||
>
|
||||
<div class="text-3xl">{{ ach.icon || '🏆' }}</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-yellow-400 text-sm">{{ ach.title }}</h4>
|
||||
<p class="text-xs text-slate-400 mt-0.5">{{ ach.description }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="dismiss(ach.id)"
|
||||
class="text-slate-500 hover:text-slate-300 ml-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
achievements: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const visibleAchievements = ref([])
|
||||
|
||||
watch(() => props.achievements, (newAchievements) => {
|
||||
const incoming = newAchievements.filter(a => !visibleAchievements.value.find(v => v.id === a.id))
|
||||
incoming.forEach(ach => {
|
||||
visibleAchievements.value.push(ach)
|
||||
setTimeout(() => dismiss(ach.id), 5000)
|
||||
})
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
const dismiss = (id) => {
|
||||
visibleAchievements.value = visibleAchievements.value.filter(a => a.id !== id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.achievement-enter-active,
|
||||
.achievement-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.achievement-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%) scale(0.9);
|
||||
}
|
||||
|
||||
.achievement-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%) scale(0.9);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,525 @@
|
||||
<template>
|
||||
<div class="h-screen w-full flex bg-slate-950">
|
||||
<!-- Node Palette -->
|
||||
<div class="w-56 bg-slate-900 border-r border-slate-700 flex flex-col shrink-0 z-20">
|
||||
<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>
|
||||
|
||||
<!-- Canvas Area -->
|
||||
<div class="flex-1 flex flex-col relative min-w-0">
|
||||
<!-- Toolbar -->
|
||||
<div class="h-10 bg-slate-900 border-b border-slate-700 flex items-center px-3 gap-2 z-20">
|
||||
<span class="text-sm font-semibold text-slate-300 mr-2">{{ canvasName }}</span>
|
||||
<span v-if="unsavedChanges.isDirty" class="px-2 py-1 bg-yellow-600 text-yellow-100 rounded text-xs font-medium">
|
||||
● Несохранено
|
||||
</span>
|
||||
<button @click="addNodeAtCenter" class="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs">+ Узел</button>
|
||||
<button @click="saveCanvas" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs">Сохранить</button>
|
||||
<button @click="exportObsidian" class="px-2 py-1 bg-purple-600 hover:bg-purple-500 text-white rounded text-xs">Экспорт</button>
|
||||
<button @click="deleteSelected" :disabled="!selectedNode" class="px-2 py-1 bg-red-600 hover:bg-red-500 disabled:bg-slate-700 disabled:text-slate-500 text-white rounded text-xs">Удалить</button>
|
||||
<div class="flex-1"></div>
|
||||
<button @click="manualGitSync" :disabled="gitSync.isSyncing.value"
|
||||
class="px-2 py-1 bg-orange-600 hover:bg-orange-500 disabled:bg-slate-700 disabled:text-slate-500 text-white rounded text-xs flex items-center gap-1">
|
||||
<span>{{ gitSync.syncStatusIcon.value }}</span>
|
||||
Git
|
||||
</button>
|
||||
<button @click="fitView" class="px-2 py-1 bg-slate-700 hover:bg-slate-600 text-slate-200 rounded text-xs">Расположить</button>
|
||||
</div>
|
||||
|
||||
<!-- Vue Flow Canvas -->
|
||||
<div class="flex-1 relative w-full h-full" @drop="onDrop" @dragover.prevent>
|
||||
<VueFlow v-model="elements" :default-zoom="0.8" :min-zoom="0.2" :max-zoom="2" fit-view-on-init
|
||||
@node-double-click="onNodeDoubleClick" @node-click="onNodeClick" @pane-click="onPaneClick" @connect="onConnect"
|
||||
@node-drag-start="onNodeDragStart" @node-drag="onNodeDrag" @node-drag-stop="onNodeDragStop"
|
||||
class="bg-slate-950 w-full h-full">
|
||||
<Background pattern-color="#334155" :gap="20" />
|
||||
<Controls />
|
||||
<MiniMap pannable zoomable class="bg-slate-800 border-slate-700" />
|
||||
<template #node-text="{ data, id }">
|
||||
<FlowNodeRenderer :id="id" :data="data" type="text" :selected="selectedNode?.id === id" @update="updateNodeData" />
|
||||
</template>
|
||||
<template #node-file="{ data, id }">
|
||||
<FlowNodeRenderer :id="id" :data="data" type="file" :selected="selectedNode?.id === id" @update="updateNodeData" />
|
||||
</template>
|
||||
<template #node-task="{ data, id }">
|
||||
<FlowNodeRenderer :id="id" :data="data" type="task" :selected="selectedNode?.id === id" @update="updateNodeData" />
|
||||
</template>
|
||||
<template #node-ai="{ data, id }">
|
||||
<FlowNodeRenderer :id="id" :data="data" type="ai" :selected="selectedNode?.id === id" @update="updateNodeData" />
|
||||
</template>
|
||||
<template #node-webhook="{ data, id }">
|
||||
<FlowNodeRenderer :id="id" :data="data" type="webhook" :selected="selectedNode?.id === id" @update="updateNodeData" />
|
||||
</template>
|
||||
<template #node-git="{ data, id }">
|
||||
<FlowNodeRenderer :id="id" :data="data" type="git" :selected="selectedNode?.id === id" @update="updateNodeData" />
|
||||
</template>
|
||||
</VueFlow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Properties Panel -->
|
||||
<div class="w-60 bg-slate-900 border-l border-slate-700 flex flex-col shrink-0 z-20">
|
||||
<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="selectedNode" class="space-y-3">
|
||||
<div>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">ID</label>
|
||||
<input :value="selectedNode.id" disabled class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-400 font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Тип</label>
|
||||
<input :value="selectedNode.type" disabled class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-300 capitalize" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Название</label>
|
||||
<input v-model="selectedNode.data.label" @blur="commitNodeChange" 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="selectedNode.data.text !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Текст</label>
|
||||
<textarea v-model="selectedNode.data.text" @blur="commitNodeChange" 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="selectedNode.data.file !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Файл</label>
|
||||
<input v-model="selectedNode.data.file" @blur="commitNodeChange" 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="selectedNode.data.state !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Состояние</label>
|
||||
<select v-model="selectedNode.data.state" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option value="pending">Ожидает</option>
|
||||
<option value="in_progress">В процессе</option>
|
||||
<option value="completed">Завершено</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="selectedNode.data.assigned_to !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Назначено</label>
|
||||
<input v-model="selectedNode.data.assigned_to" @blur="commitNodeChange" 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="selectedNode.data.alarm !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Напоминание</label>
|
||||
<input v-model="selectedNode.data.alarm" @blur="commitNodeChange" 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="selectedNode.data.script_id !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Скрипт</label>
|
||||
<input v-model="selectedNode.data.script_id" @blur="commitNodeChange" 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="selectedNode.data.prompt !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">AI Запрос</label>
|
||||
<textarea v-model="selectedNode.data.prompt" @blur="commitNodeChange" 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="selectedNode.data.width" @blur="commitNodeChange" 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="selectedNode.data.height" @blur="commitNodeChange" 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>
|
||||
</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 gitSync = useGitSync()
|
||||
const unsavedChanges = useUnsavedChanges()
|
||||
const auth = useAuth()
|
||||
const authHeaders = computed(() => ({ Authorization: `Bearer ${auth.token.value}` }))
|
||||
const elements = ref([])
|
||||
const selectedNode = ref(null)
|
||||
const canvasName = computed(() => props.canvasId)
|
||||
|
||||
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 синхронизация' }
|
||||
]
|
||||
|
||||
// Load canvas from API
|
||||
const loadData = async () => {
|
||||
const data = await loadCanvas(props.canvasId)
|
||||
elements.value = transformToFlowElements(data)
|
||||
// Mark initial state as saved
|
||||
unsavedChanges.markAsSaved(data)
|
||||
}
|
||||
|
||||
// Transform Obsidian canvas to Vue Flow elements
|
||||
const transformToFlowElements = (canvasData) => {
|
||||
const nodes = (canvasData.nodes || []).map(node => ({
|
||||
id: node.id,
|
||||
type: node.type === 'file' ? 'file' : node.type === 'ai' ? 'ai' : node.type === 'git' ? 'git' : 'text',
|
||||
position: { x: node.x, y: node.y },
|
||||
data: {
|
||||
...node,
|
||||
width: node.width || 300,
|
||||
height: node.height || 200,
|
||||
label: node.label || node.text?.slice(0, 20) || node.file || 'Node'
|
||||
},
|
||||
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 => ({
|
||||
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
|
||||
const transformToObsidianCanvas = (flowElements) => {
|
||||
const nodes = flowElements.filter(el => el.position !== undefined).map(node => ({
|
||||
id: node.id,
|
||||
type: node.type === 'file' ? 'file' : 'text',
|
||||
x: Math.round(node.position.x),
|
||||
y: Math.round(node.position.y),
|
||||
width: node.data.width || 300,
|
||||
height: node.data.height || 200,
|
||||
text: node.data.text,
|
||||
file: node.data.file,
|
||||
state: node.data.state,
|
||||
assigned_to: node.data.assigned_to,
|
||||
alarm: node.data.alarm,
|
||||
script_id: node.data.script_id,
|
||||
prompt: node.data.prompt,
|
||||
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 }
|
||||
}
|
||||
|
||||
// Drag and drop handlers
|
||||
const onDragStart = (event, nodeType) => {
|
||||
event.dataTransfer.setData('application/vueflow', JSON.stringify(nodeType))
|
||||
}
|
||||
|
||||
const onDrop = (event) => {
|
||||
const data = JSON.parse(event.dataTransfer.getData('application/vueflow'))
|
||||
if (!data) return
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const x = event.clientX - rect.left
|
||||
const y = event.clientY - rect.top
|
||||
createNode(data.type, x, y)
|
||||
}
|
||||
|
||||
// Create new node
|
||||
const createNode = (type, x, y) => {
|
||||
const id = `node-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const nodeData = {
|
||||
id,
|
||||
type,
|
||||
position: { x: x - 150, y: y - 100 },
|
||||
data: {
|
||||
width: 300,
|
||||
height: 200,
|
||||
label: `${type.charAt(0).toUpperCase() + type.slice(1)} Node`
|
||||
}
|
||||
}
|
||||
|
||||
// Add type-specific defaults
|
||||
switch (type) {
|
||||
case 'text':
|
||||
nodeData.data.text = '# New Note\n\nEnter your text here'
|
||||
break
|
||||
case 'file':
|
||||
nodeData.data.file = 'docs/file.md'
|
||||
break
|
||||
case 'task':
|
||||
nodeData.data.state = 'pending'
|
||||
nodeData.data.assigned_to = ''
|
||||
nodeData.data.alarm = ''
|
||||
nodeData.data.script_id = ''
|
||||
break
|
||||
case 'ai':
|
||||
nodeData.data.prompt = 'Analyze this content'
|
||||
break
|
||||
case 'webhook':
|
||||
nodeData.data.url = 'https://api.example.com'
|
||||
nodeData.data.method = 'POST'
|
||||
break
|
||||
case 'git':
|
||||
nodeData.data.repo = 'xflow-maps'
|
||||
nodeData.data.branch = 'main'
|
||||
nodeData.data.message = 'Update mind map'
|
||||
break
|
||||
}
|
||||
|
||||
elements.value.push(nodeData)
|
||||
}
|
||||
|
||||
// Node selection and editing
|
||||
const onNodeClick = ({ node }) => {
|
||||
selectedNode.value = node
|
||||
}
|
||||
|
||||
const onPaneClick = () => {
|
||||
selectedNode.value = null
|
||||
}
|
||||
|
||||
const updateNodeData = ({ id, data }) => {
|
||||
const node = elements.value.find(el => el.id === id)
|
||||
if (node) {
|
||||
node.data = { ...node.data, ...data }
|
||||
}
|
||||
}
|
||||
|
||||
const commitNodeChange = () => {
|
||||
if (selectedNode.value) {
|
||||
// Trigger reactivity
|
||||
const idx = elements.value.findIndex(el => el.id === selectedNode.value.id)
|
||||
if (idx !== -1) {
|
||||
elements.value.splice(idx, 1, { ...elements.value[idx] })
|
||||
}
|
||||
// Track changes
|
||||
unsavedChanges.trackChanges(transformToObsidianCanvas(elements.value))
|
||||
}
|
||||
}
|
||||
|
||||
// Node actions
|
||||
const addNodeAtCenter = () => {
|
||||
createNode('text', 400, 300)
|
||||
}
|
||||
|
||||
const deleteSelected = () => {
|
||||
if (selectedNode.value) {
|
||||
elements.value = elements.value.filter(el => el.id !== selectedNode.value.id)
|
||||
selectedNode.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const onNodeDragStart = (_event) => {}
|
||||
|
||||
const onNodeDrag = (_event) => {}
|
||||
|
||||
const onNodeDragStop = (event) => {
|
||||
const node = event.node
|
||||
|
||||
// Update node data position
|
||||
if (node.data) {
|
||||
node.data.x = node.position.x
|
||||
node.data.y = node.position.y
|
||||
}
|
||||
|
||||
// Track changes
|
||||
unsavedChanges.trackChanges(transformToObsidianCanvas(elements.value))
|
||||
|
||||
console.log('Node drag stopped:', node.id, 'at position:', node.position)
|
||||
}
|
||||
|
||||
const onNodeDoubleClick = (event) => {
|
||||
const node = event.node
|
||||
const currentState = node.data?.state || 'pending'
|
||||
const nextState = currentState === 'pending' ? 'in_progress' : currentState === 'in_progress' ? 'completed' : 'pending'
|
||||
updateNodeData({ id: node.id, data: { state: nextState } })
|
||||
}
|
||||
|
||||
const onConnect = (connection) => {
|
||||
const edge = {
|
||||
id: `edge-${Date.now()}`,
|
||||
source: connection.source,
|
||||
target: connection.target,
|
||||
sourceHandle: connection.sourceHandle,
|
||||
targetHandle: connection.targetHandle,
|
||||
animated: true,
|
||||
style: { stroke: '#475569', strokeWidth: 2 }
|
||||
}
|
||||
elements.value.push(edge)
|
||||
}
|
||||
|
||||
// Canvas actions (moved to enhanced version below with Git sync)
|
||||
|
||||
const exportObsidian = () => {
|
||||
const obsidianCanvas = transformToObsidianCanvas(elements.value)
|
||||
const blob = new Blob([JSON.stringify(obsidianCanvas, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${props.canvasId}.canvas`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const fitView = () => {
|
||||
const padding = 40
|
||||
const cols = 3
|
||||
|
||||
// Read actual unscaled sizes from DOM using offsetWidth/offsetHeight
|
||||
// These are NOT affected by CSS transforms (zoom)
|
||||
const domSizes = {}
|
||||
const domNodes = document.querySelectorAll('.vue-flow__node')
|
||||
domNodes.forEach(el => {
|
||||
const id = el.getAttribute('data-id')
|
||||
if (id) {
|
||||
domSizes[id] = { width: el.offsetWidth, height: el.offsetHeight }
|
||||
}
|
||||
})
|
||||
|
||||
const nodes = elements.value.filter(el => el.id && !el.source && !el.target)
|
||||
|
||||
// Get real unscaled size from DOM
|
||||
const getSize = (node) => {
|
||||
const dom = domSizes[node.id]
|
||||
if (dom && dom.width > 0 && dom.height > 0) {
|
||||
return dom
|
||||
}
|
||||
return { width: node.data?.width || 320, height: node.data?.height || 200 }
|
||||
}
|
||||
|
||||
// Grid layout: row by row, max height per row
|
||||
const placed = []
|
||||
let y = padding
|
||||
|
||||
for (let i = 0; i < nodes.length; i += cols) {
|
||||
const rowNodes = nodes.slice(i, i + cols)
|
||||
let x = padding
|
||||
let rowMaxHeight = 0
|
||||
|
||||
for (const node of rowNodes) {
|
||||
const size = getSize(node)
|
||||
placed.push({ id: node.id, x, y, w: size.width, h: size.height })
|
||||
x += size.width + padding
|
||||
rowMaxHeight = Math.max(rowMaxHeight, size.height)
|
||||
}
|
||||
|
||||
y += rowMaxHeight + padding
|
||||
}
|
||||
|
||||
// Apply
|
||||
elements.value = elements.value.map(el => {
|
||||
if (el.source && el.target) return el
|
||||
const p = placed.find(n => n.id === el.id)
|
||||
if (!p) return el
|
||||
return {
|
||||
...el,
|
||||
position: { x: p.x, y: p.y },
|
||||
data: { ...el.data, x: p.x, y: p.y }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Git synchronization functions
|
||||
const createGitRepo = async () => {
|
||||
try {
|
||||
await gitSync.createRepository(props.canvasId, 'personal')
|
||||
console.log('Git repository created successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to create Git repository:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const manualGitSync = async () => {
|
||||
try {
|
||||
const canvasData = transformToObsidianCanvas(elements.value)
|
||||
await gitSync.autoSyncCanvas(props.canvasId, canvasData)
|
||||
} catch (error) {
|
||||
console.error('Manual Git sync failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced save function with Git sync
|
||||
const saveCanvas = async () => {
|
||||
try {
|
||||
const obsidianCanvas = transformToObsidianCanvas(elements.value)
|
||||
await $fetch(`/api/canvas/${props.canvasId}`, {
|
||||
method: 'PUT',
|
||||
body: obsidianCanvas,
|
||||
headers: authHeaders.value
|
||||
})
|
||||
|
||||
// Mark as saved
|
||||
unsavedChanges.markAsSaved(obsidianCanvas)
|
||||
|
||||
// Auto-sync to Git if repository exists
|
||||
await gitSync.autoSyncCanvas(props.canvasId, obsidianCanvas)
|
||||
|
||||
console.log('Canvas saved successfully')
|
||||
} catch (err) {
|
||||
console.error('Failed to save canvas:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Load data on mount
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
// Check for existing Git repository
|
||||
await gitSync.checkRepository(props.canvasId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.vue-flow__node) {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
height: auto !important;
|
||||
}
|
||||
:deep(.vue-flow__edge) {
|
||||
stroke: #475569;
|
||||
}
|
||||
:deep(.vue-flow__controls) {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
:deep(.vue-flow__controls button) {
|
||||
background: #334155;
|
||||
border: none;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
:deep(.vue-flow__controls button:hover) {
|
||||
background: #475569;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,249 @@
|
||||
<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="data.state" :class="stateBadgeClass">
|
||||
{{ stateLabel }}
|
||||
</div>
|
||||
</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'])
|
||||
|
||||
// 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'
|
||||
} else if (props.data.state === 'in_progress') {
|
||||
baseStyle.borderColor = '#f59e0b'
|
||||
}
|
||||
|
||||
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-yellow-600 bg-yellow-950/20'
|
||||
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-yellow-800/50 text-yellow-300 rounded text-[10px] font-medium'
|
||||
return 'px-1.5 py-0.5 bg-slate-700 text-slate-400 rounded text-[10px] font-medium'
|
||||
})
|
||||
|
||||
const stateLabel = computed(() => {
|
||||
const state = props.data.state
|
||||
if (state === 'completed') return '✓'
|
||||
if (state === 'in_progress') return '⟳'
|
||||
return '○'
|
||||
})
|
||||
|
||||
// Get node title from text content
|
||||
const getNodeTitle = () => {
|
||||
if (props.data.text) {
|
||||
const lines = props.data.text.split('\n')
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('# ')) {
|
||||
return trimmed.replace('# ', '')
|
||||
}
|
||||
}
|
||||
// If no h1 found, use first non-empty line
|
||||
const firstLine = lines.find(l => l.trim())
|
||||
return firstLine ? firstLine.trim().slice(0, 30) : 'Node'
|
||||
}
|
||||
if (props.data.file) return props.data.file
|
||||
return 'Node'
|
||||
}
|
||||
|
||||
// Format text with markdown support
|
||||
const formattedText = computed(() => {
|
||||
if (!props.data.text) return ''
|
||||
|
||||
let text = props.data.text
|
||||
|
||||
// Convert markdown headers
|
||||
text = text.replace(/^# (.*$)/gim, '<h1 class="text-lg font-bold text-white mb-2">$1</h1>')
|
||||
text = text.replace(/^## (.*$)/gim, '<h2 class="text-base font-semibold text-white mb-2">$1</h2>')
|
||||
text = text.replace(/^### (.*$)/gim, '<h3 class="text-sm font-medium text-white mb-1">$1</h3>')
|
||||
|
||||
// Convert bold text
|
||||
text = text.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold text-white">$1</strong>')
|
||||
|
||||
// Convert italic text
|
||||
text = text.replace(/\*(.*?)\*/g, '<em class="italic text-slate-200">$1</em>')
|
||||
|
||||
// Convert code blocks
|
||||
text = text.replace(/```(.*?)```/gs, '<pre class="bg-slate-900 p-2 rounded text-xs font-mono text-slate-300 mb-2 overflow-x-auto"><code>$1</code></pre>')
|
||||
|
||||
// Convert inline code
|
||||
text = text.replace(/`(.*?)`/g, '<code class="bg-slate-700 px-1 rounded text-xs font-mono text-slate-300">$1</code>')
|
||||
|
||||
// Convert lists
|
||||
text = text.replace(/^- (.*$)/gim, '<li class="ml-4 text-slate-200">• $1</li>')
|
||||
text = text.replace(/^ - (.*$)/gim, '<li class="ml-8 text-slate-300">◦ $1</li>')
|
||||
|
||||
// Convert line breaks
|
||||
text = text.replace(/\n\n/g, '</p><p class="mb-2 text-slate-200">')
|
||||
text = text.replace(/\n/g, '<br>')
|
||||
|
||||
// Wrap in paragraphs
|
||||
if (!text.includes('<p>')) {
|
||||
text = '<p class="mb-2 text-slate-200">' + text + '</p>'
|
||||
}
|
||||
|
||||
return text
|
||||
})
|
||||
|
||||
// Format alarm time
|
||||
const formatAlarm = (alarm) => {
|
||||
try {
|
||||
const date = new Date(alarm)
|
||||
return date.toLocaleDateString('ru-RU', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
} catch {
|
||||
return alarm
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #475569 #1e293b;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,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>
|
||||
Reference in New Issue
Block a user