@@ -29,6 +29,11 @@
< / 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 ="deleteCanvas" class = "p-2 text-slate-400 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition" title = "Удалить карту" >
< svg xmlns = "http://www.w3.org/2000/svg" class = "h-5 w-5" 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 >
< 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 >
@@ -78,7 +83,14 @@
< / div >
< div class = "flex-1 overflow-y-auto p-3" >
< div v-if = "selectedNode" class="space-y-3" >
< ! - - Hidden internal ID & Type for cleaner UI - - >
< div >
< label class = "text-[10px] uppercase tracking-wider text-slate-500 block mb-1" > Тип узла < / label >
< select v-model = "selectedNode.type" @change="commitNodeChange" 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 = "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" / >
@@ -91,7 +103,7 @@
< 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.stat e ! == undefined " >
< div v-if = "selectedNode.typ e = == 'task' " >
< 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 = "todo" > 📋 К выполнению ( To Do ) < / option >
@@ -101,7 +113,7 @@
< option value = "blocked" > ⛔ Заблокировано ( Blocked ) < / option >
< / select >
< / div >
< div v-if = "selectedNode.data.priority !== undefined " >
< div v-if = "selectedNode.type === 'task' " >
< label class = "text-[10px] uppercase tracking-wider text-slate-500 block mb-1" > Приоритет < / label >
< select v-model = "selectedNode.data.priority" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200" >
< option value = "low" > 🧊 Низкий < / option >
@@ -109,7 +121,7 @@
< option value = "high" > 🔥 Высокий < / option >
< / select >
< / div >
< div v-if = "selectedNode.data.assigned_to !== undefined " >
< div v-if = "selectedNode.type === 'task' " >
< label class = "text-[10px] uppercase tracking-wider text-slate-500 block mb-1" > Исполнитель < / label >
< select v-model = "selectedNode.data.assigned_to" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200" >
< option value = "" > Не назначено < / option >
@@ -118,7 +130,7 @@
< / option >
< / select >
< / div >
< div v-if = "selectedNode.data.alarm !== undefined " >
< div v-if = "selectedNode.type === 'task' " >
< 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 >
@@ -126,6 +138,15 @@
< 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.type === 'file' || selectedNode.data.file !== undefined" >
< label class = "text-[10px] uppercase tracking-wider text-slate-500 block mb-1" > Ссылка на Карту < / label >
< select v-model = "selectedNode.data.file" @change="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" >
< 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 = "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 >
@@ -166,7 +187,7 @@ const auth = useAuth()
const authHeaders = computed ( ( ) => ( { Authorization : ` Bearer ${ auth . token . value } ` } ) )
const elements = ref ( [ ] )
const selectedNode = ref ( null )
const canvasName = computed ( ( ) => props . canvasId )
const canvasName = ref ( props . canvasId )
const nodeTypes = [
{ type : 'text' , name : 'Текст' , icon : '📝' , description : 'Markdown заметка' } ,
@@ -181,6 +202,7 @@ const nodeTypes = [
const loadData = async ( ) => {
const data = await loadCanvas ( props . canvasId )
elements . value = transformToFlowElements ( data )
canvasName . value = data . name || props . canvasId
// Mark initial state as saved
markAsSaved ( data )
}
@@ -405,6 +427,23 @@ const onNodeDragStop = (event) => {
const onNodeDoubleClick = ( event ) => {
const node = event . node
// 1. Cross-Canvas Navigation Logic
// Check if it's a file node or has a URL/file reference to another canvas
const targetId = node . data ? . file || node . data ? . url || ''
if ( targetId && ( node . type === 'file' || targetId . includes ( '.canvas' ) || targetId . startsWith ( '/canvas/' ) ) ) {
// Clean up ID (e.g. "project.canvas" -> "project", "/canvas/project" -> "project")
const cleanId = targetId . replace ( '.canvas' , '' ) . replace ( '/canvas/' , '' )
console . log ( '[Navigation] Opening cross-linked canvas:' , cleanId )
navigateTo ( ` /canvas/ ${ cleanId } ` )
return // Stop further processing
} else if ( node . type === 'file' && ! targetId ) {
alert ( 'У этого узла еще нет ссылки на другую карту. Выберите карту в панели свойств (справа).' )
return
}
// 2. Task Toggle Logic (fallback)
const currentState = node . data ? . state || 'todo'
// Quick toggle workflow: todo -> in_progress -> review -> completed -> todo
@@ -413,7 +452,7 @@ const onNodeDoubleClick = (event) => {
'in_progress' : 'review' ,
'review' : 'completed' ,
'completed' : 'todo' ,
'blocked' : 'todo' // From blocked back to todo
'blocked' : 'todo'
}
const nextState = nextStateMap [ currentState ] || 'todo'
@@ -446,6 +485,27 @@ const exportObsidian = () => {
URL . revokeObjectURL ( url )
}
const deleteCanvas = async ( ) => {
if ( ! confirm ( 'Вы уверены, что хотите полностью удалить эту карту? Это действие необратимо.' ) ) return
try {
const res = await fetch ( ` /api/canvas/ ${ props . canvasId } ` , {
method : 'DELETE' ,
headers : authHeaders . value
} )
if ( res . ok ) {
navigateTo ( '/canvases' )
} else {
const err = await res . json ( )
alert ( 'Ошибка при удалении: ' + ( err . message || 'неизвестная ошибка' ) )
}
} catch ( err ) {
console . error ( 'Delete error:' , err )
alert ( 'Не удалось удалить карту' )
}
}
const fitView = ( ) => {
const padding = 40
const cols = 3
@@ -551,6 +611,7 @@ const saveCanvas = async () => {
}
const usersList = ref ( [ ] )
const canvasesList = ref ( [ ] )
// Load data on mount
onMounted ( async ( ) => {
@@ -569,6 +630,19 @@ onMounted(async () => {
} catch ( e ) {
console . error ( 'Failed to load users' , e )
}
// Load canvases for cross-link dropdown
try {
const res = await fetch ( '/api/canvas' , {
headers : authHeaders . value
} )
if ( res . ok ) {
const data = await res . json ( )
canvasesList . value = data . canvases || [ ]
}
} catch ( e ) {
console . error ( 'Failed to load canvases' , e )
}
} )
< / script >