Spaces:
Sleeping
Sleeping
Commit ·
f104d74
1
Parent(s): dcaa853
canban
Browse files
src/context/AchievementNotificationContext.jsx
CHANGED
|
@@ -131,7 +131,7 @@ const resetDownstreamNodesBackground = (startNodeId, nodes, edges) => {
|
|
| 131 |
...node,
|
| 132 |
data: {
|
| 133 |
...node.data,
|
| 134 |
-
status: '
|
| 135 |
}
|
| 136 |
};
|
| 137 |
}
|
|
@@ -307,7 +307,7 @@ export const AchievementNotificationProvider = ({ children }) => {
|
|
| 307 |
...n,
|
| 308 |
data: {
|
| 309 |
...n.data,
|
| 310 |
-
status: '
|
| 311 |
lastResetTime: new Date().toISOString()
|
| 312 |
}
|
| 313 |
};
|
|
@@ -315,7 +315,7 @@ export const AchievementNotificationProvider = ({ children }) => {
|
|
| 315 |
return n;
|
| 316 |
});
|
| 317 |
updatedNodes = resetDownstreamNodesBackground(nodeId, updatedNodes, edges);
|
| 318 |
-
knownHabitStatuses.current.set(nodeId, '
|
| 319 |
newlyRechargedHabits.push(node.data?.label || t('canvas.habit'));
|
| 320 |
}
|
| 321 |
}
|
|
@@ -339,7 +339,7 @@ export const AchievementNotificationProvider = ({ children }) => {
|
|
| 339 |
...n,
|
| 340 |
data: {
|
| 341 |
...n.data,
|
| 342 |
-
status: '
|
| 343 |
lastResetTime: new Date().toISOString()
|
| 344 |
}
|
| 345 |
};
|
|
@@ -350,17 +350,17 @@ export const AchievementNotificationProvider = ({ children }) => {
|
|
| 350 |
// 2. Reset downstream nodes
|
| 351 |
updatedNodes = resetDownstreamNodesBackground(nodeId, updatedNodes, edges);
|
| 352 |
|
| 353 |
-
knownHabitStatuses.current.set(nodeId, '
|
| 354 |
newlyRechargedHabits.push(node.data?.label || t('canvas.habit'));
|
| 355 |
} else {
|
| 356 |
knownHabitStatuses.current.set(nodeId, 'Done');
|
| 357 |
}
|
| 358 |
-
} else if (currentStatus === 'Pending') {
|
| 359 |
if (prevStatus === 'Done') {
|
| 360 |
-
console.log(`[HabitTrackerBackground] Habit transitioned to Pending externally: "${node.data.label}"`);
|
| 361 |
newlyRechargedHabits.push(node.data?.label || t('canvas.habit'));
|
| 362 |
}
|
| 363 |
-
knownHabitStatuses.current.set(nodeId,
|
| 364 |
}
|
| 365 |
}
|
| 366 |
}
|
|
|
|
| 131 |
...node,
|
| 132 |
data: {
|
| 133 |
...node.data,
|
| 134 |
+
status: 'Todo'
|
| 135 |
}
|
| 136 |
};
|
| 137 |
}
|
|
|
|
| 307 |
...n,
|
| 308 |
data: {
|
| 309 |
...n.data,
|
| 310 |
+
status: 'Todo',
|
| 311 |
lastResetTime: new Date().toISOString()
|
| 312 |
}
|
| 313 |
};
|
|
|
|
| 315 |
return n;
|
| 316 |
});
|
| 317 |
updatedNodes = resetDownstreamNodesBackground(nodeId, updatedNodes, edges);
|
| 318 |
+
knownHabitStatuses.current.set(nodeId, 'Todo');
|
| 319 |
newlyRechargedHabits.push(node.data?.label || t('canvas.habit'));
|
| 320 |
}
|
| 321 |
}
|
|
|
|
| 339 |
...n,
|
| 340 |
data: {
|
| 341 |
...n.data,
|
| 342 |
+
status: 'Todo',
|
| 343 |
lastResetTime: new Date().toISOString()
|
| 344 |
}
|
| 345 |
};
|
|
|
|
| 350 |
// 2. Reset downstream nodes
|
| 351 |
updatedNodes = resetDownstreamNodesBackground(nodeId, updatedNodes, edges);
|
| 352 |
|
| 353 |
+
knownHabitStatuses.current.set(nodeId, 'Todo');
|
| 354 |
newlyRechargedHabits.push(node.data?.label || t('canvas.habit'));
|
| 355 |
} else {
|
| 356 |
knownHabitStatuses.current.set(nodeId, 'Done');
|
| 357 |
}
|
| 358 |
+
} else if (currentStatus === 'Pending' || currentStatus === 'Todo') {
|
| 359 |
if (prevStatus === 'Done') {
|
| 360 |
+
console.log(`[HabitTrackerBackground] Habit transitioned to Todo/Pending externally: "${node.data.label}"`);
|
| 361 |
newlyRechargedHabits.push(node.data?.label || t('canvas.habit'));
|
| 362 |
}
|
| 363 |
+
knownHabitStatuses.current.set(nodeId, currentStatus);
|
| 364 |
}
|
| 365 |
}
|
| 366 |
}
|
src/features/canvas/components/nodes/HabitNode.jsx
CHANGED
|
@@ -26,7 +26,7 @@ const HabitNode = ({ id, data, selected }) => {
|
|
| 26 |
|
| 27 |
if (data.updateNodeData) {
|
| 28 |
data.updateNodeData(id, {
|
| 29 |
-
status: '
|
| 30 |
lastResetTime: new Date().toISOString()
|
| 31 |
});
|
| 32 |
} else {
|
|
@@ -37,7 +37,7 @@ const HabitNode = ({ id, data, selected }) => {
|
|
| 37 |
...node,
|
| 38 |
data: {
|
| 39 |
...node.data,
|
| 40 |
-
status: '
|
| 41 |
lastResetTime: new Date().toISOString()
|
| 42 |
}
|
| 43 |
};
|
|
@@ -50,7 +50,7 @@ const HabitNode = ({ id, data, selected }) => {
|
|
| 50 |
|
| 51 |
const toggleDone = (e) => {
|
| 52 |
e.stopPropagation();
|
| 53 |
-
const newStatus = data.status === 'Done' ? '
|
| 54 |
if (data.updateNodeData) {
|
| 55 |
data.updateNodeData(id, { status: newStatus });
|
| 56 |
} else {
|
|
@@ -245,7 +245,7 @@ const HabitNode = ({ id, data, selected }) => {
|
|
| 245 |
? 'bg-green-500/10 text-success border-success/30'
|
| 246 |
: 'bg-amber-500/10 text-[#f59e0b] border-[#f59e0b]/30'
|
| 247 |
}`}>
|
| 248 |
-
{isDone ? t('canvas.habit_status_done') : t('canvas.
|
| 249 |
</span>
|
| 250 |
</div>
|
| 251 |
|
|
|
|
| 26 |
|
| 27 |
if (data.updateNodeData) {
|
| 28 |
data.updateNodeData(id, {
|
| 29 |
+
status: 'Todo',
|
| 30 |
lastResetTime: new Date().toISOString()
|
| 31 |
});
|
| 32 |
} else {
|
|
|
|
| 37 |
...node,
|
| 38 |
data: {
|
| 39 |
...node.data,
|
| 40 |
+
status: 'Todo',
|
| 41 |
lastResetTime: new Date().toISOString()
|
| 42 |
}
|
| 43 |
};
|
|
|
|
| 50 |
|
| 51 |
const toggleDone = (e) => {
|
| 52 |
e.stopPropagation();
|
| 53 |
+
const newStatus = data.status === 'Done' ? 'Todo' : 'Done';
|
| 54 |
if (data.updateNodeData) {
|
| 55 |
data.updateNodeData(id, { status: newStatus });
|
| 56 |
} else {
|
|
|
|
| 245 |
? 'bg-green-500/10 text-success border-success/30'
|
| 246 |
: 'bg-amber-500/10 text-[#f59e0b] border-[#f59e0b]/30'
|
| 247 |
}`}>
|
| 248 |
+
{isDone ? t('canvas.habit_status_done', 'Done') : t('canvas.habit_status_todo', 'Todo')}
|
| 249 |
</span>
|
| 250 |
</div>
|
| 251 |
|
src/features/canvas/components/nodes/SketchNode.jsx
CHANGED
|
@@ -61,7 +61,7 @@ const SketchNode = ({ id, data, selected }) => {
|
|
| 61 |
return;
|
| 62 |
}
|
| 63 |
|
| 64 |
-
const newStatus = data.status === 'Done' ? '
|
| 65 |
const newProgress = newStatus === 'Done' ? 100 : 0;
|
| 66 |
|
| 67 |
if (data.updateNodeData) {
|
|
@@ -97,12 +97,12 @@ const SketchNode = ({ id, data, selected }) => {
|
|
| 97 |
setShowWarning(false);
|
| 98 |
}, 3000);
|
| 99 |
|
| 100 |
-
// Cap progress at 99% and status to
|
| 101 |
-
updateProgress(99, '
|
| 102 |
return;
|
| 103 |
}
|
| 104 |
|
| 105 |
-
const newStatus = val === 100 ? 'Done' : '
|
| 106 |
updateProgress(val, newStatus);
|
| 107 |
};
|
| 108 |
|
|
@@ -127,7 +127,10 @@ const SketchNode = ({ id, data, selected }) => {
|
|
| 127 |
switch (status) {
|
| 128 |
case 'Done': return 'bg-green-500/10 text-success border-success/30';
|
| 129 |
case 'Blocked': return 'bg-red-500/10 text-error border-error/30';
|
| 130 |
-
case '
|
|
|
|
|
|
|
|
|
|
| 131 |
default: return 'bg-surface-container-lowest text-primary border-primary';
|
| 132 |
}
|
| 133 |
};
|
|
|
|
| 61 |
return;
|
| 62 |
}
|
| 63 |
|
| 64 |
+
const newStatus = data.status === 'Done' ? 'Todo' : 'Done';
|
| 65 |
const newProgress = newStatus === 'Done' ? 100 : 0;
|
| 66 |
|
| 67 |
if (data.updateNodeData) {
|
|
|
|
| 97 |
setShowWarning(false);
|
| 98 |
}, 3000);
|
| 99 |
|
| 100 |
+
// Cap progress at 99% and status to In Progress
|
| 101 |
+
updateProgress(99, 'In Progress');
|
| 102 |
return;
|
| 103 |
}
|
| 104 |
|
| 105 |
+
const newStatus = val === 100 ? 'Done' : (val === 0 ? 'Todo' : 'In Progress');
|
| 106 |
updateProgress(val, newStatus);
|
| 107 |
};
|
| 108 |
|
|
|
|
| 127 |
switch (status) {
|
| 128 |
case 'Done': return 'bg-green-500/10 text-success border-success/30';
|
| 129 |
case 'Blocked': return 'bg-red-500/10 text-error border-error/30';
|
| 130 |
+
case 'In Progress': return 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30';
|
| 131 |
+
case 'Review': return 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30';
|
| 132 |
+
case 'Todo':
|
| 133 |
+
case 'Pending': return 'bg-secondary/15 text-primary/70 border-primary/20';
|
| 134 |
default: return 'bg-surface-container-lowest text-primary border-primary';
|
| 135 |
}
|
| 136 |
};
|
src/features/canvas/hooks/useCanvasLogic.js
CHANGED
|
@@ -312,9 +312,52 @@ export const useCanvasLogic = (projectId, user) => {
|
|
| 312 |
setNodes((nds) => {
|
| 313 |
return nds.map((n) => {
|
| 314 |
if (n.id === nodeId) {
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
}
|
| 319 |
return updated;
|
| 320 |
}
|
|
@@ -578,6 +621,8 @@ export const useCanvasLogic = (projectId, user) => {
|
|
| 578 |
let height = n.height;
|
| 579 |
let nodeData = n.data || '';
|
| 580 |
let assignees = [];
|
|
|
|
|
|
|
| 581 |
|
| 582 |
if (n.type === 'condition') {
|
| 583 |
if (n.decision && n.decision.startsWith('{')) {
|
|
@@ -610,6 +655,8 @@ export const useCanvasLogic = (projectId, user) => {
|
|
| 610 |
lastResetTime = parsed.lastResetTime;
|
| 611 |
createdAt = parsed.createdAt || createdAt;
|
| 612 |
description = parsed.description || description;
|
|
|
|
|
|
|
| 613 |
decision = undefined;
|
| 614 |
} catch (e) {
|
| 615 |
console.error('Failed to parse habit decision JSON', e);
|
|
@@ -627,6 +674,8 @@ export const useCanvasLogic = (projectId, user) => {
|
|
| 627 |
isPinned = parsed.isPinned !== undefined ? !!parsed.isPinned : isPinned;
|
| 628 |
width = parsed.width || width;
|
| 629 |
height = parsed.height || height;
|
|
|
|
|
|
|
| 630 |
if (parsed.assignees && Array.isArray(parsed.assignees)) {
|
| 631 |
assignees = parsed.assignees;
|
| 632 |
} else if (parsed.assignee) {
|
|
@@ -640,7 +689,8 @@ export const useCanvasLogic = (projectId, user) => {
|
|
| 640 |
|
| 641 |
const dataObj = {
|
| 642 |
label: n.label,
|
| 643 |
-
status:
|
|
|
|
| 644 |
decision,
|
| 645 |
options,
|
| 646 |
selectedOptions,
|
|
@@ -797,7 +847,10 @@ export const useCanvasLogic = (projectId, user) => {
|
|
| 797 |
resetRule: n.data.resetRule || { type: 'daily', time: '00:00' },
|
| 798 |
lastResetTime: n.data.lastResetTime,
|
| 799 |
createdAt: n.data.createdAt || new Date().toISOString(),
|
| 800 |
-
description: n.data.description || ''
|
|
|
|
|
|
|
|
|
|
| 801 |
})
|
| 802 |
: n.type === 'sketch'
|
| 803 |
? JSON.stringify({
|
|
@@ -807,7 +860,9 @@ export const useCanvasLogic = (projectId, user) => {
|
|
| 807 |
decision: n.data.decision || 'bottom',
|
| 808 |
assignees: n.data.assignees || [],
|
| 809 |
color: n.data.color,
|
| 810 |
-
isPinned: !!n.data.isPinned
|
|
|
|
|
|
|
| 811 |
})
|
| 812 |
: n.type === 'comment'
|
| 813 |
? JSON.stringify({
|
|
@@ -1194,8 +1249,9 @@ export const useCanvasLogic = (projectId, user) => {
|
|
| 1194 |
const id = crypto.randomUUID();
|
| 1195 |
const nodeData = {
|
| 1196 |
label: '',
|
| 1197 |
-
status: '
|
| 1198 |
description: '',
|
|
|
|
| 1199 |
...initialData
|
| 1200 |
};
|
| 1201 |
|
|
@@ -1486,7 +1542,8 @@ export const useCanvasLogic = (projectId, user) => {
|
|
| 1486 |
position: { x: n.posX, y: n.posY },
|
| 1487 |
data: {
|
| 1488 |
label: n.label,
|
| 1489 |
-
status: n.state === 1 ? 'Done' : '
|
|
|
|
| 1490 |
decision,
|
| 1491 |
options,
|
| 1492 |
selectedOptions,
|
|
@@ -1575,7 +1632,7 @@ export const useCanvasLogic = (projectId, user) => {
|
|
| 1575 |
...node,
|
| 1576 |
data: {
|
| 1577 |
...node.data,
|
| 1578 |
-
status: '
|
| 1579 |
}
|
| 1580 |
};
|
| 1581 |
}
|
|
@@ -1624,7 +1681,7 @@ export const useCanvasLogic = (projectId, user) => {
|
|
| 1624 |
...n,
|
| 1625 |
data: {
|
| 1626 |
...n.data,
|
| 1627 |
-
status: '
|
| 1628 |
lastResetTime: new Date().toISOString()
|
| 1629 |
}
|
| 1630 |
}
|
|
|
|
| 312 |
setNodes((nds) => {
|
| 313 |
return nds.map((n) => {
|
| 314 |
if (n.id === nodeId) {
|
| 315 |
+
// Automatic status and progress coupling
|
| 316 |
+
let coupledData = { ...newData };
|
| 317 |
+
const currentStatus = n.data?.status || 'Todo';
|
| 318 |
+
const currentProgress = n.data?.progress !== undefined ? n.data.progress : 0;
|
| 319 |
+
|
| 320 |
+
if (coupledData.status !== undefined || coupledData.progress !== undefined) {
|
| 321 |
+
const nextStatus = coupledData.status !== undefined ? coupledData.status : currentStatus;
|
| 322 |
+
const nextProgress = coupledData.progress !== undefined ? Number(coupledData.progress) : currentProgress;
|
| 323 |
+
|
| 324 |
+
if (coupledData.status !== undefined && coupledData.progress === undefined) {
|
| 325 |
+
// Status changed, progress was not
|
| 326 |
+
if (nextStatus === 'Done') {
|
| 327 |
+
coupledData.progress = 100;
|
| 328 |
+
} else if (nextStatus === 'Todo' || nextStatus === 'Pending') {
|
| 329 |
+
coupledData.progress = 0;
|
| 330 |
+
} else if (nextStatus === 'In Progress' && (currentProgress === 0 || currentProgress === 100)) {
|
| 331 |
+
coupledData.progress = 50;
|
| 332 |
+
} else if (nextStatus === 'Review' && (currentProgress === 0 || currentProgress === 100)) {
|
| 333 |
+
coupledData.progress = 90;
|
| 334 |
+
}
|
| 335 |
+
} else if (coupledData.progress !== undefined && coupledData.status === undefined) {
|
| 336 |
+
// Progress changed, status was not
|
| 337 |
+
if (nextProgress === 100) {
|
| 338 |
+
coupledData.status = 'Done';
|
| 339 |
+
} else if (nextProgress === 0) {
|
| 340 |
+
coupledData.status = 'Todo';
|
| 341 |
+
} else if (currentStatus === 'Done' || currentStatus === 'Todo' || currentStatus === 'Pending') {
|
| 342 |
+
coupledData.status = 'In Progress';
|
| 343 |
+
}
|
| 344 |
+
} else if (coupledData.progress !== undefined && coupledData.status !== undefined) {
|
| 345 |
+
// Both changed, align them
|
| 346 |
+
if (coupledData.status === 'Done') {
|
| 347 |
+
coupledData.progress = 100;
|
| 348 |
+
} else if (coupledData.status === 'Todo' || coupledData.status === 'Pending') {
|
| 349 |
+
coupledData.progress = 0;
|
| 350 |
+
} else if (coupledData.progress === 100) {
|
| 351 |
+
coupledData.status = 'Done';
|
| 352 |
+
} else if (coupledData.progress === 0) {
|
| 353 |
+
coupledData.status = 'Todo';
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
const updated = { ...n, data: { ...n.data, ...coupledData } };
|
| 359 |
+
if (coupledData.isPinned !== undefined) {
|
| 360 |
+
updated.draggable = !coupledData.isPinned;
|
| 361 |
}
|
| 362 |
return updated;
|
| 363 |
}
|
|
|
|
| 621 |
let height = n.height;
|
| 622 |
let nodeData = n.data || '';
|
| 623 |
let assignees = [];
|
| 624 |
+
let tags = [];
|
| 625 |
+
let status = n.state === 1 ? 'Done' : 'Todo';
|
| 626 |
|
| 627 |
if (n.type === 'condition') {
|
| 628 |
if (n.decision && n.decision.startsWith('{')) {
|
|
|
|
| 655 |
lastResetTime = parsed.lastResetTime;
|
| 656 |
createdAt = parsed.createdAt || createdAt;
|
| 657 |
description = parsed.description || description;
|
| 658 |
+
tags = parsed.tags || [];
|
| 659 |
+
status = parsed.status || (n.state === 1 ? 'Done' : 'Todo');
|
| 660 |
decision = undefined;
|
| 661 |
} catch (e) {
|
| 662 |
console.error('Failed to parse habit decision JSON', e);
|
|
|
|
| 674 |
isPinned = parsed.isPinned !== undefined ? !!parsed.isPinned : isPinned;
|
| 675 |
width = parsed.width || width;
|
| 676 |
height = parsed.height || height;
|
| 677 |
+
tags = parsed.tags || [];
|
| 678 |
+
status = parsed.status || (n.state === 1 ? 'Done' : 'Todo');
|
| 679 |
if (parsed.assignees && Array.isArray(parsed.assignees)) {
|
| 680 |
assignees = parsed.assignees;
|
| 681 |
} else if (parsed.assignee) {
|
|
|
|
| 689 |
|
| 690 |
const dataObj = {
|
| 691 |
label: n.label,
|
| 692 |
+
status: status,
|
| 693 |
+
tags: tags,
|
| 694 |
decision,
|
| 695 |
options,
|
| 696 |
selectedOptions,
|
|
|
|
| 847 |
resetRule: n.data.resetRule || { type: 'daily', time: '00:00' },
|
| 848 |
lastResetTime: n.data.lastResetTime,
|
| 849 |
createdAt: n.data.createdAt || new Date().toISOString(),
|
| 850 |
+
description: n.data.description || '',
|
| 851 |
+
tags: n.data.tags || [],
|
| 852 |
+
status: n.data.status || 'Todo',
|
| 853 |
+
progress: n.data.progress || 0
|
| 854 |
})
|
| 855 |
: n.type === 'sketch'
|
| 856 |
? JSON.stringify({
|
|
|
|
| 860 |
decision: n.data.decision || 'bottom',
|
| 861 |
assignees: n.data.assignees || [],
|
| 862 |
color: n.data.color,
|
| 863 |
+
isPinned: !!n.data.isPinned,
|
| 864 |
+
tags: n.data.tags || [],
|
| 865 |
+
status: n.data.status || 'Todo'
|
| 866 |
})
|
| 867 |
: n.type === 'comment'
|
| 868 |
? JSON.stringify({
|
|
|
|
| 1249 |
const id = crypto.randomUUID();
|
| 1250 |
const nodeData = {
|
| 1251 |
label: '',
|
| 1252 |
+
status: 'Todo',
|
| 1253 |
description: '',
|
| 1254 |
+
tags: [],
|
| 1255 |
...initialData
|
| 1256 |
};
|
| 1257 |
|
|
|
|
| 1542 |
position: { x: n.posX, y: n.posY },
|
| 1543 |
data: {
|
| 1544 |
label: n.label,
|
| 1545 |
+
status: n.state === 1 ? 'Done' : 'Todo',
|
| 1546 |
+
tags: [],
|
| 1547 |
decision,
|
| 1548 |
options,
|
| 1549 |
selectedOptions,
|
|
|
|
| 1632 |
...node,
|
| 1633 |
data: {
|
| 1634 |
...node.data,
|
| 1635 |
+
status: 'Todo'
|
| 1636 |
}
|
| 1637 |
};
|
| 1638 |
}
|
|
|
|
| 1681 |
...n,
|
| 1682 |
data: {
|
| 1683 |
...n.data,
|
| 1684 |
+
status: 'Todo',
|
| 1685 |
lastResetTime: new Date().toISOString()
|
| 1686 |
}
|
| 1687 |
}
|
src/pages/CanvasPage.jsx
CHANGED
|
@@ -83,7 +83,7 @@ const getTransformForBoundsCustom = (bounds, width, height, minZoom, maxZoom, pa
|
|
| 83 |
import Sidebar from '../components/Sidebar';
|
| 84 |
import { useAuth } from '../context/AuthContext';
|
| 85 |
import { useTheme } from '../context/ThemeContext';
|
| 86 |
-
import { Maximize2, Play, Settings, Save, ChevronLeft, ChevronDown, ChevronUp, Loader2, Trash2, Target, GitFork, Image, X, Plus, Timer, Milestone, Share2, User, Copy, Scissors, Clipboard, Check, StickyNote, Pin, Type } from 'lucide-react';
|
| 87 |
|
| 88 |
// Feature components
|
| 89 |
import SketchNode from '../features/canvas/components/nodes/SketchNode';
|
|
@@ -515,6 +515,10 @@ const SketchSettingsModal = ({ node, onClose, onSave, collaborators = [] }) => {
|
|
| 515 |
const [description, setDescription] = useState(node.data.description || '');
|
| 516 |
const [showProgress, setShowProgress] = useState(!!node.data.showProgress);
|
| 517 |
const [progress, setProgress] = useState(node.data.progress || 0);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
const [assignees, setAssignees] = useState(node.data.assignees || []);
|
| 519 |
const [isAssigneesExpanded, setIsAssigneesExpanded] = useState(false);
|
| 520 |
const [color, setColor] = useState(node.data.color || (node.type === 'comment' ? 'yellow' : 'default'));
|
|
@@ -530,13 +534,40 @@ const SketchSettingsModal = ({ node, onClose, onSave, collaborators = [] }) => {
|
|
| 530 |
white: { light: '#f5f5f4', dark: '#1c1917', borderLight: '#d6d3d1', borderDark: '#44403c' }
|
| 531 |
};
|
| 532 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
const handleSave = () => {
|
| 534 |
let finalProgress = Number(progress);
|
| 535 |
-
let
|
| 536 |
|
| 537 |
if (node.type === 'sketch' && finalProgress === 100 && node.data.subgoals && node.data.subgoals.done < node.data.subgoals.total) {
|
| 538 |
finalProgress = 99;
|
| 539 |
-
|
| 540 |
alert(t('canvas.subgoal_warning', 'Виконайте всі підцілі перед завершенням головної цілі!'));
|
| 541 |
}
|
| 542 |
|
|
@@ -545,9 +576,10 @@ const SketchSettingsModal = ({ node, onClose, onSave, collaborators = [] }) => {
|
|
| 545 |
description,
|
| 546 |
showProgress: node.type === 'comment' ? false : showProgress,
|
| 547 |
progress: node.type === 'comment' ? 0 : finalProgress,
|
| 548 |
-
status: node.type === 'comment' ? 'Pending' :
|
| 549 |
assignees,
|
| 550 |
-
color
|
|
|
|
| 551 |
});
|
| 552 |
};
|
| 553 |
|
|
@@ -630,7 +662,33 @@ const SketchSettingsModal = ({ node, onClose, onSave, collaborators = [] }) => {
|
|
| 630 |
</div>
|
| 631 |
</div>
|
| 632 |
|
| 633 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
|
| 635 |
{/* Assignees Input (Collapsible custom visual list) */}
|
| 636 |
{node.type !== 'comment' && (
|
|
@@ -725,6 +783,117 @@ const SketchSettingsModal = ({ node, onClose, onSave, collaborators = [] }) => {
|
|
| 725 |
</div>
|
| 726 |
)}
|
| 727 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 728 |
{/* Show Progress Toggle */}
|
| 729 |
{node.type !== 'comment' && (
|
| 730 |
<div className="flex items-center justify-between bg-secondary/5 border-2 border-primary rounded-xl p-4">
|
|
@@ -762,7 +931,7 @@ const SketchSettingsModal = ({ node, onClose, onSave, collaborators = [] }) => {
|
|
| 762 |
min="0"
|
| 763 |
max="100"
|
| 764 |
value={progress}
|
| 765 |
-
onChange={(e) =>
|
| 766 |
className="w-full h-2 rounded-lg appearance-none cursor-pointer focus:outline-none transition-all accent-emerald-500"
|
| 767 |
style={{
|
| 768 |
background: `linear-gradient(to right, #10b981 ${progress}%, var(--color-border, #e2e8f0) ${progress}%)`
|
|
@@ -1228,6 +1397,505 @@ const ShareModal = ({ projectId, onClose }) => {
|
|
| 1228 |
);
|
| 1229 |
};
|
| 1230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1231 |
const CanvasInner = () => {
|
| 1232 |
const { t } = useTranslation();
|
| 1233 |
const { id } = useParams();
|
|
@@ -1285,6 +1953,13 @@ const CanvasInner = () => {
|
|
| 1285 |
updateProjectMetadata
|
| 1286 |
} = useCanvasLogic(id, user);
|
| 1287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1288 |
const lastCursorSendRef = useRef(0);
|
| 1289 |
const broadcastRef = useRef(broadcastMessage);
|
| 1290 |
|
|
@@ -1837,12 +2512,12 @@ const CanvasInner = () => {
|
|
| 1837 |
<div className="flex h-screen bg-background overflow-hidden" onMouseUp={handleMouseUp}>
|
| 1838 |
<Sidebar />
|
| 1839 |
|
| 1840 |
-
<main className="flex-grow flex flex-col relative">
|
| 1841 |
<div className="relative h-24 bg-surface-container-lowest border-b-4 border-primary px-8 flex items-center justify-between z-30 shadow-sm">
|
| 1842 |
<div className="flex items-center gap-6">
|
| 1843 |
<button
|
| 1844 |
onClick={() => navigate('/dashboard')}
|
| 1845 |
-
className="p-2 hover:bg-surface-variant rounded-xl transition-colors"
|
| 1846 |
>
|
| 1847 |
<ChevronLeft size={28} />
|
| 1848 |
</button>
|
|
@@ -1873,6 +2548,34 @@ const CanvasInner = () => {
|
|
| 1873 |
{projectData?.description || t('canvas.strategic_goal_map')}
|
| 1874 |
</p>
|
| 1875 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1876 |
</div>
|
| 1877 |
|
| 1878 |
<div className="flex items-center gap-4">
|
|
@@ -1931,434 +2634,459 @@ const CanvasInner = () => {
|
|
| 1931 |
</div>
|
| 1932 |
</div>
|
| 1933 |
|
| 1934 |
-
{
|
| 1935 |
-
<CanvasToolbar
|
| 1936 |
-
handleAIRequest={handleAIRequest}
|
| 1937 |
-
isAIActive={isAIActive}
|
| 1938 |
-
user={user}
|
| 1939 |
-
addNewNodeAt={addNewNodeAt}
|
| 1940 |
-
handleCopy={handleCopy}
|
| 1941 |
-
handleCut={handleCut}
|
| 1942 |
-
handlePaste={() => handlePaste(mousePosRef.current)}
|
| 1943 |
-
exportToPng={exportToPng}
|
| 1944 |
-
onDeleteSelected={() => {
|
| 1945 |
-
const selectedNodeIds = nodes.filter(n => n.selected).map(n => n.id);
|
| 1946 |
-
const selectedEdgeIds = edges.filter(e => e.selected).map(e => e.id);
|
| 1947 |
-
requestDeletion(selectedNodeIds, selectedEdgeIds);
|
| 1948 |
-
}}
|
| 1949 |
-
hasSelectedNodes={nodes.some(n => n.selected)}
|
| 1950 |
-
hasClipboardContent={hasClipboardContent}
|
| 1951 |
-
undo={undo}
|
| 1952 |
-
redo={redo}
|
| 1953 |
-
canUndo={canUndo}
|
| 1954 |
-
canRedo={canRedo}
|
| 1955 |
-
drawingMode={drawingMode}
|
| 1956 |
-
setDrawingMode={setDrawingMode}
|
| 1957 |
-
/>
|
| 1958 |
-
)}
|
| 1959 |
-
|
| 1960 |
-
{/* React Flow Canvas */}
|
| 1961 |
<div
|
| 1962 |
-
|
| 1963 |
-
|
| 1964 |
-
onMouseDown={drawingMode ? undefined : handleMouseDown}
|
| 1965 |
-
onPointerDown={onPointerDown}
|
| 1966 |
-
onPointerMove={onPointerMove}
|
| 1967 |
-
onPointerUp={onPointerUp}
|
| 1968 |
>
|
| 1969 |
-
|
| 1970 |
-
|
| 1971 |
-
|
| 1972 |
-
|
| 1973 |
-
|
| 1974 |
-
|
| 1975 |
-
|
| 1976 |
-
|
| 1977 |
-
|
| 1978 |
-
|
| 1979 |
-
|
| 1980 |
-
|
| 1981 |
-
|
| 1982 |
-
|
| 1983 |
-
|
| 1984 |
-
|
| 1985 |
-
|
| 1986 |
-
|
| 1987 |
-
|
| 1988 |
-
|
| 1989 |
-
|
| 1990 |
-
|
| 1991 |
-
|
| 1992 |
-
nodeTypes={nodeTypes}
|
| 1993 |
-
edgeTypes={edgeTypes}
|
| 1994 |
-
connectionLineType={ConnectionLineType.SmoothStep}
|
| 1995 |
-
fitView
|
| 1996 |
-
className="sketch-flow"
|
| 1997 |
-
>
|
| 1998 |
-
<Background variant="dots" gap={20} size={1} color={isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.06)'} />
|
| 1999 |
-
<Controls className="!bottom-8 !left-28 !overflow-hidden" />
|
| 2000 |
-
<MiniMap
|
| 2001 |
-
className="!bottom-8 !right-8 !overflow-hidden"
|
| 2002 |
-
nodeStrokeColor={isDarkMode ? '#e2e3e4' : '#31363F'}
|
| 2003 |
-
nodeColor={isDarkMode ? '#222326' : '#EEEEEE'}
|
| 2004 |
-
maskColor={isDarkMode ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.6)'}
|
| 2005 |
-
/>
|
| 2006 |
-
{/* Collaborative cursors of active participants */}
|
| 2007 |
-
<CollaborativeCursors cursors={cursors} />
|
| 2008 |
-
</ReactFlow>
|
| 2009 |
-
|
| 2010 |
-
{/* Active Drawing Overlay */}
|
| 2011 |
-
{activeStroke && (
|
| 2012 |
-
<svg
|
| 2013 |
-
className="absolute inset-0 pointer-events-none z-[1000]"
|
| 2014 |
-
style={{ width: '100%', height: '100%' }}
|
| 2015 |
-
>
|
| 2016 |
-
<path
|
| 2017 |
-
d={activeStroke.path}
|
| 2018 |
-
fill="none"
|
| 2019 |
-
stroke={isDarkMode ? '#ffffff' : '#31363F'}
|
| 2020 |
-
strokeWidth={3}
|
| 2021 |
-
strokeLinecap="round"
|
| 2022 |
-
strokeLinejoin="round"
|
| 2023 |
-
/>
|
| 2024 |
-
</svg>
|
| 2025 |
-
)}
|
| 2026 |
-
|
| 2027 |
-
<AIHintOverlay
|
| 2028 |
-
isAIActive={isAIActive}
|
| 2029 |
-
setIsAIActive={setIsAIActive}
|
| 2030 |
-
aiLoading={aiLoading}
|
| 2031 |
-
submitAICanvasPrompt={submitAICanvasPrompt}
|
| 2032 |
-
/>
|
| 2033 |
-
<PaywallModal showPaywall={showPaywall} setShowPaywall={setShowPaywall} />
|
| 2034 |
-
|
| 2035 |
-
{editingNode && (
|
| 2036 |
-
<ConditionSettingsModal
|
| 2037 |
-
node={editingNode}
|
| 2038 |
-
onClose={() => setEditingConditionNodeId(null)}
|
| 2039 |
-
onSave={handleSaveConditionConfig}
|
| 2040 |
/>
|
| 2041 |
)}
|
| 2042 |
|
| 2043 |
-
|
| 2044 |
-
|
| 2045 |
-
|
| 2046 |
-
|
| 2047 |
-
|
| 2048 |
-
|
| 2049 |
-
|
| 2050 |
-
|
| 2051 |
-
|
| 2052 |
-
|
| 2053 |
-
|
| 2054 |
-
|
| 2055 |
-
|
| 2056 |
-
|
| 2057 |
-
|
| 2058 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2059 |
|
| 2060 |
-
|
| 2061 |
-
|
| 2062 |
-
|
| 2063 |
-
|
| 2064 |
-
|
| 2065 |
-
|
| 2066 |
-
|
| 2067 |
-
|
| 2068 |
-
|
| 2069 |
-
|
| 2070 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2071 |
|
| 2072 |
-
|
| 2073 |
-
|
| 2074 |
-
|
| 2075 |
-
|
| 2076 |
-
|
| 2077 |
-
fetchCollaborators();
|
| 2078 |
-
}}
|
| 2079 |
/>
|
| 2080 |
-
|
| 2081 |
|
| 2082 |
-
|
| 2083 |
-
|
| 2084 |
-
|
| 2085 |
-
|
| 2086 |
-
|
| 2087 |
-
>
|
| 2088 |
-
<button
|
| 2089 |
-
onClick={(e) => {
|
| 2090 |
-
e.stopPropagation();
|
| 2091 |
-
// Ensure node is selected for copy if it isn't already
|
| 2092 |
-
const targetNode = nodes.find(n => n.id === contextMenu.nodeId);
|
| 2093 |
-
if (targetNode) {
|
| 2094 |
-
const isSelected = targetNode.selected;
|
| 2095 |
-
if (!isSelected) {
|
| 2096 |
-
setNodes(nds => nds.map(n => n.id === contextMenu.nodeId ? { ...n, selected: true } : n));
|
| 2097 |
-
}
|
| 2098 |
-
// Copy node(s)
|
| 2099 |
-
const selectedNodes = nodes.filter(n => n.selected || n.id === contextMenu.nodeId);
|
| 2100 |
-
const selectedEdges = edges.filter(edge =>
|
| 2101 |
-
selectedNodes.some(n => n.id === edge.source) &&
|
| 2102 |
-
selectedNodes.some(n => n.id === edge.target)
|
| 2103 |
-
);
|
| 2104 |
-
const clipboardPayload = {
|
| 2105 |
-
nodes: selectedNodes.map(n => ({ ...n, originalPosition: { ...n.position } })),
|
| 2106 |
-
edges: selectedEdges
|
| 2107 |
-
};
|
| 2108 |
-
window.__flowClipboard = clipboardPayload;
|
| 2109 |
-
triggerClipboardUpdate();
|
| 2110 |
-
try {
|
| 2111 |
-
navigator.clipboard.writeText('FLOWWEB_NODES_DATA:' + JSON.stringify(clipboardPayload)).catch(() => {});
|
| 2112 |
-
} catch (err) {}
|
| 2113 |
-
}
|
| 2114 |
-
setContextMenu(null);
|
| 2115 |
-
}}
|
| 2116 |
-
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2117 |
>
|
| 2118 |
-
<
|
| 2119 |
-
|
| 2120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2121 |
|
| 2122 |
-
|
| 2123 |
-
|
| 2124 |
-
|
| 2125 |
-
|
| 2126 |
-
|
| 2127 |
-
if (targetNode) {
|
| 2128 |
-
const selectedNodes = nodes.filter(n => n.selected || n.id === contextMenu.nodeId);
|
| 2129 |
-
const selectedEdges = edges.filter(edge =>
|
| 2130 |
-
selectedNodes.some(n => n.id === edge.source) &&
|
| 2131 |
-
selectedNodes.some(n => n.id === edge.target)
|
| 2132 |
-
);
|
| 2133 |
-
const clipboardPayload = {
|
| 2134 |
-
nodes: selectedNodes.map(n => ({ ...n, originalPosition: { ...n.position } })),
|
| 2135 |
-
edges: selectedEdges
|
| 2136 |
-
};
|
| 2137 |
-
window.__flowClipboard = clipboardPayload;
|
| 2138 |
-
triggerClipboardUpdate();
|
| 2139 |
-
try {
|
| 2140 |
-
navigator.clipboard.writeText('FLOWWEB_NODES_DATA:' + JSON.stringify(clipboardPayload)).catch(() => {});
|
| 2141 |
-
} catch (err) {}
|
| 2142 |
-
// Delete selected nodes
|
| 2143 |
-
selectedNodes.forEach(n => deleteNode(n.id));
|
| 2144 |
-
}
|
| 2145 |
-
setContextMenu(null);
|
| 2146 |
-
}}
|
| 2147 |
-
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2148 |
>
|
| 2149 |
-
|
| 2150 |
-
|
| 2151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2152 |
|
| 2153 |
-
|
| 2154 |
-
|
| 2155 |
-
|
| 2156 |
-
const targetNode = nodes.find(n => n.id === contextMenu.nodeId);
|
| 2157 |
-
if (targetNode) {
|
| 2158 |
-
const newPinnedState = !targetNode.data?.isPinned;
|
| 2159 |
-
updateNodeData(contextMenu.nodeId, { isPinned: newPinnedState });
|
| 2160 |
-
}
|
| 2161 |
-
setContextMenu(null);
|
| 2162 |
-
}}
|
| 2163 |
-
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2164 |
-
>
|
| 2165 |
-
<Pin
|
| 2166 |
-
size={14}
|
| 2167 |
-
className={`text-primary transition-transform ${
|
| 2168 |
-
nodes.find(n => n.id === contextMenu.nodeId)?.data?.isPinned
|
| 2169 |
-
? 'rotate-45 text-amber-500 fill-amber-500'
|
| 2170 |
-
: ''
|
| 2171 |
-
}`}
|
| 2172 |
-
/>
|
| 2173 |
-
<span>
|
| 2174 |
-
{nodes.find(n => n.id === contextMenu.nodeId)?.data?.isPinned
|
| 2175 |
-
? t('canvas.context_menu.unpin', 'Відкріпити')
|
| 2176 |
-
: t('canvas.context_menu.pin', 'Закріпити')}
|
| 2177 |
-
</span>
|
| 2178 |
-
</button>
|
| 2179 |
|
| 2180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2181 |
|
| 2182 |
-
|
| 2183 |
-
|
| 2184 |
-
|
| 2185 |
-
|
| 2186 |
-
|
| 2187 |
-
}}
|
| 2188 |
-
className="w-full text-left px-2.5 py-1.5 hover:bg-red-500/20 text-red-600 hover:text-red-700 font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2189 |
>
|
| 2190 |
-
<
|
| 2191 |
-
|
| 2192 |
-
|
| 2193 |
-
|
| 2194 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2195 |
|
| 2196 |
-
|
| 2197 |
-
|
| 2198 |
-
|
| 2199 |
-
|
| 2200 |
-
|
| 2201 |
-
|
| 2202 |
-
|
| 2203 |
-
|
| 2204 |
-
|
| 2205 |
-
|
| 2206 |
-
|
| 2207 |
-
|
| 2208 |
-
|
| 2209 |
-
|
| 2210 |
-
|
| 2211 |
-
|
| 2212 |
-
|
| 2213 |
-
|
| 2214 |
-
|
| 2215 |
-
|
| 2216 |
-
|
| 2217 |
-
|
| 2218 |
-
|
| 2219 |
-
|
| 2220 |
-
e.stopPropagation();
|
| 2221 |
-
addNewNodeAt(contextMenu.clientX, contextMenu.clientY, 'comment');
|
| 2222 |
-
setContextMenu(null);
|
| 2223 |
-
}}
|
| 2224 |
-
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2225 |
-
>
|
| 2226 |
-
<StickyNote size={15} className="text-secondary" />
|
| 2227 |
-
<span>Стікер</span>
|
| 2228 |
-
</button>
|
| 2229 |
-
<button
|
| 2230 |
-
onClick={(e) => {
|
| 2231 |
-
e.stopPropagation();
|
| 2232 |
-
addNewNodeAt(contextMenu.clientX, contextMenu.clientY, 'condition');
|
| 2233 |
-
setContextMenu(null);
|
| 2234 |
-
}}
|
| 2235 |
-
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2236 |
-
>
|
| 2237 |
-
<GitFork size={15} className="text-primary" />
|
| 2238 |
-
<span>{t('canvas.context_menu.condition_node')}</span>
|
| 2239 |
-
</button>
|
| 2240 |
-
<button
|
| 2241 |
-
onClick={(e) => {
|
| 2242 |
-
e.stopPropagation();
|
| 2243 |
-
addNewNodeAt(contextMenu.clientX, contextMenu.clientY, 'habit');
|
| 2244 |
-
setContextMenu(null);
|
| 2245 |
-
}}
|
| 2246 |
-
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2247 |
-
>
|
| 2248 |
-
<Timer size={15} className="text-secondary" />
|
| 2249 |
-
<span>{t('canvas.context_menu.habit_node')}</span>
|
| 2250 |
-
</button>
|
| 2251 |
|
| 2252 |
-
|
| 2253 |
-
|
| 2254 |
-
|
| 2255 |
-
|
| 2256 |
-
|
| 2257 |
-
|
| 2258 |
-
|
| 2259 |
-
|
| 2260 |
-
}}
|
| 2261 |
-
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2262 |
-
>
|
| 2263 |
-
<Clipboard size={15} className="text-primary" />
|
| 2264 |
-
<span>{t('canvas.context_menu.paste', 'Вставити')}</span>
|
| 2265 |
-
</button>
|
| 2266 |
-
</>
|
| 2267 |
-
)}
|
| 2268 |
|
| 2269 |
-
|
| 2270 |
-
|
| 2271 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2272 |
|
| 2273 |
-
|
| 2274 |
-
|
| 2275 |
-
|
| 2276 |
-
|
| 2277 |
-
|
| 2278 |
-
|
| 2279 |
-
|
| 2280 |
-
|
| 2281 |
-
<Image size={15} className="text-green-600" />
|
| 2282 |
-
<span>{t('canvas.context_menu.export_png')}</span>
|
| 2283 |
-
</button>
|
| 2284 |
-
</div>
|
| 2285 |
-
)}
|
| 2286 |
|
| 2287 |
-
|
| 2288 |
-
|
| 2289 |
-
|
| 2290 |
-
|
| 2291 |
-
|
| 2292 |
-
>
|
| 2293 |
-
|
| 2294 |
-
|
| 2295 |
-
|
| 2296 |
-
|
| 2297 |
-
|
| 2298 |
-
|
| 2299 |
-
|
| 2300 |
-
|
| 2301 |
-
|
| 2302 |
-
|
| 2303 |
-
|
| 2304 |
-
|
| 2305 |
-
|
| 2306 |
-
|
| 2307 |
-
|
| 2308 |
-
});
|
| 2309 |
-
}
|
| 2310 |
-
setContextMenu(null);
|
| 2311 |
-
}}
|
| 2312 |
-
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2313 |
-
>
|
| 2314 |
-
<Target size={15} className="text-primary" />
|
| 2315 |
-
<span>{t('canvas.context_menu.goal_node')}</span>
|
| 2316 |
-
</button>
|
| 2317 |
-
<button
|
| 2318 |
-
onClick={(e) => {
|
| 2319 |
-
e.stopPropagation();
|
| 2320 |
-
const newNodeId = addNewNodeAt(contextMenu.clientX, contextMenu.clientY, 'condition');
|
| 2321 |
-
if (contextMenu.connectionSource && newNodeId) {
|
| 2322 |
-
const { nodeId, handleId, handleType } = contextMenu.connectionSource;
|
| 2323 |
-
const isSource = handleType === 'source';
|
| 2324 |
-
onConnect({
|
| 2325 |
-
source: isSource ? nodeId : newNodeId,
|
| 2326 |
-
target: isSource ? newNodeId : nodeId,
|
| 2327 |
-
sourceHandle: isSource ? handleId : null,
|
| 2328 |
-
targetHandle: isSource ? null : handleId
|
| 2329 |
-
});
|
| 2330 |
-
}
|
| 2331 |
-
setContextMenu(null);
|
| 2332 |
-
}}
|
| 2333 |
-
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2334 |
-
>
|
| 2335 |
-
<GitFork size={15} className="text-primary" />
|
| 2336 |
-
<span>{t('canvas.context_menu.condition_node')}</span>
|
| 2337 |
-
</button>
|
| 2338 |
-
<button
|
| 2339 |
-
onClick={(e) => {
|
| 2340 |
-
e.stopPropagation();
|
| 2341 |
-
const newNodeId = addNewNodeAt(contextMenu.clientX, contextMenu.clientY, 'habit');
|
| 2342 |
-
if (contextMenu.connectionSource && newNodeId) {
|
| 2343 |
-
const { nodeId, handleId, handleType } = contextMenu.connectionSource;
|
| 2344 |
-
const isSource = handleType === 'source';
|
| 2345 |
-
onConnect({
|
| 2346 |
-
source: isSource ? nodeId : newNodeId,
|
| 2347 |
-
target: isSource ? newNodeId : nodeId,
|
| 2348 |
-
sourceHandle: isSource ? handleId : null,
|
| 2349 |
-
targetHandle: isSource ? null : handleId
|
| 2350 |
-
});
|
| 2351 |
-
}
|
| 2352 |
-
setContextMenu(null);
|
| 2353 |
-
}}
|
| 2354 |
-
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2355 |
-
>
|
| 2356 |
-
<Timer size={15} className="text-secondary" />
|
| 2357 |
-
<span>{t('canvas.context_menu.habit_node')}</span>
|
| 2358 |
-
</button>
|
| 2359 |
-
</div>
|
| 2360 |
-
)}
|
| 2361 |
-
</div>
|
| 2362 |
</main>
|
| 2363 |
</div>
|
| 2364 |
);
|
|
|
|
| 83 |
import Sidebar from '../components/Sidebar';
|
| 84 |
import { useAuth } from '../context/AuthContext';
|
| 85 |
import { useTheme } from '../context/ThemeContext';
|
| 86 |
+
import { Maximize2, Play, Settings, Save, ChevronLeft, ChevronDown, ChevronUp, Loader2, Trash2, Target, GitFork, Image, X, Plus, Timer, Milestone, Share2, User, Copy, Scissors, Clipboard, Check, StickyNote, Pin, Type, Tag, Search, ListTodo, Map } from 'lucide-react';
|
| 87 |
|
| 88 |
// Feature components
|
| 89 |
import SketchNode from '../features/canvas/components/nodes/SketchNode';
|
|
|
|
| 515 |
const [description, setDescription] = useState(node.data.description || '');
|
| 516 |
const [showProgress, setShowProgress] = useState(!!node.data.showProgress);
|
| 517 |
const [progress, setProgress] = useState(node.data.progress || 0);
|
| 518 |
+
const [status, setStatus] = useState(node.data.status || 'Todo');
|
| 519 |
+
const [tags, setTags] = useState(node.data.tags || []);
|
| 520 |
+
const [customTagInput, setCustomTagInput] = useState('');
|
| 521 |
+
const [isTagsExpanded, setIsTagsExpanded] = useState(false);
|
| 522 |
const [assignees, setAssignees] = useState(node.data.assignees || []);
|
| 523 |
const [isAssigneesExpanded, setIsAssigneesExpanded] = useState(false);
|
| 524 |
const [color, setColor] = useState(node.data.color || (node.type === 'comment' ? 'yellow' : 'default'));
|
|
|
|
| 534 |
white: { light: '#f5f5f4', dark: '#1c1917', borderLight: '#d6d3d1', borderDark: '#44403c' }
|
| 535 |
};
|
| 536 |
|
| 537 |
+
const handleStatusChange = (newStatus) => {
|
| 538 |
+
setStatus(newStatus);
|
| 539 |
+
if (newStatus === 'Done') {
|
| 540 |
+
setProgress(100);
|
| 541 |
+
} else if (newStatus === 'Todo') {
|
| 542 |
+
setProgress(0);
|
| 543 |
+
} else {
|
| 544 |
+
if (progress === 100 || progress === 0) {
|
| 545 |
+
setProgress(newStatus === 'Review' ? 90 : 10);
|
| 546 |
+
}
|
| 547 |
+
}
|
| 548 |
+
};
|
| 549 |
+
|
| 550 |
+
const handleProgressChange = (newVal) => {
|
| 551 |
+
const numericVal = Number(newVal);
|
| 552 |
+
setProgress(numericVal);
|
| 553 |
+
if (numericVal === 100) {
|
| 554 |
+
setStatus('Done');
|
| 555 |
+
} else if (numericVal === 0) {
|
| 556 |
+
setStatus('Todo');
|
| 557 |
+
} else {
|
| 558 |
+
if (status === 'Done' || status === 'Todo') {
|
| 559 |
+
setStatus('In Progress');
|
| 560 |
+
}
|
| 561 |
+
}
|
| 562 |
+
};
|
| 563 |
+
|
| 564 |
const handleSave = () => {
|
| 565 |
let finalProgress = Number(progress);
|
| 566 |
+
let finalStatus = status;
|
| 567 |
|
| 568 |
if (node.type === 'sketch' && finalProgress === 100 && node.data.subgoals && node.data.subgoals.done < node.data.subgoals.total) {
|
| 569 |
finalProgress = 99;
|
| 570 |
+
finalStatus = 'In Progress';
|
| 571 |
alert(t('canvas.subgoal_warning', 'Виконайте всі підцілі перед завершенням головної цілі!'));
|
| 572 |
}
|
| 573 |
|
|
|
|
| 576 |
description,
|
| 577 |
showProgress: node.type === 'comment' ? false : showProgress,
|
| 578 |
progress: node.type === 'comment' ? 0 : finalProgress,
|
| 579 |
+
status: node.type === 'comment' ? 'Pending' : finalStatus,
|
| 580 |
assignees,
|
| 581 |
+
color,
|
| 582 |
+
tags
|
| 583 |
});
|
| 584 |
};
|
| 585 |
|
|
|
|
| 662 |
</div>
|
| 663 |
</div>
|
| 664 |
|
| 665 |
+
{/* Goal Status Selector */}
|
| 666 |
+
{node.type !== 'comment' && (
|
| 667 |
+
<div>
|
| 668 |
+
<label className="block text-[11px] font-bold uppercase tracking-widest text-primary/60 mb-2">
|
| 669 |
+
{t('canvas.goal_status_label', 'Статус цілі')}
|
| 670 |
+
</label>
|
| 671 |
+
<div className="grid grid-cols-4 gap-1.5 bg-secondary/5 border-2 border-primary rounded-xl p-1.5">
|
| 672 |
+
{['Todo', 'In Progress', 'Review', 'Done'].map((st) => {
|
| 673 |
+
const isSelected = status === st;
|
| 674 |
+
let btnClass = isSelected
|
| 675 |
+
? 'bg-primary text-on-primary shadow-[2px_2px_0px_0px_var(--color-primary)]'
|
| 676 |
+
: 'text-primary/70 hover:bg-secondary/10 hover:text-primary';
|
| 677 |
+
|
| 678 |
+
return (
|
| 679 |
+
<button
|
| 680 |
+
key={st}
|
| 681 |
+
type="button"
|
| 682 |
+
onClick={() => handleStatusChange(st)}
|
| 683 |
+
className={`py-2 text-[10px] font-extrabold uppercase rounded-lg border border-transparent transition-all cursor-pointer ${btnClass}`}
|
| 684 |
+
>
|
| 685 |
+
{st === 'In Progress' ? 'Progress' : st}
|
| 686 |
+
</button>
|
| 687 |
+
);
|
| 688 |
+
})}
|
| 689 |
+
</div>
|
| 690 |
+
</div>
|
| 691 |
+
)}
|
| 692 |
|
| 693 |
{/* Assignees Input (Collapsible custom visual list) */}
|
| 694 |
{node.type !== 'comment' && (
|
|
|
|
| 783 |
</div>
|
| 784 |
)}
|
| 785 |
|
| 786 |
+
{/* Goal Tags Editor */}
|
| 787 |
+
{node.type !== 'comment' && (
|
| 788 |
+
<div className="border-2 border-primary rounded-2xl p-4 bg-secondary/5 space-y-2">
|
| 789 |
+
<div
|
| 790 |
+
onClick={() => setIsTagsExpanded(!isTagsExpanded)}
|
| 791 |
+
className="flex items-center justify-between cursor-pointer select-none"
|
| 792 |
+
>
|
| 793 |
+
<label className="block text-[11px] font-bold uppercase tracking-widest text-primary/60 cursor-pointer">
|
| 794 |
+
{t('canvas.tags_label', 'Теги цілі')}
|
| 795 |
+
</label>
|
| 796 |
+
<div className="flex items-center gap-2">
|
| 797 |
+
{!isTagsExpanded && tags.length > 0 && (
|
| 798 |
+
<div className="flex flex-wrap gap-1 items-center max-w-[150px] justify-end">
|
| 799 |
+
{tags.slice(0, 2).map((tg, i) => (
|
| 800 |
+
<span key={i} className="text-[8px] font-extrabold px-1.5 py-0.5 rounded border border-primary/20 bg-secondary/15 text-primary">
|
| 801 |
+
{tg}
|
| 802 |
+
</span>
|
| 803 |
+
))}
|
| 804 |
+
{tags.length > 2 && <span className="text-[8px] font-bold">+{tags.length - 2}</span>}
|
| 805 |
+
</div>
|
| 806 |
+
)}
|
| 807 |
+
{isTagsExpanded ? <ChevronUp size={16} className="text-primary" /> : <ChevronDown size={16} className="text-primary" />}
|
| 808 |
+
</div>
|
| 809 |
+
</div>
|
| 810 |
+
|
| 811 |
+
{isTagsExpanded && (
|
| 812 |
+
<div className="space-y-3 pt-2 animate-fade-in">
|
| 813 |
+
{/* Current Tags */}
|
| 814 |
+
{tags.length > 0 ? (
|
| 815 |
+
<div className="flex flex-wrap gap-1.5">
|
| 816 |
+
{tags.map((tg, idx) => (
|
| 817 |
+
<span
|
| 818 |
+
key={idx}
|
| 819 |
+
className="flex items-center gap-1.5 text-[10px] font-extrabold px-2.5 py-0.5 rounded-lg border-2 border-primary bg-surface-container-lowest text-primary shadow-[1px_1px_0px_0px_var(--color-primary)]"
|
| 820 |
+
>
|
| 821 |
+
<span>{tg}</span>
|
| 822 |
+
<button
|
| 823 |
+
type="button"
|
| 824 |
+
onClick={() => setTags(tags.filter(t => t !== tg))}
|
| 825 |
+
className="hover:text-red-500 font-extrabold ml-1 cursor-pointer"
|
| 826 |
+
>
|
| 827 |
+
×
|
| 828 |
+
</button>
|
| 829 |
+
</span>
|
| 830 |
+
))}
|
| 831 |
+
</div>
|
| 832 |
+
) : (
|
| 833 |
+
<p className="text-[10px] text-primary/40 italic">{t('canvas.no_tags', 'Немає тегів')}</p>
|
| 834 |
+
)}
|
| 835 |
+
|
| 836 |
+
{/* Quick default tags selector */}
|
| 837 |
+
<div className="space-y-1">
|
| 838 |
+
<span className="block text-[8px] font-bold uppercase tracking-wider text-primary/50">{t('canvas.quick_add_tags', 'Швидкі теги')}</span>
|
| 839 |
+
<div className="flex flex-wrap gap-1 max-h-[70px] overflow-y-auto pr-1">
|
| 840 |
+
{['Todo', 'In Progress', 'Done', 'Feature', 'Bug', 'Refactor', 'High', 'Medium', 'Low'].map(tg => {
|
| 841 |
+
const isAdded = tags.includes(tg);
|
| 842 |
+
return (
|
| 843 |
+
<button
|
| 844 |
+
key={tg}
|
| 845 |
+
type="button"
|
| 846 |
+
disabled={isAdded}
|
| 847 |
+
onClick={() => setTags([...tags, tg])}
|
| 848 |
+
className={`text-[9px] font-extrabold px-2 py-1 rounded-lg border-2 border-primary/20 transition-all ${
|
| 849 |
+
isAdded
|
| 850 |
+
? 'bg-secondary/20 text-primary/40 cursor-not-allowed border-transparent'
|
| 851 |
+
: 'bg-surface-container-lowest text-primary border-primary/40 hover:border-primary cursor-pointer'
|
| 852 |
+
}`}
|
| 853 |
+
>
|
| 854 |
+
+{tg}
|
| 855 |
+
</button>
|
| 856 |
+
);
|
| 857 |
+
})}
|
| 858 |
+
</div>
|
| 859 |
+
</div>
|
| 860 |
+
|
| 861 |
+
{/* Add Custom Tag */}
|
| 862 |
+
<div className="flex gap-2">
|
| 863 |
+
<input
|
| 864 |
+
type="text"
|
| 865 |
+
value={customTagInput}
|
| 866 |
+
onChange={(e) => setCustomTagInput(e.target.value)}
|
| 867 |
+
placeholder={t('canvas.custom_tag_placeholder', 'Свій тег')}
|
| 868 |
+
className="flex-grow px-3 py-1.5 bg-secondary/10 border-2 border-primary rounded-xl text-xs font-bold text-primary focus:outline-none focus:bg-surface-container-lowest transition-all"
|
| 869 |
+
onKeyDown={(e) => {
|
| 870 |
+
if (e.key === 'Enter') {
|
| 871 |
+
e.preventDefault();
|
| 872 |
+
if (customTagInput.trim() && !tags.includes(customTagInput.trim())) {
|
| 873 |
+
setTags([...tags, customTagInput.trim()]);
|
| 874 |
+
setCustomTagInput('');
|
| 875 |
+
}
|
| 876 |
+
}
|
| 877 |
+
}}
|
| 878 |
+
/>
|
| 879 |
+
<button
|
| 880 |
+
type="button"
|
| 881 |
+
onClick={() => {
|
| 882 |
+
if (customTagInput.trim() && !tags.includes(customTagInput.trim())) {
|
| 883 |
+
setTags([...tags, customTagInput.trim()]);
|
| 884 |
+
setCustomTagInput('');
|
| 885 |
+
}
|
| 886 |
+
}}
|
| 887 |
+
className="px-3 py-1.5 bg-primary text-on-primary font-bold text-xs rounded-xl border-2 border-primary cursor-pointer hover:scale-105 transition-all shadow-[2px_2px_0px_0px_var(--color-primary)] active:translate-y-0.5"
|
| 888 |
+
>
|
| 889 |
+
+
|
| 890 |
+
</button>
|
| 891 |
+
</div>
|
| 892 |
+
</div>
|
| 893 |
+
)}
|
| 894 |
+
</div>
|
| 895 |
+
)}
|
| 896 |
+
|
| 897 |
{/* Show Progress Toggle */}
|
| 898 |
{node.type !== 'comment' && (
|
| 899 |
<div className="flex items-center justify-between bg-secondary/5 border-2 border-primary rounded-xl p-4">
|
|
|
|
| 931 |
min="0"
|
| 932 |
max="100"
|
| 933 |
value={progress}
|
| 934 |
+
onChange={(e) => handleProgressChange(e.target.value)}
|
| 935 |
className="w-full h-2 rounded-lg appearance-none cursor-pointer focus:outline-none transition-all accent-emerald-500"
|
| 936 |
style={{
|
| 937 |
background: `linear-gradient(to right, #10b981 ${progress}%, var(--color-border, #e2e8f0) ${progress}%)`
|
|
|
|
| 1397 |
);
|
| 1398 |
};
|
| 1399 |
|
| 1400 |
+
const TaskListView = ({
|
| 1401 |
+
nodes, edges, updateNodeData, collaborators = [], permission, t, isDarkMode,
|
| 1402 |
+
searchQuery, setSearchQuery, filterActive, setFilterActive, filterCollaborator,
|
| 1403 |
+
setFilterCollaborator, filterTag, setFilterTag, expandedNodes, setExpandedNodes,
|
| 1404 |
+
setEditingSketchNodeId, setEditingHabitNodeId
|
| 1405 |
+
}) => {
|
| 1406 |
+
const [addingTagNodeId, setAddingTagNodeId] = useState(null);
|
| 1407 |
+
const [inlineTagText, setInlineTagText] = useState('');
|
| 1408 |
+
|
| 1409 |
+
// 1. Identify subgoals & build dependency structures
|
| 1410 |
+
const subgoalEdges = useMemo(() => edges.filter(e => e.sourceHandle?.startsWith('subgoals-source')), [edges]);
|
| 1411 |
+
|
| 1412 |
+
const subtaskTargetIds = useMemo(() => new Set(subgoalEdges.map(e => e.target)), [subgoalEdges]);
|
| 1413 |
+
|
| 1414 |
+
const getSubtasks = useCallback((parentId) => {
|
| 1415 |
+
return subgoalEdges
|
| 1416 |
+
.filter(e => e.source === parentId)
|
| 1417 |
+
.map(e => nodes.find(n => n.id === e.target))
|
| 1418 |
+
.filter(Boolean)
|
| 1419 |
+
.filter(n => n.type === 'sketch' || n.type === 'habit');
|
| 1420 |
+
}, [subgoalEdges, nodes]);
|
| 1421 |
+
|
| 1422 |
+
// 2. Extract tags & filter matching
|
| 1423 |
+
const allUniqueTags = useMemo(() => {
|
| 1424 |
+
const defaultTags = ['Todo', 'In Progress', 'Done', 'Feature', 'Bug', 'Refactor', 'High', 'Medium', 'Low'];
|
| 1425 |
+
const set = new Set(defaultTags);
|
| 1426 |
+
nodes.forEach(n => {
|
| 1427 |
+
n.data?.tags?.forEach(tg => {
|
| 1428 |
+
if (tg && typeof tg === 'string') {
|
| 1429 |
+
set.add(tg.trim());
|
| 1430 |
+
}
|
| 1431 |
+
});
|
| 1432 |
+
});
|
| 1433 |
+
return Array.from(set);
|
| 1434 |
+
}, [nodes]);
|
| 1435 |
+
|
| 1436 |
+
const matchesFilters = useCallback((node) => {
|
| 1437 |
+
// 1. Active filter: те к которым мы подошли и еще не выполнили
|
| 1438 |
+
if (filterActive && !(node.data?.isReached && node.data?.status !== 'Done')) {
|
| 1439 |
+
return false;
|
| 1440 |
+
}
|
| 1441 |
+
|
| 1442 |
+
// 2. Collaborator filter: чисто задания конкретного участника
|
| 1443 |
+
if (filterCollaborator !== 'all') {
|
| 1444 |
+
const assignees = node.data?.assignees || [];
|
| 1445 |
+
const isAssigned = assignees.some(a => (a.sharedWithUserId || 'owner') === filterCollaborator);
|
| 1446 |
+
if (!isAssigned) return false;
|
| 1447 |
+
}
|
| 1448 |
+
|
| 1449 |
+
// 3. Tag filter
|
| 1450 |
+
if (filterTag !== 'all') {
|
| 1451 |
+
if (!node.data?.tags?.includes(filterTag)) {
|
| 1452 |
+
return false;
|
| 1453 |
+
}
|
| 1454 |
+
}
|
| 1455 |
+
|
| 1456 |
+
// 4. Search query
|
| 1457 |
+
if (searchQuery.trim() !== '') {
|
| 1458 |
+
const q = searchQuery.toLowerCase();
|
| 1459 |
+
const l = (node.data?.label || '').toLowerCase();
|
| 1460 |
+
const d = (node.data?.description || '').toLowerCase();
|
| 1461 |
+
const tgs = (node.data?.tags || []).map(tg => tg.toLowerCase());
|
| 1462 |
+
if (!l.includes(q) && !d.includes(q) && !tgs.some(tg => tg.includes(q))) {
|
| 1463 |
+
return false;
|
| 1464 |
+
}
|
| 1465 |
+
}
|
| 1466 |
+
|
| 1467 |
+
return true;
|
| 1468 |
+
}, [filterActive, filterCollaborator, filterTag, searchQuery]);
|
| 1469 |
+
|
| 1470 |
+
const nodeOrDescendantMatches = useCallback((node) => {
|
| 1471 |
+
if (matchesFilters(node)) return true;
|
| 1472 |
+
return getSubtasks(node.id).some(s => nodeOrDescendantMatches(s));
|
| 1473 |
+
}, [matchesFilters, getSubtasks]);
|
| 1474 |
+
|
| 1475 |
+
const orderedRootTasks = useMemo(() => {
|
| 1476 |
+
const rootTasks = nodes.filter(n => (n.type === 'sketch' || n.type === 'habit') && !subtaskTargetIds.has(n.id));
|
| 1477 |
+
const normalEdges = edges.filter(e => !e.sourceHandle?.startsWith('subgoals-source'));
|
| 1478 |
+
|
| 1479 |
+
// Build adj list and inDegrees for standard edges (sequence flow)
|
| 1480 |
+
const adj = {};
|
| 1481 |
+
const inDegree = {};
|
| 1482 |
+
rootTasks.forEach(t => {
|
| 1483 |
+
adj[t.id] = [];
|
| 1484 |
+
inDegree[t.id] = 0;
|
| 1485 |
+
});
|
| 1486 |
+
|
| 1487 |
+
normalEdges.forEach(e => {
|
| 1488 |
+
if (adj[e.source] && adj[e.target] !== undefined) {
|
| 1489 |
+
adj[e.source].push(e.target);
|
| 1490 |
+
inDegree[e.target]++;
|
| 1491 |
+
}
|
| 1492 |
+
});
|
| 1493 |
+
|
| 1494 |
+
const queue = rootTasks.filter(t => inDegree[t.id] === 0).map(t => t.id);
|
| 1495 |
+
const orderedIds = [];
|
| 1496 |
+
|
| 1497 |
+
while (queue.length > 0) {
|
| 1498 |
+
queue.sort((a, b) => {
|
| 1499 |
+
const nodeA = nodes.find(n => n.id === a);
|
| 1500 |
+
const nodeB = nodes.find(n => n.id === b);
|
| 1501 |
+
return (nodeA?.position?.y || 0) - (nodeB?.position?.y || 0);
|
| 1502 |
+
});
|
| 1503 |
+
|
| 1504 |
+
const curr = queue.shift();
|
| 1505 |
+
orderedIds.push(curr);
|
| 1506 |
+
|
| 1507 |
+
adj[curr]?.forEach(neigh => {
|
| 1508 |
+
inDegree[neigh]--;
|
| 1509 |
+
if (inDegree[neigh] === 0) {
|
| 1510 |
+
queue.push(neigh);
|
| 1511 |
+
}
|
| 1512 |
+
});
|
| 1513 |
+
}
|
| 1514 |
+
|
| 1515 |
+
rootTasks.forEach(t => {
|
| 1516 |
+
if (!orderedIds.includes(t.id)) {
|
| 1517 |
+
orderedIds.push(t.id);
|
| 1518 |
+
}
|
| 1519 |
+
});
|
| 1520 |
+
|
| 1521 |
+
return orderedIds
|
| 1522 |
+
.map(id => nodes.find(n => n.id === id))
|
| 1523 |
+
.filter(Boolean)
|
| 1524 |
+
.filter(nodeOrDescendantMatches);
|
| 1525 |
+
}, [nodes, edges, subtaskTargetIds, nodeOrDescendantMatches]);
|
| 1526 |
+
|
| 1527 |
+
const toggleExpanded = (nodeId) => {
|
| 1528 |
+
const newExpanded = new Set(expandedNodes);
|
| 1529 |
+
if (newExpanded.has(nodeId)) {
|
| 1530 |
+
newExpanded.delete(nodeId);
|
| 1531 |
+
} else {
|
| 1532 |
+
newExpanded.add(nodeId);
|
| 1533 |
+
}
|
| 1534 |
+
setExpandedNodes(newExpanded);
|
| 1535 |
+
};
|
| 1536 |
+
|
| 1537 |
+
const handleToggleStatus = (nodeId, currentStatus) => {
|
| 1538 |
+
if (permission === 'Read') return;
|
| 1539 |
+
const isDone = currentStatus === 'Done';
|
| 1540 |
+
const newStatus = isDone ? 'Todo' : 'Done';
|
| 1541 |
+
const newProgress = isDone ? 0 : 100;
|
| 1542 |
+
|
| 1543 |
+
updateNodeData(nodeId, {
|
| 1544 |
+
status: newStatus,
|
| 1545 |
+
progress: newProgress
|
| 1546 |
+
});
|
| 1547 |
+
};
|
| 1548 |
+
|
| 1549 |
+
const handleDropdownStatusChange = (nodeId, newStatus) => {
|
| 1550 |
+
if (permission === 'Read') return;
|
| 1551 |
+
let newProgress = 0;
|
| 1552 |
+
if (newStatus === 'Done') {
|
| 1553 |
+
newProgress = 100;
|
| 1554 |
+
} else if (newStatus === 'Todo') {
|
| 1555 |
+
newProgress = 0;
|
| 1556 |
+
} else if (newStatus === 'Review') {
|
| 1557 |
+
newProgress = 90;
|
| 1558 |
+
} else {
|
| 1559 |
+
newProgress = 50;
|
| 1560 |
+
}
|
| 1561 |
+
|
| 1562 |
+
updateNodeData(nodeId, {
|
| 1563 |
+
status: newStatus,
|
| 1564 |
+
progress: newProgress
|
| 1565 |
+
});
|
| 1566 |
+
};
|
| 1567 |
+
|
| 1568 |
+
const renderTaskCard = (node, depth = 0) => {
|
| 1569 |
+
const subtasks = getSubtasks(node.id).filter(nodeOrDescendantMatches);
|
| 1570 |
+
const isExpanded = expandedNodes.has(node.id);
|
| 1571 |
+
const hasSubtasks = subtasks.length > 0;
|
| 1572 |
+
|
| 1573 |
+
const isDone = node.data?.status === 'Done';
|
| 1574 |
+
const status = node.data?.status || 'Todo';
|
| 1575 |
+
const progress = node.data?.progress || 0;
|
| 1576 |
+
|
| 1577 |
+
const pastelColor = node.data?.color || 'default';
|
| 1578 |
+
const bgColors = {
|
| 1579 |
+
default: 'bg-surface-container-lowest border-primary',
|
| 1580 |
+
yellow: 'bg-yellow-50 dark:bg-yellow-950/20 border-yellow-400 dark:border-yellow-600',
|
| 1581 |
+
blue: 'bg-blue-50 dark:bg-blue-950/20 border-blue-400 dark:border-blue-600',
|
| 1582 |
+
green: 'bg-emerald-50 dark:bg-emerald-950/20 border-emerald-400 dark:border-emerald-600',
|
| 1583 |
+
pink: 'bg-pink-50 dark:bg-pink-950/20 border-pink-400 dark:border-pink-600',
|
| 1584 |
+
purple: 'bg-purple-50 dark:bg-purple-950/20 border-purple-400 dark:border-purple-600',
|
| 1585 |
+
orange: 'bg-orange-50 dark:bg-orange-950/20 border-orange-400 dark:border-orange-600',
|
| 1586 |
+
white: 'bg-stone-50 dark:bg-stone-900/20 border-stone-300 dark:border-stone-700'
|
| 1587 |
+
};
|
| 1588 |
+
const cardBg = bgColors[pastelColor] || bgColors.default;
|
| 1589 |
+
|
| 1590 |
+
return (
|
| 1591 |
+
<div key={node.id} className="space-y-3">
|
| 1592 |
+
<div
|
| 1593 |
+
className={`p-4 border-2 rounded-2xl flex flex-col md:flex-row md:items-center justify-between gap-4 transition-all shadow-[3px_3px_0px_0px_var(--color-primary)] hover:shadow-[5px_5px_0px_0px_var(--color-primary)] ${cardBg} ${
|
| 1594 |
+
!node.data?.isReached ? 'opacity-65' : ''
|
| 1595 |
+
}`}
|
| 1596 |
+
>
|
| 1597 |
+
<div className="flex items-start gap-3 flex-1 min-w-0">
|
| 1598 |
+
<button
|
| 1599 |
+
disabled={permission === 'Read'}
|
| 1600 |
+
onClick={() => handleToggleStatus(node.id, status)}
|
| 1601 |
+
className={`mt-1 flex-shrink-0 w-5.5 h-5.5 rounded-lg border-2 border-primary transition-all flex items-center justify-center cursor-pointer ${
|
| 1602 |
+
isDone
|
| 1603 |
+
? 'bg-success border-success text-black'
|
| 1604 |
+
: 'bg-surface-container-lowest hover:bg-secondary/15'
|
| 1605 |
+
}`}
|
| 1606 |
+
>
|
| 1607 |
+
{isDone && <Check size={14} strokeWidth={4} className="text-black" />}
|
| 1608 |
+
</button>
|
| 1609 |
+
|
| 1610 |
+
{hasSubtasks ? (
|
| 1611 |
+
<button
|
| 1612 |
+
onClick={() => toggleExpanded(node.id)}
|
| 1613 |
+
className="mt-1 flex-shrink-0 p-0.5 border-2 border-primary rounded-md bg-surface-container-lowest hover:bg-secondary/10 text-primary transition-all cursor-pointer shadow-[1px_1px_0px_0px_var(--color-primary)]"
|
| 1614 |
+
>
|
| 1615 |
+
{isExpanded ? <ChevronUp size={12} strokeWidth={3} /> : <ChevronDown size={12} strokeWidth={3} />}
|
| 1616 |
+
</button>
|
| 1617 |
+
) : (
|
| 1618 |
+
<div className="w-5.5" />
|
| 1619 |
+
)}
|
| 1620 |
+
|
| 1621 |
+
<div className="min-w-0 flex-1">
|
| 1622 |
+
<div className="flex flex-wrap items-center gap-2">
|
| 1623 |
+
<span className={`font-display-md text-base text-primary leading-snug break-words ${isDone ? 'line-through opacity-50' : ''}`}>
|
| 1624 |
+
{node.data?.label || (node.type === 'habit' ? t('canvas.new_habit', 'Нова звичка') : t('canvas.goal_node', 'Ціль'))}
|
| 1625 |
+
</span>
|
| 1626 |
+
|
| 1627 |
+
<span className="text-[8px] font-extrabold uppercase px-1.5 py-0.5 rounded border border-primary/20 bg-secondary/10 text-primary/70 tracking-wider shadow-[0.5px_0.5px_0px_0px_rgba(0,0,0,0.05)]">
|
| 1628 |
+
{node.type === 'habit' ? 'Звичка' : 'Ціль'}
|
| 1629 |
+
</span>
|
| 1630 |
+
|
| 1631 |
+
{!node.data?.isReached && (
|
| 1632 |
+
<span className="text-[8px] font-extrabold uppercase px-1.5 py-0.5 rounded border border-amber-500 bg-amber-500/10 text-amber-500 tracking-wider">
|
| 1633 |
+
Заблоковано
|
| 1634 |
+
</span>
|
| 1635 |
+
)}
|
| 1636 |
+
</div>
|
| 1637 |
+
|
| 1638 |
+
{node.data?.description && (
|
| 1639 |
+
<p className={`text-xs text-primary/70 font-bold uppercase tracking-wide mt-1 leading-normal max-w-2xl break-words ${isDone ? 'opacity-40' : ''}`}>
|
| 1640 |
+
{node.data.description}
|
| 1641 |
+
</p>
|
| 1642 |
+
)}
|
| 1643 |
+
|
| 1644 |
+
<div className="flex flex-wrap items-center gap-1.5 mt-2.5">
|
| 1645 |
+
{(node.data?.tags || []).map((tg, idx) => (
|
| 1646 |
+
<span
|
| 1647 |
+
key={idx}
|
| 1648 |
+
className="flex items-center gap-1 text-[9px] font-extrabold px-1.5 py-0.5 rounded border border-primary bg-secondary/15 text-primary shadow-[1.5px_1.5px_0px_0px_rgba(0,0,0,0.05)]"
|
| 1649 |
+
>
|
| 1650 |
+
<span>{tg}</span>
|
| 1651 |
+
{permission !== 'Read' && (
|
| 1652 |
+
<button
|
| 1653 |
+
onClick={() => {
|
| 1654 |
+
const currentTags = node.data?.tags || [];
|
| 1655 |
+
updateNodeData(node.id, { tags: currentTags.filter(t => t !== tg) });
|
| 1656 |
+
}}
|
| 1657 |
+
className="hover:text-red-500 font-extrabold ml-0.5 text-[8px] cursor-pointer"
|
| 1658 |
+
>
|
| 1659 |
+
×
|
| 1660 |
+
</button>
|
| 1661 |
+
)}
|
| 1662 |
+
</span>
|
| 1663 |
+
))}
|
| 1664 |
+
|
| 1665 |
+
{addingTagNodeId === node.id ? (
|
| 1666 |
+
<div className="flex items-center gap-1.5 animate-fade-in">
|
| 1667 |
+
<input
|
| 1668 |
+
type="text"
|
| 1669 |
+
autoFocus
|
| 1670 |
+
value={inlineTagText}
|
| 1671 |
+
onChange={(e) => setInlineTagText(e.target.value)}
|
| 1672 |
+
placeholder="Новий тег..."
|
| 1673 |
+
onKeyDown={(e) => {
|
| 1674 |
+
if (e.key === 'Enter') {
|
| 1675 |
+
e.preventDefault();
|
| 1676 |
+
if (inlineTagText.trim()) {
|
| 1677 |
+
const currentTags = node.data?.tags || [];
|
| 1678 |
+
if (!currentTags.includes(inlineTagText.trim())) {
|
| 1679 |
+
updateNodeData(node.id, { tags: [...currentTags, inlineTagText.trim()] });
|
| 1680 |
+
}
|
| 1681 |
+
}
|
| 1682 |
+
setAddingTagNodeId(null);
|
| 1683 |
+
setInlineTagText('');
|
| 1684 |
+
} else if (e.key === 'Escape') {
|
| 1685 |
+
setAddingTagNodeId(null);
|
| 1686 |
+
setInlineTagText('');
|
| 1687 |
+
}
|
| 1688 |
+
}}
|
| 1689 |
+
className="px-2 py-0.5 border border-primary rounded bg-surface-container-lowest text-[10px] font-bold text-primary focus:outline-none w-20"
|
| 1690 |
+
/>
|
| 1691 |
+
<button
|
| 1692 |
+
onClick={() => {
|
| 1693 |
+
if (inlineTagText.trim()) {
|
| 1694 |
+
const currentTags = node.data?.tags || [];
|
| 1695 |
+
if (!currentTags.includes(inlineTagText.trim())) {
|
| 1696 |
+
updateNodeData(node.id, { tags: [...currentTags, inlineTagText.trim()] });
|
| 1697 |
+
}
|
| 1698 |
+
}
|
| 1699 |
+
setAddingTagNodeId(null);
|
| 1700 |
+
setInlineTagText('');
|
| 1701 |
+
}}
|
| 1702 |
+
className="text-[10px] font-extrabold text-green-600 hover:text-green-700 cursor-pointer"
|
| 1703 |
+
>
|
| 1704 |
+
✓
|
| 1705 |
+
</button>
|
| 1706 |
+
<button
|
| 1707 |
+
onClick={() => {
|
| 1708 |
+
setAddingTagNodeId(null);
|
| 1709 |
+
setInlineTagText('');
|
| 1710 |
+
}}
|
| 1711 |
+
className="text-[10px] font-extrabold text-red-500 hover:text-red-600 cursor-pointer"
|
| 1712 |
+
>
|
| 1713 |
+
×
|
| 1714 |
+
</button>
|
| 1715 |
+
</div>
|
| 1716 |
+
) : (
|
| 1717 |
+
permission !== 'Read' && (
|
| 1718 |
+
<button
|
| 1719 |
+
onClick={() => {
|
| 1720 |
+
setAddingTagNodeId(node.id);
|
| 1721 |
+
setInlineTagText('');
|
| 1722 |
+
}}
|
| 1723 |
+
className="text-[9px] font-extrabold px-1.5 py-0.5 rounded border-2 border-dashed border-primary/40 hover:border-primary text-primary bg-secondary/5 hover:bg-secondary/10 transition-colors flex items-center gap-0.5 cursor-pointer shadow-[1px_1px_0px_0px_rgba(0,0,0,0.05)]"
|
| 1724 |
+
>
|
| 1725 |
+
<Plus size={8} />
|
| 1726 |
+
<span>Тег</span>
|
| 1727 |
+
</button>
|
| 1728 |
+
)
|
| 1729 |
+
)}
|
| 1730 |
+
</div>
|
| 1731 |
+
</div>
|
| 1732 |
+
</div>
|
| 1733 |
+
|
| 1734 |
+
<div className="flex items-center gap-4 flex-wrap shrink-0 justify-end">
|
| 1735 |
+
{node.type === 'sketch' && node.data?.showProgress && (
|
| 1736 |
+
<div className="flex flex-col gap-1 w-24">
|
| 1737 |
+
<div className="flex items-center justify-between text-[9px] font-extrabold text-primary/60">
|
| 1738 |
+
<span>Прогрес</span>
|
| 1739 |
+
<span>{progress}%</span>
|
| 1740 |
+
</div>
|
| 1741 |
+
<div className="w-full bg-secondary/20 h-1.5 rounded-full overflow-hidden border border-primary/10">
|
| 1742 |
+
<div className="bg-emerald-500 h-full transition-all" style={{ width: `${progress}%` }} />
|
| 1743 |
+
</div>
|
| 1744 |
+
</div>
|
| 1745 |
+
)}
|
| 1746 |
+
|
| 1747 |
+
{node.data?.assignees && node.data.assignees.length > 0 && (
|
| 1748 |
+
<div className="flex items-center -space-x-1 flex-row-reverse">
|
| 1749 |
+
{node.data.assignees.slice(0, 3).reverse().map((a, i) => (
|
| 1750 |
+
<div
|
| 1751 |
+
key={a.sharedWithUserId || a.email || i}
|
| 1752 |
+
title={a.displayName || a.email}
|
| 1753 |
+
className="w-6.5 h-6.5 rounded-full border border-primary bg-secondary/15 text-primary flex items-center justify-center text-[9px] font-extrabold shadow-[1px_1px_0px_0px_var(--color-primary)]"
|
| 1754 |
+
>
|
| 1755 |
+
{getAssigneeInitials(a.displayName, a.email)}
|
| 1756 |
+
</div>
|
| 1757 |
+
))}
|
| 1758 |
+
{node.data.assignees.length > 3 && (
|
| 1759 |
+
<span className="text-[9px] font-extrabold text-primary mr-1">+{node.data.assignees.length - 3}</span>
|
| 1760 |
+
)}
|
| 1761 |
+
</div>
|
| 1762 |
+
)}
|
| 1763 |
+
|
| 1764 |
+
{node.type !== 'comment' && (
|
| 1765 |
+
<select
|
| 1766 |
+
disabled={permission === 'Read'}
|
| 1767 |
+
value={status}
|
| 1768 |
+
onChange={(e) => handleDropdownStatusChange(node.id, e.target.value)}
|
| 1769 |
+
className={`px-2.5 py-1.5 rounded-xl border-2 border-primary font-bold text-xs uppercase cursor-pointer transition-all shadow-[2px_2px_0px_0px_var(--color-primary)] focus:outline-none focus:translate-y-0.5 focus:shadow-[1px_1px_0px_0px_var(--color-primary)] ${
|
| 1770 |
+
status === 'Done'
|
| 1771 |
+
? 'bg-success/20 text-success'
|
| 1772 |
+
: status === 'Review'
|
| 1773 |
+
? 'bg-blue-500/20 text-blue-600 dark:text-blue-400'
|
| 1774 |
+
: status === 'In Progress'
|
| 1775 |
+
? 'bg-amber-500/20 text-amber-600 dark:text-amber-400'
|
| 1776 |
+
: 'bg-secondary/10 text-primary/70'
|
| 1777 |
+
}`}
|
| 1778 |
+
>
|
| 1779 |
+
<option value="Todo" className="bg-surface-container-lowest text-primary">Todo</option>
|
| 1780 |
+
<option value="In Progress" className="bg-surface-container-lowest text-primary">In Progress</option>
|
| 1781 |
+
<option value="Review" className="bg-surface-container-lowest text-primary">Review</option>
|
| 1782 |
+
<option value="Done" className="bg-surface-container-lowest text-primary">Done</option>
|
| 1783 |
+
</select>
|
| 1784 |
+
)}
|
| 1785 |
+
|
| 1786 |
+
{permission !== 'Read' && (
|
| 1787 |
+
<button
|
| 1788 |
+
onClick={() => {
|
| 1789 |
+
if (node.type === 'habit') {
|
| 1790 |
+
setEditingHabitNodeId(node.id);
|
| 1791 |
+
} else {
|
| 1792 |
+
setEditingSketchNodeId(node.id);
|
| 1793 |
+
}
|
| 1794 |
+
}}
|
| 1795 |
+
className="p-2 border-2 border-primary rounded-xl bg-surface-container-lowest hover:bg-secondary/10 text-primary transition-all cursor-pointer shadow-[2px_2px_0px_0px_var(--color-primary)] active:translate-y-0.5 active:shadow-[1px_1px_0px_0px_var(--color-primary)]"
|
| 1796 |
+
title="Налаштування ноди"
|
| 1797 |
+
>
|
| 1798 |
+
<Settings size={14} />
|
| 1799 |
+
</button>
|
| 1800 |
+
)}
|
| 1801 |
+
</div>
|
| 1802 |
+
</div>
|
| 1803 |
+
|
| 1804 |
+
{hasSubtasks && isExpanded && (
|
| 1805 |
+
<div className="pl-6 border-l-2 border-dashed border-primary/20 ml-4 space-y-4 pt-1 pb-2">
|
| 1806 |
+
{subtasks.map(sub => renderTaskCard(sub, depth + 1))}
|
| 1807 |
+
</div>
|
| 1808 |
+
)}
|
| 1809 |
+
</div>
|
| 1810 |
+
);
|
| 1811 |
+
};
|
| 1812 |
+
|
| 1813 |
+
return (
|
| 1814 |
+
<div className="flex-grow flex flex-col bg-background overflow-y-auto p-8 graph-paper-bg sketch-flow">
|
| 1815 |
+
<div className="bg-surface-container-lowest border-4 border-primary rounded-3xl p-6 shadow-[5px_5px_0px_0px_var(--color-primary)] mb-8 flex flex-col gap-4 max-w-5xl w-full mx-auto">
|
| 1816 |
+
<div className="flex items-center gap-3">
|
| 1817 |
+
<ListTodo size={24} className="text-primary animate-pulse" />
|
| 1818 |
+
<h3 className="text-xl font-display-lg text-primary">Список завдань та прогресу</h3>
|
| 1819 |
+
</div>
|
| 1820 |
+
|
| 1821 |
+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-center">
|
| 1822 |
+
<div className="lg:col-span-6 relative">
|
| 1823 |
+
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 text-primary/40" size={16} />
|
| 1824 |
+
<input
|
| 1825 |
+
type="text"
|
| 1826 |
+
value={searchQuery}
|
| 1827 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 1828 |
+
placeholder="Шукати за назвою, описом або тегами..."
|
| 1829 |
+
className="w-full pl-10 pr-4 py-2.5 bg-secondary/10 border-2 border-primary rounded-xl font-bold text-xs text-primary focus:outline-none focus:bg-surface-container-lowest transition-all shadow-[2px_2px_0px_0px_rgba(0,0,0,0.05)]"
|
| 1830 |
+
/>
|
| 1831 |
+
</div>
|
| 1832 |
+
|
| 1833 |
+
<div className="lg:col-span-2">
|
| 1834 |
+
<button
|
| 1835 |
+
onClick={() => setFilterActive(!filterActive)}
|
| 1836 |
+
className={`w-full py-2.5 px-3 rounded-xl border-2 font-bold text-xs uppercase transition-all cursor-pointer text-center flex items-center justify-center gap-1.5 shadow-[2px_2px_0px_0px_var(--color-primary)] active:translate-y-0.5 active:shadow-[1px_1px_0px_0px_var(--color-primary)] ${
|
| 1837 |
+
filterActive
|
| 1838 |
+
? 'bg-primary text-white border-primary'
|
| 1839 |
+
: 'bg-surface-container-lowest text-primary border-primary hover:bg-secondary/5'
|
| 1840 |
+
}`}
|
| 1841 |
+
>
|
| 1842 |
+
<Target size={14} />
|
| 1843 |
+
<span>Лише активні</span>
|
| 1844 |
+
</button>
|
| 1845 |
+
</div>
|
| 1846 |
+
|
| 1847 |
+
<div className="lg:col-span-2">
|
| 1848 |
+
<select
|
| 1849 |
+
value={filterCollaborator}
|
| 1850 |
+
onChange={(e) => setFilterCollaborator(e.target.value)}
|
| 1851 |
+
className="w-full py-2.5 px-3 rounded-xl border-2 border-primary font-bold text-xs text-primary bg-surface-container-lowest cursor-pointer shadow-[2px_2px_0px_0px_var(--color-primary)] focus:outline-none"
|
| 1852 |
+
>
|
| 1853 |
+
<option value="all">Усі виконавці</option>
|
| 1854 |
+
<option value="owner">Власник</option>
|
| 1855 |
+
{collaborators.map(c => (
|
| 1856 |
+
<option key={c.sharedWithUserId || c.email} value={c.sharedWithUserId || 'owner'}>
|
| 1857 |
+
{c.displayName || c.email}
|
| 1858 |
+
</option>
|
| 1859 |
+
))}
|
| 1860 |
+
</select>
|
| 1861 |
+
</div>
|
| 1862 |
+
|
| 1863 |
+
<div className="lg:col-span-2">
|
| 1864 |
+
<select
|
| 1865 |
+
value={filterTag}
|
| 1866 |
+
onChange={(e) => setFilterTag(e.target.value)}
|
| 1867 |
+
className="w-full py-2.5 px-3 rounded-xl border-2 border-primary font-bold text-xs text-primary bg-surface-container-lowest cursor-pointer shadow-[2px_2px_0px_0px_var(--color-primary)] focus:outline-none"
|
| 1868 |
+
>
|
| 1869 |
+
<option value="all">Усі теги</option>
|
| 1870 |
+
{allUniqueTags.map(tg => (
|
| 1871 |
+
<option key={tg} value={tg}>
|
| 1872 |
+
{tg}
|
| 1873 |
+
</option>
|
| 1874 |
+
))}
|
| 1875 |
+
</select>
|
| 1876 |
+
</div>
|
| 1877 |
+
</div>
|
| 1878 |
+
</div>
|
| 1879 |
+
|
| 1880 |
+
<div className="flex-1 space-y-4 max-w-5xl w-full mx-auto pb-12">
|
| 1881 |
+
{orderedRootTasks.length === 0 ? (
|
| 1882 |
+
<div className="bg-surface-container-lowest border-4 border-primary rounded-3xl p-12 text-center shadow-[6px_6px_0px_0px_var(--color-primary)]">
|
| 1883 |
+
<div className="flex justify-center mb-4">
|
| 1884 |
+
<StickyNote size={48} className="text-primary/30 animate-bounce" />
|
| 1885 |
+
</div>
|
| 1886 |
+
<h4 className="text-lg font-display-md text-primary mb-2">Немає завдань</h4>
|
| 1887 |
+
<p className="text-xs font-bold uppercase tracking-widest text-primary/50 max-w-md mx-auto leading-relaxed">
|
| 1888 |
+
Створіть цілі або звички на холсті або змініть налаштування фільтрації у верхній панелі!
|
| 1889 |
+
</p>
|
| 1890 |
+
</div>
|
| 1891 |
+
) : (
|
| 1892 |
+
orderedRootTasks.map(node => renderTaskCard(node, 0))
|
| 1893 |
+
)}
|
| 1894 |
+
</div>
|
| 1895 |
+
</div>
|
| 1896 |
+
);
|
| 1897 |
+
};
|
| 1898 |
+
|
| 1899 |
const CanvasInner = () => {
|
| 1900 |
const { t } = useTranslation();
|
| 1901 |
const { id } = useParams();
|
|
|
|
| 1953 |
updateProjectMetadata
|
| 1954 |
} = useCanvasLogic(id, user);
|
| 1955 |
|
| 1956 |
+
const [activeTab, setActiveTab] = useState('canvas'); // 'canvas' | 'list'
|
| 1957 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 1958 |
+
const [filterActive, setFilterActive] = useState(false);
|
| 1959 |
+
const [filterCollaborator, setFilterCollaborator] = useState('all');
|
| 1960 |
+
const [filterTag, setFilterTag] = useState('all');
|
| 1961 |
+
const [expandedNodes, setExpandedNodes] = useState(new Set());
|
| 1962 |
+
|
| 1963 |
const lastCursorSendRef = useRef(0);
|
| 1964 |
const broadcastRef = useRef(broadcastMessage);
|
| 1965 |
|
|
|
|
| 2512 |
<div className="flex h-screen bg-background overflow-hidden" onMouseUp={handleMouseUp}>
|
| 2513 |
<Sidebar />
|
| 2514 |
|
| 2515 |
+
<main className="flex-grow flex flex-col relative text-primary">
|
| 2516 |
<div className="relative h-24 bg-surface-container-lowest border-b-4 border-primary px-8 flex items-center justify-between z-30 shadow-sm">
|
| 2517 |
<div className="flex items-center gap-6">
|
| 2518 |
<button
|
| 2519 |
onClick={() => navigate('/dashboard')}
|
| 2520 |
+
className="p-2 hover:bg-surface-variant rounded-xl transition-colors text-primary"
|
| 2521 |
>
|
| 2522 |
<ChevronLeft size={28} />
|
| 2523 |
</button>
|
|
|
|
| 2548 |
{projectData?.description || t('canvas.strategic_goal_map')}
|
| 2549 |
</p>
|
| 2550 |
</div>
|
| 2551 |
+
|
| 2552 |
+
{/* Styled Notebook Tabs Segmented Control */}
|
| 2553 |
+
<div className="flex items-center bg-secondary/5 border-2 border-primary rounded-xl p-1 shadow-[2px_2px_0px_0px_var(--color-primary)] ml-4">
|
| 2554 |
+
<button
|
| 2555 |
+
type="button"
|
| 2556 |
+
onClick={() => setActiveTab('canvas')}
|
| 2557 |
+
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-bold text-xs uppercase tracking-wider transition-all cursor-pointer select-none ${
|
| 2558 |
+
activeTab === 'canvas'
|
| 2559 |
+
? 'bg-primary text-white shadow-[1px_1px_0px_0px_var(--color-primary)]'
|
| 2560 |
+
: 'text-primary hover:bg-secondary/10'
|
| 2561 |
+
}`}
|
| 2562 |
+
>
|
| 2563 |
+
<Map size={16} />
|
| 2564 |
+
<span>{t('canvas.tabs.canvas', 'Карта')}</span>
|
| 2565 |
+
</button>
|
| 2566 |
+
<button
|
| 2567 |
+
type="button"
|
| 2568 |
+
onClick={() => setActiveTab('list')}
|
| 2569 |
+
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-bold text-xs uppercase tracking-wider transition-all cursor-pointer select-none ${
|
| 2570 |
+
activeTab === 'list'
|
| 2571 |
+
? 'bg-primary text-white shadow-[1px_1px_0px_0px_var(--color-primary)]'
|
| 2572 |
+
: 'text-primary hover:bg-secondary/10'
|
| 2573 |
+
}`}
|
| 2574 |
+
>
|
| 2575 |
+
<ListTodo size={16} />
|
| 2576 |
+
<span>{t('canvas.tabs.list', 'Завдання')}</span>
|
| 2577 |
+
</button>
|
| 2578 |
+
</div>
|
| 2579 |
</div>
|
| 2580 |
|
| 2581 |
<div className="flex items-center gap-4">
|
|
|
|
| 2634 |
</div>
|
| 2635 |
</div>
|
| 2636 |
|
| 2637 |
+
{/* React Flow Canvas Wrapper with visibility toggled via display style */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2638 |
<div
|
| 2639 |
+
className="flex-grow flex flex-col relative"
|
| 2640 |
+
style={{ display: activeTab === 'canvas' ? 'flex' : 'none' }}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2641 |
>
|
| 2642 |
+
{permission !== 'Read' && (
|
| 2643 |
+
<CanvasToolbar
|
| 2644 |
+
handleAIRequest={handleAIRequest}
|
| 2645 |
+
isAIActive={isAIActive}
|
| 2646 |
+
user={user}
|
| 2647 |
+
addNewNodeAt={addNewNodeAt}
|
| 2648 |
+
handleCopy={handleCopy}
|
| 2649 |
+
handleCut={handleCut}
|
| 2650 |
+
handlePaste={() => handlePaste(mousePosRef.current)}
|
| 2651 |
+
exportToPng={exportToPng}
|
| 2652 |
+
onDeleteSelected={() => {
|
| 2653 |
+
const selectedNodeIds = nodes.filter(n => n.selected).map(n => n.id);
|
| 2654 |
+
const selectedEdgeIds = edges.filter(e => e.selected).map(e => e.id);
|
| 2655 |
+
requestDeletion(selectedNodeIds, selectedEdgeIds);
|
| 2656 |
+
}}
|
| 2657 |
+
hasSelectedNodes={nodes.some(n => n.selected)}
|
| 2658 |
+
hasClipboardContent={hasClipboardContent}
|
| 2659 |
+
undo={undo}
|
| 2660 |
+
redo={redo}
|
| 2661 |
+
canUndo={canUndo}
|
| 2662 |
+
canRedo={canRedo}
|
| 2663 |
+
drawingMode={drawingMode}
|
| 2664 |
+
setDrawingMode={setDrawingMode}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2665 |
/>
|
| 2666 |
)}
|
| 2667 |
|
| 2668 |
+
<div
|
| 2669 |
+
ref={reactFlowWrapper}
|
| 2670 |
+
className="flex-grow graph-paper-bg relative"
|
| 2671 |
+
onMouseDown={drawingMode ? undefined : handleMouseDown}
|
| 2672 |
+
onPointerDown={onPointerDown}
|
| 2673 |
+
onPointerMove={onPointerMove}
|
| 2674 |
+
onPointerUp={onPointerUp}
|
| 2675 |
+
>
|
| 2676 |
+
<ReactFlow
|
| 2677 |
+
nodes={nodes}
|
| 2678 |
+
edges={edges}
|
| 2679 |
+
onNodesChange={onNodesChange}
|
| 2680 |
+
onEdgesChange={onEdgesChange}
|
| 2681 |
+
onConnect={permission === 'Read' ? undefined : onConnect}
|
| 2682 |
+
onEdgesDelete={permission === 'Read' ? undefined : (eds) => requestDeletion([], eds.map(e => e.id))}
|
| 2683 |
+
onEdgeClick={permission === 'Read' ? undefined : (_, edge) => requestDeletion([], [edge.id])}
|
| 2684 |
+
onNodesDelete={permission === 'Read' ? undefined : (nds) => requestDeletion(nds.map(n => n.id), [])}
|
| 2685 |
+
onNodeDragStart={permission === 'Read' ? undefined : () => takeHistorySnapshot()}
|
| 2686 |
+
onNodeContextMenu={handleNodeContextMenu}
|
| 2687 |
+
onPaneClick={() => setContextMenu(null)}
|
| 2688 |
+
onPaneContextMenu={handlePaneContextMenu}
|
| 2689 |
+
onDrop={onDrop}
|
| 2690 |
+
onDragOver={onDragOver}
|
| 2691 |
+
onConnectStart={(event, params) => {
|
| 2692 |
+
connectionStartRef.current = params;
|
| 2693 |
+
}}
|
| 2694 |
+
onConnectEnd={onConnectEnd}
|
| 2695 |
+
nodesDraggable={!drawingMode && permission !== 'Read'}
|
| 2696 |
+
nodesConnectable={!drawingMode && permission !== 'Read'}
|
| 2697 |
+
panOnDrag={!drawingMode}
|
| 2698 |
+
selectionOnDrag={!drawingMode}
|
| 2699 |
+
nodeTypes={nodeTypes}
|
| 2700 |
+
edgeTypes={edgeTypes}
|
| 2701 |
+
connectionLineType={ConnectionLineType.SmoothStep}
|
| 2702 |
+
fitView
|
| 2703 |
+
className="sketch-flow"
|
| 2704 |
+
>
|
| 2705 |
+
<Background variant="dots" gap={20} size={1} color={isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.06)'} />
|
| 2706 |
+
<Controls className="!bottom-8 !left-28 !overflow-hidden" />
|
| 2707 |
+
<MiniMap
|
| 2708 |
+
className="!bottom-8 !right-8 !overflow-hidden"
|
| 2709 |
+
nodeStrokeColor={isDarkMode ? '#e2e3e4' : '#31363F'}
|
| 2710 |
+
nodeColor={isDarkMode ? '#222326' : '#EEEEEE'}
|
| 2711 |
+
maskColor={isDarkMode ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.6)'}
|
| 2712 |
+
/>
|
| 2713 |
+
<CollaborativeCursors cursors={cursors} />
|
| 2714 |
+
</ReactFlow>
|
| 2715 |
|
| 2716 |
+
{activeStroke && (
|
| 2717 |
+
<svg
|
| 2718 |
+
className="absolute inset-0 pointer-events-none z-[1000]"
|
| 2719 |
+
style={{ width: '100%', height: '100%' }}
|
| 2720 |
+
>
|
| 2721 |
+
<path
|
| 2722 |
+
d={activeStroke.path}
|
| 2723 |
+
fill="none"
|
| 2724 |
+
stroke={isDarkMode ? '#ffffff' : '#31363F'}
|
| 2725 |
+
strokeWidth={3}
|
| 2726 |
+
strokeLinecap="round"
|
| 2727 |
+
strokeLinejoin="round"
|
| 2728 |
+
/>
|
| 2729 |
+
</svg>
|
| 2730 |
+
)}
|
| 2731 |
|
| 2732 |
+
<AIHintOverlay
|
| 2733 |
+
isAIActive={isAIActive}
|
| 2734 |
+
setIsAIActive={setIsAIActive}
|
| 2735 |
+
aiLoading={aiLoading}
|
| 2736 |
+
submitAICanvasPrompt={submitAICanvasPrompt}
|
|
|
|
|
|
|
| 2737 |
/>
|
| 2738 |
+
<PaywallModal showPaywall={showPaywall} setShowPaywall={setShowPaywall} />
|
| 2739 |
|
| 2740 |
+
{contextMenu && contextMenu.type === 'node' && (
|
| 2741 |
+
<div
|
| 2742 |
+
style={{ top: contextMenu.y, left: contextMenu.x }}
|
| 2743 |
+
className="absolute bg-surface-container-lowest border-2 border-primary shadow-[2px_2px_0px_0px_var(--color-primary)] py-1.5 px-1 z-50 min-w-[135px] rounded-lg transition-all flex flex-col gap-0.5"
|
| 2744 |
+
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2745 |
>
|
| 2746 |
+
<button
|
| 2747 |
+
onClick={(e) => {
|
| 2748 |
+
e.stopPropagation();
|
| 2749 |
+
const targetNode = nodes.find(n => n.id === contextMenu.nodeId);
|
| 2750 |
+
if (targetNode) {
|
| 2751 |
+
const isSelected = targetNode.selected;
|
| 2752 |
+
if (!isSelected) {
|
| 2753 |
+
setNodes(nds => nds.map(n => n.id === contextMenu.nodeId ? { ...n, selected: true } : n));
|
| 2754 |
+
}
|
| 2755 |
+
const selectedNodes = nodes.filter(n => n.selected || n.id === contextMenu.nodeId);
|
| 2756 |
+
const selectedEdges = edges.filter(edge =>
|
| 2757 |
+
selectedNodes.some(n => n.id === edge.source) &&
|
| 2758 |
+
selectedNodes.some(n => n.id === edge.target)
|
| 2759 |
+
);
|
| 2760 |
+
const clipboardPayload = {
|
| 2761 |
+
nodes: selectedNodes.map(n => ({ ...n, originalPosition: { ...n.position } })),
|
| 2762 |
+
edges: selectedEdges
|
| 2763 |
+
};
|
| 2764 |
+
window.__flowClipboard = clipboardPayload;
|
| 2765 |
+
triggerClipboardUpdate();
|
| 2766 |
+
try {
|
| 2767 |
+
navigator.clipboard.writeText('FLOWWEB_NODES_DATA:' + JSON.stringify(clipboardPayload)).catch(() => {});
|
| 2768 |
+
} catch (err) {}
|
| 2769 |
+
}
|
| 2770 |
+
setContextMenu(null);
|
| 2771 |
+
}}
|
| 2772 |
+
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2773 |
+
>
|
| 2774 |
+
<Copy size={14} className="text-primary" />
|
| 2775 |
+
<span>{t('canvas.context_menu.copy', 'Копіювати')}</span>
|
| 2776 |
+
</button>
|
| 2777 |
+
|
| 2778 |
+
<button
|
| 2779 |
+
onClick={(e) => {
|
| 2780 |
+
e.stopPropagation();
|
| 2781 |
+
const targetNode = nodes.find(n => n.id === contextMenu.nodeId);
|
| 2782 |
+
if (targetNode) {
|
| 2783 |
+
const selectedNodes = nodes.filter(n => n.selected || n.id === contextMenu.nodeId);
|
| 2784 |
+
const selectedEdges = edges.filter(edge =>
|
| 2785 |
+
selectedNodes.some(n => n.id === edge.source) &&
|
| 2786 |
+
selectedNodes.some(n => n.id === edge.target)
|
| 2787 |
+
);
|
| 2788 |
+
const clipboardPayload = {
|
| 2789 |
+
nodes: selectedNodes.map(n => ({ ...n, originalPosition: { ...n.position } })),
|
| 2790 |
+
edges: selectedEdges
|
| 2791 |
+
};
|
| 2792 |
+
window.__flowClipboard = clipboardPayload;
|
| 2793 |
+
triggerClipboardUpdate();
|
| 2794 |
+
try {
|
| 2795 |
+
navigator.clipboard.writeText('FLOWWEB_NODES_DATA:' + JSON.stringify(clipboardPayload)).catch(() => {});
|
| 2796 |
+
} catch (err) {}
|
| 2797 |
+
selectedNodes.forEach(n => deleteNode(n.id));
|
| 2798 |
+
}
|
| 2799 |
+
setContextMenu(null);
|
| 2800 |
+
}}
|
| 2801 |
+
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2802 |
+
>
|
| 2803 |
+
<Scissors size={14} className="text-primary" />
|
| 2804 |
+
<span>{t('canvas.context_menu.cut', 'Вирізати')}</span>
|
| 2805 |
+
</button>
|
| 2806 |
+
|
| 2807 |
+
<button
|
| 2808 |
+
onClick={(e) => {
|
| 2809 |
+
e.stopPropagation();
|
| 2810 |
+
const targetNode = nodes.find(n => n.id === contextMenu.nodeId);
|
| 2811 |
+
if (targetNode) {
|
| 2812 |
+
const newPinnedState = !targetNode.data?.isPinned;
|
| 2813 |
+
updateNodeData(contextMenu.nodeId, { isPinned: newPinnedState });
|
| 2814 |
+
}
|
| 2815 |
+
setContextMenu(null);
|
| 2816 |
+
}}
|
| 2817 |
+
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2818 |
+
>
|
| 2819 |
+
<Pin
|
| 2820 |
+
size={14}
|
| 2821 |
+
className={`text-primary transition-transform ${
|
| 2822 |
+
nodes.find(n => n.id === contextMenu.nodeId)?.data?.isPinned
|
| 2823 |
+
? 'rotate-45 text-amber-500 fill-amber-500'
|
| 2824 |
+
: ''
|
| 2825 |
+
}`}
|
| 2826 |
+
/>
|
| 2827 |
+
<span>
|
| 2828 |
+
{nodes.find(n => n.id === contextMenu.nodeId)?.data?.isPinned
|
| 2829 |
+
? t('canvas.context_menu.unpin', 'Відкріпити')
|
| 2830 |
+
: t('canvas.context_menu.pin', 'Закріпити')}
|
| 2831 |
+
</span>
|
| 2832 |
+
</button>
|
| 2833 |
+
|
| 2834 |
+
<div className="border-t border-primary/10 my-0.5"></div>
|
| 2835 |
+
|
| 2836 |
+
<button
|
| 2837 |
+
onClick={(e) => {
|
| 2838 |
+
e.stopPropagation();
|
| 2839 |
+
requestDeletion([contextMenu.nodeId], []);
|
| 2840 |
+
setContextMenu(null);
|
| 2841 |
+
}}
|
| 2842 |
+
className="w-full text-left px-2.5 py-1.5 hover:bg-red-500/20 text-red-600 hover:text-red-700 font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2843 |
+
>
|
| 2844 |
+
<Trash2 size={14} />
|
| 2845 |
+
<span>{t('canvas.context_menu.delete')}</span>
|
| 2846 |
+
</button>
|
| 2847 |
+
</div>
|
| 2848 |
+
)}
|
| 2849 |
|
| 2850 |
+
{contextMenu && contextMenu.type === 'pane' && (
|
| 2851 |
+
<div
|
| 2852 |
+
style={{ top: contextMenu.y, left: contextMenu.x }}
|
| 2853 |
+
className="absolute bg-surface-container-lowest border-2 border-primary shadow-[2px_2px_0px_0px_var(--color-primary)] py-1.5 px-1 z-50 min-w-[170px] rounded-lg transition-all flex flex-col gap-0.5"
|
| 2854 |
+
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2855 |
>
|
| 2856 |
+
{permission !== 'Read' && (
|
| 2857 |
+
<>
|
| 2858 |
+
<div className="px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-on-surface-variant/40 border-b border-primary/10">
|
| 2859 |
+
{t('canvas.context_menu.create_node')}
|
| 2860 |
+
</div>
|
| 2861 |
+
<button
|
| 2862 |
+
onClick={(e) => {
|
| 2863 |
+
e.stopPropagation();
|
| 2864 |
+
addNewNodeAt(contextMenu.clientX, contextMenu.clientY, 'sketch');
|
| 2865 |
+
setContextMenu(null);
|
| 2866 |
+
}}
|
| 2867 |
+
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2868 |
+
>
|
| 2869 |
+
<Target size={15} className="text-primary" />
|
| 2870 |
+
<span>{t('canvas.context_menu.goal_node')}</span>
|
| 2871 |
+
</button>
|
| 2872 |
+
<button
|
| 2873 |
+
onClick={(e) => {
|
| 2874 |
+
e.stopPropagation();
|
| 2875 |
+
addNewNodeAt(contextMenu.clientX, contextMenu.clientY, 'comment');
|
| 2876 |
+
setContextMenu(null);
|
| 2877 |
+
}}
|
| 2878 |
+
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2879 |
+
>
|
| 2880 |
+
<StickyNote size={15} className="text-secondary" />
|
| 2881 |
+
<span>Стікер</span>
|
| 2882 |
+
</button>
|
| 2883 |
+
<button
|
| 2884 |
+
onClick={(e) => {
|
| 2885 |
+
e.stopPropagation();
|
| 2886 |
+
addNewNodeAt(contextMenu.clientX, contextMenu.clientY, 'condition');
|
| 2887 |
+
setContextMenu(null);
|
| 2888 |
+
}}
|
| 2889 |
+
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2890 |
+
>
|
| 2891 |
+
<GitFork size={15} className="text-primary" />
|
| 2892 |
+
<span>{t('canvas.context_menu.condition_node')}</span>
|
| 2893 |
+
</button>
|
| 2894 |
+
<button
|
| 2895 |
+
onClick={(e) => {
|
| 2896 |
+
e.stopPropagation();
|
| 2897 |
+
addNewNodeAt(contextMenu.clientX, contextMenu.clientY, 'habit');
|
| 2898 |
+
setContextMenu(null);
|
| 2899 |
+
}}
|
| 2900 |
+
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2901 |
+
>
|
| 2902 |
+
<Timer size={15} className="text-secondary" />
|
| 2903 |
+
<span>{t('canvas.context_menu.habit_node')}</span>
|
| 2904 |
+
</button>
|
| 2905 |
+
|
| 2906 |
+
{window.__flowClipboard && window.__flowClipboard.nodes && window.__flowClipboard.nodes.length > 0 && (
|
| 2907 |
+
<>
|
| 2908 |
+
<div className="border-t border-primary/10 my-0.5"></div>
|
| 2909 |
+
<button
|
| 2910 |
+
onClick={(e) => {
|
| 2911 |
+
e.stopPropagation();
|
| 2912 |
+
handlePaste({ x: contextMenu.clientX, y: contextMenu.clientY });
|
| 2913 |
+
setContextMenu(null);
|
| 2914 |
+
}}
|
| 2915 |
+
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2916 |
+
>
|
| 2917 |
+
<Clipboard size={15} className="text-primary" />
|
| 2918 |
+
<span>{t('canvas.context_menu.paste', 'Вставити')}</span>
|
| 2919 |
+
</button>
|
| 2920 |
+
</>
|
| 2921 |
+
)}
|
| 2922 |
|
| 2923 |
+
<div className="border-t border-primary/10 my-0.5"></div>
|
| 2924 |
+
</>
|
| 2925 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2926 |
|
| 2927 |
+
<button
|
| 2928 |
+
onClick={(e) => {
|
| 2929 |
+
e.stopPropagation();
|
| 2930 |
+
exportToPng();
|
| 2931 |
+
setContextMenu(null);
|
| 2932 |
+
}}
|
| 2933 |
+
className="w-full text-left px-2.5 py-1.5 hover:bg-green-50 text-green-600 hover:text-green-700 font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2934 |
+
>
|
| 2935 |
+
<Image size={15} className="text-green-600" />
|
| 2936 |
+
<span>{t('canvas.context_menu.export_png')}</span>
|
| 2937 |
+
</button>
|
| 2938 |
+
</div>
|
| 2939 |
+
)}
|
| 2940 |
|
| 2941 |
+
{contextMenu && contextMenu.type === 'connect-pane' && (
|
| 2942 |
+
<div
|
| 2943 |
+
style={{ top: contextMenu.y, left: contextMenu.x }}
|
| 2944 |
+
className="absolute bg-surface-container-lowest border-2 border-primary shadow-[2px_2px_0px_0px_var(--color-primary)] py-1.5 px-1 z-50 min-w-[170px] rounded-lg transition-all flex flex-col gap-0.5"
|
| 2945 |
+
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
|
| 2946 |
>
|
| 2947 |
+
<div className="px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-secondary border-b border-primary/10">
|
| 2948 |
+
Зв'язати з новою:
|
| 2949 |
+
</div>
|
| 2950 |
+
<button
|
| 2951 |
+
onClick={(e) => {
|
| 2952 |
+
e.stopPropagation();
|
| 2953 |
+
const newNodeId = addNewNodeAt(contextMenu.clientX, contextMenu.clientY, 'sketch');
|
| 2954 |
+
if (contextMenu.connectionSource && newNodeId) {
|
| 2955 |
+
const { nodeId, handleId, handleType } = contextMenu.connectionSource;
|
| 2956 |
+
const isSource = handleType === 'source';
|
| 2957 |
+
onConnect({
|
| 2958 |
+
source: isSource ? nodeId : newNodeId,
|
| 2959 |
+
target: isSource ? newNodeId : nodeId,
|
| 2960 |
+
sourceHandle: isSource ? handleId : 'main-source',
|
| 2961 |
+
targetHandle: isSource ? 'main-target' : handleId
|
| 2962 |
+
});
|
| 2963 |
+
}
|
| 2964 |
+
setContextMenu(null);
|
| 2965 |
+
}}
|
| 2966 |
+
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2967 |
+
>
|
| 2968 |
+
<Target size={15} className="text-primary" />
|
| 2969 |
+
<span>{t('canvas.context_menu.goal_node')}</span>
|
| 2970 |
+
</button>
|
| 2971 |
+
<button
|
| 2972 |
+
onClick={(e) => {
|
| 2973 |
+
e.stopPropagation();
|
| 2974 |
+
const newNodeId = addNewNodeAt(contextMenu.clientX, contextMenu.clientY, 'condition');
|
| 2975 |
+
if (contextMenu.connectionSource && newNodeId) {
|
| 2976 |
+
const { nodeId, handleId, handleType } = contextMenu.connectionSource;
|
| 2977 |
+
const isSource = handleType === 'source';
|
| 2978 |
+
onConnect({
|
| 2979 |
+
source: isSource ? nodeId : newNodeId,
|
| 2980 |
+
target: isSource ? newNodeId : nodeId,
|
| 2981 |
+
sourceHandle: isSource ? handleId : null,
|
| 2982 |
+
targetHandle: isSource ? null : handleId
|
| 2983 |
+
});
|
| 2984 |
+
}
|
| 2985 |
+
setContextMenu(null);
|
| 2986 |
+
}}
|
| 2987 |
+
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 2988 |
+
>
|
| 2989 |
+
<GitFork size={15} className="text-primary" />
|
| 2990 |
+
<span>{t('canvas.context_menu.condition_node')}</span>
|
| 2991 |
+
</button>
|
| 2992 |
+
<button
|
| 2993 |
+
onClick={(e) => {
|
| 2994 |
+
e.stopPropagation();
|
| 2995 |
+
const newNodeId = addNewNodeAt(contextMenu.clientX, contextMenu.clientY, 'habit');
|
| 2996 |
+
if (contextMenu.connectionSource && newNodeId) {
|
| 2997 |
+
const { nodeId, handleId, handleType } = contextMenu.connectionSource;
|
| 2998 |
+
const isSource = handleType === 'source';
|
| 2999 |
+
onConnect({
|
| 3000 |
+
source: isSource ? nodeId : newNodeId,
|
| 3001 |
+
target: isSource ? newNodeId : nodeId,
|
| 3002 |
+
sourceHandle: isSource ? handleId : null,
|
| 3003 |
+
targetHandle: isSource ? null : handleId
|
| 3004 |
+
});
|
| 3005 |
+
}
|
| 3006 |
+
setContextMenu(null);
|
| 3007 |
+
}}
|
| 3008 |
+
className="w-full text-left px-2.5 py-1.5 hover:bg-surface-variant text-primary font-bold rounded-md transition-colors flex items-center gap-2 text-xs"
|
| 3009 |
+
>
|
| 3010 |
+
<Timer size={15} className="text-secondary" />
|
| 3011 |
+
<span>{t('canvas.context_menu.habit_node')}</span>
|
| 3012 |
+
</button>
|
| 3013 |
+
</div>
|
| 3014 |
+
)}
|
| 3015 |
+
</div>
|
| 3016 |
+
</div>
|
| 3017 |
|
| 3018 |
+
{/* Task List Tab content */}
|
| 3019 |
+
{activeTab === 'list' && (
|
| 3020 |
+
<TaskListView
|
| 3021 |
+
nodes={nodes}
|
| 3022 |
+
edges={edges}
|
| 3023 |
+
updateNodeData={updateNodeData}
|
| 3024 |
+
collaborators={collaborators}
|
| 3025 |
+
permission={permission}
|
| 3026 |
+
t={t}
|
| 3027 |
+
isDarkMode={isDarkMode}
|
| 3028 |
+
searchQuery={searchQuery}
|
| 3029 |
+
setSearchQuery={setSearchQuery}
|
| 3030 |
+
filterActive={filterActive}
|
| 3031 |
+
setFilterActive={setFilterActive}
|
| 3032 |
+
filterCollaborator={filterCollaborator}
|
| 3033 |
+
setFilterCollaborator={setFilterCollaborator}
|
| 3034 |
+
filterTag={filterTag}
|
| 3035 |
+
setFilterTag={setFilterTag}
|
| 3036 |
+
expandedNodes={expandedNodes}
|
| 3037 |
+
setExpandedNodes={setExpandedNodes}
|
| 3038 |
+
setEditingSketchNodeId={setEditingSketchNodeId}
|
| 3039 |
+
setEditingHabitNodeId={setEditingHabitNodeId}
|
| 3040 |
+
/>
|
| 3041 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3042 |
|
| 3043 |
+
{/* Globally rendered settings modals accessible from both views */}
|
| 3044 |
+
{editingNode && (
|
| 3045 |
+
<ConditionSettingsModal
|
| 3046 |
+
node={editingNode}
|
| 3047 |
+
onClose={() => setEditingConditionNodeId(null)}
|
| 3048 |
+
onSave={handleSaveConditionConfig}
|
| 3049 |
+
/>
|
| 3050 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3051 |
|
| 3052 |
+
{editingHabitNode && (
|
| 3053 |
+
<HabitSettingsModal
|
| 3054 |
+
node={editingHabitNode}
|
| 3055 |
+
onClose={() => setEditingHabitNodeId(null)}
|
| 3056 |
+
onSave={handleSaveHabitConfig}
|
| 3057 |
+
/>
|
| 3058 |
+
)}
|
| 3059 |
|
| 3060 |
+
{editingSketchNode && (
|
| 3061 |
+
<SketchSettingsModal
|
| 3062 |
+
node={editingSketchNode}
|
| 3063 |
+
onClose={() => setEditingSketchNodeId(null)}
|
| 3064 |
+
onSave={handleSaveSketchConfig}
|
| 3065 |
+
collaborators={collaborators}
|
| 3066 |
+
/>
|
| 3067 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3068 |
|
| 3069 |
+
{isEditingMetadata && (
|
| 3070 |
+
<ProjectMetadataModal
|
| 3071 |
+
title={projectTitle}
|
| 3072 |
+
description={projectData?.description || ''}
|
| 3073 |
+
onClose={() => setIsEditingMetadata(false)}
|
| 3074 |
+
onSave={(newTitle, newDesc) => {
|
| 3075 |
+
updateProjectMetadata(newTitle, newDesc);
|
| 3076 |
+
setIsEditingMetadata(false);
|
| 3077 |
+
}}
|
| 3078 |
+
/>
|
| 3079 |
+
)}
|
| 3080 |
+
|
| 3081 |
+
{isShareModalOpen && (
|
| 3082 |
+
<ShareModal
|
| 3083 |
+
projectId={id}
|
| 3084 |
+
onClose={() => {
|
| 3085 |
+
setIsShareModalOpen(false);
|
| 3086 |
+
fetchCollaborators();
|
| 3087 |
+
}}
|
| 3088 |
+
/>
|
| 3089 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3090 |
</main>
|
| 3091 |
</div>
|
| 3092 |
);
|