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