-
-
-
-
-
-
-
-
+
@@ -99,19 +92,34 @@
-
+
+
+
+
+
-
-
+
+
-
+
@@ -153,7 +161,7 @@ const props = defineProps({
const { loadCanvas, updateNodeState: apiUpdateNodeState } = useCanvas()
const gitSync = useGitSync()
-const unsavedChanges = useUnsavedChanges()
+const { isDirty, trackChanges, markAsSaved } = useUnsavedChanges()
const auth = useAuth()
const authHeaders = computed(() => ({ Authorization: `Bearer ${auth.token.value}` }))
const elements = ref([])
@@ -174,20 +182,42 @@ const loadData = async () => {
const data = await loadCanvas(props.canvasId)
elements.value = transformToFlowElements(data)
// Mark initial state as saved
- unsavedChanges.markAsSaved(data)
+ 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 },
+ 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'
+ 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 ?? '',
+ 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`,
@@ -215,18 +245,25 @@ const transformToFlowElements = (canvasData) => {
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),
+ 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,
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'
@@ -281,7 +318,8 @@ const createNode = (type, x, y) => {
nodeData.data.file = 'docs/file.md'
break
case 'task':
- nodeData.data.state = 'pending'
+ nodeData.data.state = 'todo'
+ nodeData.data.priority = 'medium'
nodeData.data.assigned_to = ''
nodeData.data.alarm = ''
nodeData.data.script_id = ''
@@ -324,10 +362,13 @@ const commitNodeChange = () => {
// Trigger reactivity
const idx = elements.value.findIndex(el => el.id === selectedNode.value.id)
if (idx !== -1) {
+ elements.value[idx].data = { ...selectedNode.value.data }
+ elements.value[idx].style = { ...selectedNode.value.style }
+ elements.value[idx].type = selectedNode.value.type
elements.value.splice(idx, 1, { ...elements.value[idx] })
}
// Track changes
- unsavedChanges.trackChanges(transformToObsidianCanvas(elements.value))
+ trackChanges(transformToObsidianCanvas(elements.value))
}
}
@@ -357,15 +398,25 @@ const onNodeDragStop = (event) => {
}
// Track changes
- unsavedChanges.trackChanges(transformToObsidianCanvas(elements.value))
+ 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'
+ const currentState = node.data?.state || 'todo'
+
+ // Quick toggle workflow: todo -> in_progress -> review -> completed -> todo
+ const nextStateMap = {
+ 'todo': 'in_progress',
+ 'in_progress': 'review',
+ 'review': 'completed',
+ 'completed': 'todo',
+ 'blocked': 'todo' // From blocked back to todo
+ }
+
+ const nextState = nextStateMap[currentState] || 'todo'
updateNodeData({ id: node.id, data: { state: nextState } })
}
@@ -475,15 +526,20 @@ const manualGitSync = async () => {
// Enhanced save function with Git sync
const saveCanvas = async () => {
try {
+ console.log('[DEBUG saveCanvas] Starting save...')
const obsidianCanvas = transformToObsidianCanvas(elements.value)
- await $fetch(`/api/canvas/${props.canvasId}`, {
+
+ console.log('[DEBUG saveCanvas] Sending PUT request...')
+ const res = await $fetch(`/api/canvas/${props.canvasId}`, {
method: 'PUT',
- body: obsidianCanvas,
+ body: { ...obsidianCanvas, name: canvasName.value },
headers: authHeaders.value
})
+ console.log('[DEBUG saveCanvas] PUT request successful!', res)
// Mark as saved
- unsavedChanges.markAsSaved(obsidianCanvas)
+ markAsSaved(obsidianCanvas)
+ console.log('[DEBUG saveCanvas] Marked as saved in UI')
// Auto-sync to Git if repository exists
await gitSync.autoSyncCanvas(props.canvasId, obsidianCanvas)
@@ -494,11 +550,25 @@ const saveCanvas = async () => {
}
}
+const usersList = ref([])
+
// Load data on mount
onMounted(async () => {
await loadData()
// Check for existing Git repository
await gitSync.checkRepository(props.canvasId)
+
+ // Load users for assignee dropdown
+ try {
+ const res = await fetch('/api/users', {
+ headers: authHeaders.value
+ })
+ if (res.ok) {
+ usersList.value = await res.json()
+ }
+ } catch (e) {
+ console.error('Failed to load users', e)
+ }
})
diff --git a/app/components/FlowNodeRenderer.vue b/app/components/FlowNodeRenderer.vue
index 5810f48..c162500 100644
--- a/app/components/FlowNodeRenderer.vue
+++ b/app/components/FlowNodeRenderer.vue
@@ -18,6 +18,9 @@
{{ stateLabel }}
+
+ {{ priorityLabel }}
+
@@ -99,9 +102,15 @@ const nodeStyle = computed(() => {
}
if (props.data.state === 'completed') {
- baseStyle.borderColor = '#10b981'
+ baseStyle.borderColor = '#10b981' // Green
} else if (props.data.state === 'in_progress') {
- baseStyle.borderColor = '#f59e0b'
+ 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
@@ -124,22 +133,44 @@ const nodeIcon = computed(() => {
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'
+ 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-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'
+ 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 '✓'
- if (state === 'in_progress') return '⟳'
- return '○'
+ 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
diff --git a/app/composables/useCanvas.ts b/app/composables/useCanvas.ts
index 669f37e..9f230a9 100644
--- a/app/composables/useCanvas.ts
+++ b/app/composables/useCanvas.ts
@@ -47,9 +47,20 @@ export const useCanvas = () => {
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 || {}
}))
diff --git a/app/composables/useUnsavedChanges.ts b/app/composables/useUnsavedChanges.ts
index dbb144c..a220390 100644
--- a/app/composables/useUnsavedChanges.ts
+++ b/app/composables/useUnsavedChanges.ts
@@ -10,8 +10,13 @@ export const useUnsavedChanges = () => {
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')
}
}
@@ -20,6 +25,7 @@ export const useUnsavedChanges = () => {
lastSavedData.value = JSON.stringify(data)
currentData.value = lastSavedData.value
isDirty.value = false
+ console.log('[DEBUG Unsaved] Marked as saved, isDirty = false')
}
// Reset dirty state
diff --git a/app/layouts/default.vue b/app/layouts/default.vue
index 95b0011..4018d04 100644
--- a/app/layouts/default.vue
+++ b/app/layouts/default.vue
@@ -5,9 +5,8 @@
XFlow
- Карты
- Редактор
- Поток
+ Проекты
+ Задачи
Админ
Настройки
diff --git a/app/pages/tasks.vue b/app/pages/tasks.vue
new file mode 100644
index 0000000..95eae47
--- /dev/null
+++ b/app/pages/tasks.vue
@@ -0,0 +1,170 @@
+
+
+
+
+
+ 📋 Мои Задачи
+
+
+
+
+
+
+
📭
+
У вас пока нет задач
+
Вам не назначено ни одной задачи в проектах.
+
+
+
+
+
+
+
+ {{ col.icon }} {{ col.title }}
+
+
+ {{ getTasksForColumn(col.id).length }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/server/api/canvas/[id].put.ts b/app/server/api/canvas/[id].put.ts
index 982b7c5..47aed5e 100644
--- a/app/server/api/canvas/[id].put.ts
+++ b/app/server/api/canvas/[id].put.ts
@@ -10,7 +10,12 @@ export default defineEventHandler(async (event) => {
const existing = await prisma.canvas.findUnique({ where: { id } })
if (!existing) throw createError({ statusCode: 404, message: 'Canvas not found' })
- if (existing.ownerId !== user.id) throw createError({ statusCode: 403, message: 'Access denied' })
+
+ // Allow if owner, or if admin, or if canvas is public (for collaborative editing)
+ if (existing.ownerId !== user.id && user.role !== 'admin' && !existing.isPublic) {
+ console.log('[DEBUG 403]', { ownerId: existing.ownerId, userId: user.id, userRole: user.role, isPublic: existing.isPublic })
+ throw createError({ statusCode: 403, message: 'Access denied' })
+ }
const { name, nodes, edges, isPublic, groupId } = body
const canvas = await prisma.canvas.update({
diff --git a/app/server/api/tasks.get.ts b/app/server/api/tasks.get.ts
new file mode 100644
index 0000000..9854446
--- /dev/null
+++ b/app/server/api/tasks.get.ts
@@ -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 }
+})
diff --git a/app/server/api/users/index.get.ts b/app/server/api/users/index.get.ts
new file mode 100644
index 0000000..9be1c07
--- /dev/null
+++ b/app/server/api/users/index.get.ts
@@ -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
+ }))
+})