feat: prisma schema, groups API, deploy workflow

This commit is contained in:
2026-04-23 21:41:11 +05:00
parent 4c50036172
commit 65cf8ca01f
285 changed files with 24602 additions and 25890 deletions
+19
View File
@@ -0,0 +1,19 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<script setup>
useHead({
title: 'XFlow',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no' },
{ name: 'theme-color', content: '#0f172a' }
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
]
})
</script>
+37
View File
@@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-slate-950 text-slate-100;
font-family: 'Inter', system-ui, sans-serif;
}
}
@layer components {
.vue-flow {
@apply bg-slate-900;
}
.vue-flow__node {
@apply bg-slate-800 border border-slate-700 rounded-lg shadow-lg;
}
.vue-flow__node.selected {
@apply border-blue-500 ring-2 ring-blue-500/50;
}
.vue-flow__edge-path {
stroke: #475569;
stroke-width: 2;
}
.vue-flow__controls {
@apply bg-slate-800 border-slate-700;
}
.vue-flow__controls-button {
@apply bg-slate-700 text-slate-200 hover:bg-slate-600;
}
}
+64
View File
@@ -0,0 +1,64 @@
<template>
<Teleport to="body">
<TransitionGroup
name="achievement"
tag="div"
class="fixed bottom-4 right-4 z-50 flex flex-col gap-2"
>
<div
v-for="ach in visibleAchievements"
:key="ach.id"
class="bg-slate-800 border border-yellow-500/50 rounded-xl p-4 shadow-2xl max-w-sm flex items-start gap-3"
>
<div class="text-3xl">{{ ach.icon || '🏆' }}</div>
<div>
<h4 class="font-bold text-yellow-400 text-sm">{{ ach.title }}</h4>
<p class="text-xs text-slate-400 mt-0.5">{{ ach.description }}</p>
</div>
<button
@click="dismiss(ach.id)"
class="text-slate-500 hover:text-slate-300 ml-2"
>
✕
</button>
</div>
</TransitionGroup>
</Teleport>
</template>
<script setup>
const props = defineProps({
achievements: { type: Array, default: () => [] }
})
const visibleAchievements = ref([])
watch(() => props.achievements, (newAchievements) => {
const incoming = newAchievements.filter(a => !visibleAchievements.value.find(v => v.id === a.id))
incoming.forEach(ach => {
visibleAchievements.value.push(ach)
setTimeout(() => dismiss(ach.id), 5000)
})
}, { deep: true, immediate: true })
const dismiss = (id) => {
visibleAchievements.value = visibleAchievements.value.filter(a => a.id !== id)
}
</script>
<style scoped>
.achievement-enter-active,
.achievement-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.achievement-enter-from {
opacity: 0;
transform: translateX(100%) scale(0.9);
}
.achievement-leave-to {
opacity: 0;
transform: translateX(100%) scale(0.9);
}
</style>
+108
View File
@@ -0,0 +1,108 @@
<template>
<div class="h-screen w-full relative">
<VueFlow
v-model="elements"
:default-zoom="0.8"
:min-zoom="0.2"
:max-zoom="2"
fit-view-on-init
@node-double-click="onNodeDoubleClick"
>
<Background pattern-color="#334155" :gap="20" />
<Controls />
<MiniMap pannable zoomable class="bg-slate-800 border-slate-700" />
<template #node-text="{ data, id }">
<MarkdownNode
:id="id"
:data="data"
@update-state="updateNodeState"
/>
</template>
<template #node-file="{ data, id }">
<MarkdownNode
:id="id"
:data="data"
:is-file="true"
@update-state="updateNodeState"
/>
</template>
</VueFlow>
<div class="absolute top-4 left-4 z-10 bg-slate-800/90 backdrop-blur border border-slate-700 rounded-lg p-3">
<h2 class="text-sm font-semibold text-slate-200">{{ canvasName }}</h2>
<p class="text-xs text-slate-400 mt-1">Double click to edit state</p>
</div>
</div>
</template>
<script setup>
import { VueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/minimap'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
const props = defineProps({
canvasId: { type: String, default: 'demo' }
})
const { loadCanvas, updateNodeState: apiUpdateNodeState } = useCanvas()
const canvasName = computed(() => props.canvasId)
const elements = ref([])
const transformToFlowElements = (canvasData) => {
const nodes = (canvasData.nodes || []).map(node => ({
id: node.id,
type: node.type === 'file' ? 'file' : 'text',
position: { x: node.x, y: node.y },
data: {
...node,
width: node.width,
height: node.height
},
style: {
width: `${node.width}px`,
height: `${node.height}px`,
background: node.style?.background || '#1e293b',
border: `1px solid ${node.style?.border || '#334155'}`,
borderRadius: '8px'
}
}))
const edges = (canvasData.edges || []).map(edge => ({
id: edge.id,
source: edge.fromNode,
target: edge.toNode,
sourceHandle: edge.fromSide,
targetHandle: edge.toSide,
animated: true,
style: { stroke: '#475569', strokeWidth: 2 }
}))
return [...nodes, ...edges]
}
const loadData = async () => {
const data = await loadCanvas(props.canvasId)
elements.value = transformToFlowElements(data)
}
const updateNodeState = async ({ nodeId, state }) => {
await apiUpdateNodeState(nodeId, state)
await loadData()
}
const onNodeDoubleClick = (event) => {
const node = event.node
const currentState = node.data?.state || 'pending'
const nextState = currentState === 'pending' ? 'in_progress' : currentState === 'in_progress' ? 'completed' : 'pending'
updateNodeState({ nodeId: node.id, state: nextState })
}
onMounted(() => {
loadData()
})
</script>
+525
View File
@@ -0,0 +1,525 @@
<template>
<div class="h-screen w-full flex bg-slate-950">
<!-- Node Palette -->
<div class="w-56 bg-slate-900 border-r border-slate-700 flex flex-col shrink-0 z-20">
<div class="p-3 border-b border-slate-700">
<h3 class="text-sm font-bold text-slate-200">Узлы</h3>
</div>
<div class="flex-1 overflow-y-auto p-2 space-y-1">
<div v-for="nt in nodeTypes" :key="nt.type" draggable="true" @dragstart="onDragStart($event, nt)"
class="p-2 bg-slate-800 rounded cursor-move hover:bg-slate-700 border border-slate-600 text-xs select-none">
<div class="flex items-center gap-2">
<span>{{ nt.icon }}</span>
<div>
<div class="font-medium text-slate-200">{{ nt.name }}</div>
<div class="text-[10px] text-slate-500">{{ nt.description }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Canvas Area -->
<div class="flex-1 flex flex-col relative min-w-0">
<!-- Toolbar -->
<div class="h-10 bg-slate-900 border-b border-slate-700 flex items-center px-3 gap-2 z-20">
<span class="text-sm font-semibold text-slate-300 mr-2">{{ canvasName }}</span>
<span v-if="unsavedChanges.isDirty" class="px-2 py-1 bg-yellow-600 text-yellow-100 rounded text-xs font-medium">
● Несохранено
</span>
<button @click="addNodeAtCenter" class="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs">+ Узел</button>
<button @click="saveCanvas" class="px-2 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs">Сохранить</button>
<button @click="exportObsidian" class="px-2 py-1 bg-purple-600 hover:bg-purple-500 text-white rounded text-xs">Экспорт</button>
<button @click="deleteSelected" :disabled="!selectedNode" class="px-2 py-1 bg-red-600 hover:bg-red-500 disabled:bg-slate-700 disabled:text-slate-500 text-white rounded text-xs">Удалить</button>
<div class="flex-1"></div>
<button @click="manualGitSync" :disabled="gitSync.isSyncing.value"
class="px-2 py-1 bg-orange-600 hover:bg-orange-500 disabled:bg-slate-700 disabled:text-slate-500 text-white rounded text-xs flex items-center gap-1">
<span>{{ gitSync.syncStatusIcon.value }}</span>
Git
</button>
<button @click="fitView" class="px-2 py-1 bg-slate-700 hover:bg-slate-600 text-slate-200 rounded text-xs">Расположить</button>
</div>
<!-- Vue Flow Canvas -->
<div class="flex-1 relative w-full h-full" @drop="onDrop" @dragover.prevent>
<VueFlow v-model="elements" :default-zoom="0.8" :min-zoom="0.2" :max-zoom="2" fit-view-on-init
@node-double-click="onNodeDoubleClick" @node-click="onNodeClick" @pane-click="onPaneClick" @connect="onConnect"
@node-drag-start="onNodeDragStart" @node-drag="onNodeDrag" @node-drag-stop="onNodeDragStop"
class="bg-slate-950 w-full h-full">
<Background pattern-color="#334155" :gap="20" />
<Controls />
<MiniMap pannable zoomable class="bg-slate-800 border-slate-700" />
<template #node-text="{ data, id }">
<FlowNodeRenderer :id="id" :data="data" type="text" :selected="selectedNode?.id === id" @update="updateNodeData" />
</template>
<template #node-file="{ data, id }">
<FlowNodeRenderer :id="id" :data="data" type="file" :selected="selectedNode?.id === id" @update="updateNodeData" />
</template>
<template #node-task="{ data, id }">
<FlowNodeRenderer :id="id" :data="data" type="task" :selected="selectedNode?.id === id" @update="updateNodeData" />
</template>
<template #node-ai="{ data, id }">
<FlowNodeRenderer :id="id" :data="data" type="ai" :selected="selectedNode?.id === id" @update="updateNodeData" />
</template>
<template #node-webhook="{ data, id }">
<FlowNodeRenderer :id="id" :data="data" type="webhook" :selected="selectedNode?.id === id" @update="updateNodeData" />
</template>
<template #node-git="{ data, id }">
<FlowNodeRenderer :id="id" :data="data" type="git" :selected="selectedNode?.id === id" @update="updateNodeData" />
</template>
</VueFlow>
</div>
</div>
<!-- Properties Panel -->
<div class="w-60 bg-slate-900 border-l border-slate-700 flex flex-col shrink-0 z-20">
<div class="p-3 border-b border-slate-700">
<h3 class="text-sm font-bold text-slate-300">Свойства</h3>
</div>
<div class="flex-1 overflow-y-auto p-3">
<div v-if="selectedNode" class="space-y-3">
<div>
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">ID</label>
<input :value="selectedNode.id" disabled class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-400 font-mono" />
</div>
<div>
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Тип</label>
<input :value="selectedNode.type" disabled class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-300 capitalize" />
</div>
<div>
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Название</label>
<input v-model="selectedNode.data.label" @blur="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
</div>
<div v-if="selectedNode.data.text !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Текст</label>
<textarea v-model="selectedNode.data.text" @blur="commitNodeChange" rows="4" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none resize-none font-mono"></textarea>
</div>
<div v-if="selectedNode.data.file !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Файл</label>
<input v-model="selectedNode.data.file" @blur="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
</div>
<div v-if="selectedNode.data.state !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Состояние</label>
<select v-model="selectedNode.data.state" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option value="pending">Ожидает</option>
<option value="in_progress">В процессе</option>
<option value="completed">Завершено</option>
</select>
</div>
<div v-if="selectedNode.data.assigned_to !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Назначено</label>
<input v-model="selectedNode.data.assigned_to" @blur="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
</div>
<div v-if="selectedNode.data.alarm !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Напоминание</label>
<input v-model="selectedNode.data.alarm" @blur="commitNodeChange" type="datetime-local" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
</div>
<div v-if="selectedNode.data.script_id !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Скрипт</label>
<input v-model="selectedNode.data.script_id" @blur="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none font-mono" />
</div>
<div v-if="selectedNode.data.prompt !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">AI Запрос</label>
<textarea v-model="selectedNode.data.prompt" @blur="commitNodeChange" rows="3" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none resize-none font-mono"></textarea>
</div>
<div>
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Ширина</label>
<input v-model.number="selectedNode.data.width" @blur="commitNodeChange" type="number" min="100" max="800" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200" />
</div>
<div>
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Высота</label>
<input v-model.number="selectedNode.data.height" @blur="commitNodeChange" type="number" min="50" max="600" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200" />
</div>
</div>
<div v-else class="text-xs text-slate-500 text-center mt-8">
Выберите узел для редактирования
</div>
</div>
</div>
</div>
</template>
<script setup>
import { VueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/minimap'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
const props = defineProps({
canvasId: { type: String, default: 'demo' }
})
const { loadCanvas, updateNodeState: apiUpdateNodeState } = useCanvas()
const gitSync = useGitSync()
const unsavedChanges = useUnsavedChanges()
const auth = useAuth()
const authHeaders = computed(() => ({ Authorization: `Bearer ${auth.token.value}` }))
const elements = ref([])
const selectedNode = ref(null)
const canvasName = computed(() => props.canvasId)
const nodeTypes = [
{ type: 'text', name: 'Текст', icon: '📝', description: 'Markdown заметка' },
{ type: 'file', name: 'Файл', icon: '📄', description: 'Ссылка на файл' },
{ type: 'task', name: 'Задача', icon: '✅', description: 'Задача с состоянием' },
{ type: 'ai', name: 'AI Узел', icon: '🤖', description: 'LLM запрос' },
{ type: 'webhook', name: 'Webhook', icon: '🔗', description: 'HTTP триггер' },
{ type: 'git', name: 'Git Синхр', icon: '🔄', description: 'Git синхронизация' }
]
// Load canvas from API
const loadData = async () => {
const data = await loadCanvas(props.canvasId)
elements.value = transformToFlowElements(data)
// Mark initial state as saved
unsavedChanges.markAsSaved(data)
}
// Transform Obsidian canvas to Vue Flow elements
const transformToFlowElements = (canvasData) => {
const nodes = (canvasData.nodes || []).map(node => ({
id: node.id,
type: node.type === 'file' ? 'file' : node.type === 'ai' ? 'ai' : node.type === 'git' ? 'git' : 'text',
position: { x: node.x, y: node.y },
data: {
...node,
width: node.width || 300,
height: node.height || 200,
label: node.label || node.text?.slice(0, 20) || node.file || 'Node'
},
style: {
width: `${node.width || 300}px`,
height: `${node.height || 200}px`,
background: node.style?.background || '#1e293b',
border: `1px solid ${node.style?.border || '#334155'}`,
borderRadius: '8px'
}
}))
const edges = (canvasData.edges || []).map(edge => ({
id: edge.id,
source: edge.fromNode,
target: edge.toNode,
sourceHandle: edge.fromSide,
targetHandle: edge.toSide,
animated: true,
style: { stroke: '#475569', strokeWidth: 2 }
}))
return [...nodes, ...edges]
}
// Transform Vue Flow elements back to Obsidian canvas
const transformToObsidianCanvas = (flowElements) => {
const nodes = flowElements.filter(el => el.position !== undefined).map(node => ({
id: node.id,
type: node.type === 'file' ? 'file' : 'text',
x: Math.round(node.position.x),
y: Math.round(node.position.y),
width: node.data.width || 300,
height: node.data.height || 200,
text: node.data.text,
file: node.data.file,
state: node.data.state,
assigned_to: node.data.assigned_to,
alarm: node.data.alarm,
script_id: node.data.script_id,
prompt: node.data.prompt,
style: {
background: node.style?.background || '#1e293b',
border: node.style?.border?.replace('1px solid ', '') || '#334155'
}
}))
const edges = flowElements.filter(el => el.source && el.target).map(edge => ({
id: edge.id,
fromNode: edge.source,
fromSide: edge.sourceHandle || 'right',
toNode: edge.target,
toSide: edge.targetHandle || 'left'
}))
return { nodes, edges }
}
// Drag and drop handlers
const onDragStart = (event, nodeType) => {
event.dataTransfer.setData('application/vueflow', JSON.stringify(nodeType))
}
const onDrop = (event) => {
const data = JSON.parse(event.dataTransfer.getData('application/vueflow'))
if (!data) return
const rect = event.currentTarget.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
createNode(data.type, x, y)
}
// Create new node
const createNode = (type, x, y) => {
const id = `node-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const nodeData = {
id,
type,
position: { x: x - 150, y: y - 100 },
data: {
width: 300,
height: 200,
label: `${type.charAt(0).toUpperCase() + type.slice(1)} Node`
}
}
// Add type-specific defaults
switch (type) {
case 'text':
nodeData.data.text = '# New Note\n\nEnter your text here'
break
case 'file':
nodeData.data.file = 'docs/file.md'
break
case 'task':
nodeData.data.state = 'pending'
nodeData.data.assigned_to = ''
nodeData.data.alarm = ''
nodeData.data.script_id = ''
break
case 'ai':
nodeData.data.prompt = 'Analyze this content'
break
case 'webhook':
nodeData.data.url = 'https://api.example.com'
nodeData.data.method = 'POST'
break
case 'git':
nodeData.data.repo = 'xflow-maps'
nodeData.data.branch = 'main'
nodeData.data.message = 'Update mind map'
break
}
elements.value.push(nodeData)
}
// Node selection and editing
const onNodeClick = ({ node }) => {
selectedNode.value = node
}
const onPaneClick = () => {
selectedNode.value = null
}
const updateNodeData = ({ id, data }) => {
const node = elements.value.find(el => el.id === id)
if (node) {
node.data = { ...node.data, ...data }
}
}
const commitNodeChange = () => {
if (selectedNode.value) {
// Trigger reactivity
const idx = elements.value.findIndex(el => el.id === selectedNode.value.id)
if (idx !== -1) {
elements.value.splice(idx, 1, { ...elements.value[idx] })
}
// Track changes
unsavedChanges.trackChanges(transformToObsidianCanvas(elements.value))
}
}
// Node actions
const addNodeAtCenter = () => {
createNode('text', 400, 300)
}
const deleteSelected = () => {
if (selectedNode.value) {
elements.value = elements.value.filter(el => el.id !== selectedNode.value.id)
selectedNode.value = null
}
}
const onNodeDragStart = (_event) => {}
const onNodeDrag = (_event) => {}
const onNodeDragStop = (event) => {
const node = event.node
// Update node data position
if (node.data) {
node.data.x = node.position.x
node.data.y = node.position.y
}
// Track changes
unsavedChanges.trackChanges(transformToObsidianCanvas(elements.value))
console.log('Node drag stopped:', node.id, 'at position:', node.position)
}
const onNodeDoubleClick = (event) => {
const node = event.node
const currentState = node.data?.state || 'pending'
const nextState = currentState === 'pending' ? 'in_progress' : currentState === 'in_progress' ? 'completed' : 'pending'
updateNodeData({ id: node.id, data: { state: nextState } })
}
const onConnect = (connection) => {
const edge = {
id: `edge-${Date.now()}`,
source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle,
targetHandle: connection.targetHandle,
animated: true,
style: { stroke: '#475569', strokeWidth: 2 }
}
elements.value.push(edge)
}
// Canvas actions (moved to enhanced version below with Git sync)
const exportObsidian = () => {
const obsidianCanvas = transformToObsidianCanvas(elements.value)
const blob = new Blob([JSON.stringify(obsidianCanvas, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${props.canvasId}.canvas`
a.click()
URL.revokeObjectURL(url)
}
const fitView = () => {
const padding = 40
const cols = 3
// Read actual unscaled sizes from DOM using offsetWidth/offsetHeight
// These are NOT affected by CSS transforms (zoom)
const domSizes = {}
const domNodes = document.querySelectorAll('.vue-flow__node')
domNodes.forEach(el => {
const id = el.getAttribute('data-id')
if (id) {
domSizes[id] = { width: el.offsetWidth, height: el.offsetHeight }
}
})
const nodes = elements.value.filter(el => el.id && !el.source && !el.target)
// Get real unscaled size from DOM
const getSize = (node) => {
const dom = domSizes[node.id]
if (dom && dom.width > 0 && dom.height > 0) {
return dom
}
return { width: node.data?.width || 320, height: node.data?.height || 200 }
}
// Grid layout: row by row, max height per row
const placed = []
let y = padding
for (let i = 0; i < nodes.length; i += cols) {
const rowNodes = nodes.slice(i, i + cols)
let x = padding
let rowMaxHeight = 0
for (const node of rowNodes) {
const size = getSize(node)
placed.push({ id: node.id, x, y, w: size.width, h: size.height })
x += size.width + padding
rowMaxHeight = Math.max(rowMaxHeight, size.height)
}
y += rowMaxHeight + padding
}
// Apply
elements.value = elements.value.map(el => {
if (el.source && el.target) return el
const p = placed.find(n => n.id === el.id)
if (!p) return el
return {
...el,
position: { x: p.x, y: p.y },
data: { ...el.data, x: p.x, y: p.y }
}
})
}
// Git synchronization functions
const createGitRepo = async () => {
try {
await gitSync.createRepository(props.canvasId, 'personal')
console.log('Git repository created successfully')
} catch (error) {
console.error('Failed to create Git repository:', error)
}
}
const manualGitSync = async () => {
try {
const canvasData = transformToObsidianCanvas(elements.value)
await gitSync.autoSyncCanvas(props.canvasId, canvasData)
} catch (error) {
console.error('Manual Git sync failed:', error)
}
}
// Enhanced save function with Git sync
const saveCanvas = async () => {
try {
const obsidianCanvas = transformToObsidianCanvas(elements.value)
await $fetch(`/api/canvas/${props.canvasId}`, {
method: 'PUT',
body: obsidianCanvas,
headers: authHeaders.value
})
// Mark as saved
unsavedChanges.markAsSaved(obsidianCanvas)
// Auto-sync to Git if repository exists
await gitSync.autoSyncCanvas(props.canvasId, obsidianCanvas)
console.log('Canvas saved successfully')
} catch (err) {
console.error('Failed to save canvas:', err)
}
}
// Load data on mount
onMounted(async () => {
await loadData()
// Check for existing Git repository
await gitSync.checkRepository(props.canvasId)
})
</script>
<style scoped>
:deep(.vue-flow__node) {
font-family: ui-sans-serif, system-ui, sans-serif;
height: auto !important;
}
:deep(.vue-flow__edge) {
stroke: #475569;
}
:deep(.vue-flow__controls) {
background: #1e293b;
border: 1px solid #334155;
}
:deep(.vue-flow__controls button) {
background: #334155;
border: none;
color: #e2e8f0;
}
:deep(.vue-flow__controls button:hover) {
background: #475569;
}
</style>
+249
View File
@@ -0,0 +1,249 @@
<template>
<div
ref="rootEl"
:class="[
'bg-slate-800 border rounded-lg shadow-lg p-4 text-sm font-sans flex flex-col h-full w-full',
selected ? 'ring-2 ring-blue-500' : '',
nodeStateClass
]"
:style="nodeStyle"
>
<!-- Header with icon and label -->
<div class="flex items-center gap-2 mb-3 pb-2 border-b border-slate-600">
<span class="text-lg">{{ nodeIcon }}</span>
<div class="flex-1 min-w-0">
<div class="font-semibold text-white truncate">{{ data.label || getNodeTitle() }}</div>
<div class="text-xs text-slate-400 uppercase tracking-wider">{{ data.type || type }}</div>
</div>
<div v-if="data.state" :class="stateBadgeClass">
{{ stateLabel }}
</div>
</div>
<!-- Content preview for measurement -->
<div class="text-slate-100 space-y-2 flex-1">
<div v-if="data.text" class="text-slate-300 text-xs leading-relaxed whitespace-pre-wrap mb-3" v-html="formattedText"></div>
<div v-if="data.file" class="flex items-center gap-2">
<span class="text-sm">📄</span>
<span class="font-mono text-sm text-blue-400">{{ data.file }}</span>
</div>
<div v-if="data.prompt" class="text-sm text-purple-300">
<div class="font-medium mb-1">AI Prompt:</div>
<div>{{ data.prompt }}</div>
</div>
<div v-if="data.url" class="flex items-center gap-2">
<span class="text-sm">🔗</span>
<a :href="data.url" target="_blank" class="text-blue-400 hover:text-blue-300 text-sm break-all">{{ data.url }}</a>
</div>
<div v-if="data.script_id" class="flex items-center gap-2">
<span class="text-sm">🎭</span>
<span class="font-mono text-sm text-green-400">{{ data.script_id }}</span>
</div>
<div v-if="data.assigned_to" class="flex items-center gap-2">
<span class="text-sm">👤</span>
<span class="text-sm text-slate-300">{{ data.assigned_to }}</span>
</div>
<div v-if="data.alarm" class="flex items-center gap-2">
<span class="text-sm">⏰</span>
<span class="text-sm text-orange-400">{{ formatAlarm(data.alarm) }}</span>
</div>
</div>
<!-- Connection handles -->
<Handle type="target" :position="Position.Left" class="w-3 h-3 bg-slate-600 border-2 border-slate-700" />
<Handle type="source" :position="Position.Right" class="w-3 h-3 bg-slate-600 border-2 border-slate-700" />
</div>
</template>
<script setup>
import { Handle, Position, useVueFlow } from '@vue-flow/core'
import { computed, ref, onMounted, nextTick } from 'vue'
const props = defineProps({
id: { type: String, required: true },
data: { type: Object, required: true },
type: { type: String, default: 'text' },
selected: { type: Boolean, default: false }
})
const emit = defineEmits(['update'])
// Root element ref
const rootEl = ref(null)
// Access Vue Flow API to update node dimensions
const { updateNode } = useVueFlow()
// Measure and apply real height to Vue Flow node wrapper
const applyNodeSize = () => {
if (!rootEl.value) return
const h = rootEl.value.scrollHeight
if (h > 0) {
updateNode(props.id, {
style: { height: h + 'px' }
})
}
}
onMounted(() => {
nextTick(() => setTimeout(applyNodeSize, 50))
setTimeout(applyNodeSize, 200)
})
// Node style: fixed width from data, height auto to fit content
const nodeStyle = computed(() => {
const baseStyle = {
backgroundColor: props.data.style?.background || '#1e293b',
borderColor: props.data.style?.border || '#475569',
width: `${props.data.width || 320}px`
}
if (props.data.state === 'completed') {
baseStyle.borderColor = '#10b981'
} else if (props.data.state === 'in_progress') {
baseStyle.borderColor = '#f59e0b'
}
return baseStyle
})
// Node type icon
const nodeIcon = computed(() => {
const icons = {
text: '📝',
file: '📄',
task: '✅',
ai: '🤖',
webhook: '🔗',
git: '🔄'
}
return icons[props.type] || '📝'
})
// State handling
const nodeStateClass = computed(() => {
const state = props.data.state
if (state === 'completed') return 'border-emerald-600 bg-emerald-950/20'
if (state === 'in_progress') return 'border-yellow-600 bg-yellow-950/20'
return ''
})
const stateBadgeClass = computed(() => {
const state = props.data.state
if (state === 'completed') return 'px-1.5 py-0.5 bg-emerald-800/50 text-emerald-300 rounded text-[10px] font-medium'
if (state === 'in_progress') return 'px-1.5 py-0.5 bg-yellow-800/50 text-yellow-300 rounded text-[10px] font-medium'
return 'px-1.5 py-0.5 bg-slate-700 text-slate-400 rounded text-[10px] font-medium'
})
const stateLabel = computed(() => {
const state = props.data.state
if (state === 'completed') return '✓'
if (state === 'in_progress') return '⟳'
return '○'
})
// Get node title from text content
const getNodeTitle = () => {
if (props.data.text) {
const lines = props.data.text.split('\n')
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith('# ')) {
return trimmed.replace('# ', '')
}
}
// If no h1 found, use first non-empty line
const firstLine = lines.find(l => l.trim())
return firstLine ? firstLine.trim().slice(0, 30) : 'Node'
}
if (props.data.file) return props.data.file
return 'Node'
}
// Format text with markdown support
const formattedText = computed(() => {
if (!props.data.text) return ''
let text = props.data.text
// Convert markdown headers
text = text.replace(/^# (.*$)/gim, '<h1 class="text-lg font-bold text-white mb-2">$1</h1>')
text = text.replace(/^## (.*$)/gim, '<h2 class="text-base font-semibold text-white mb-2">$1</h2>')
text = text.replace(/^### (.*$)/gim, '<h3 class="text-sm font-medium text-white mb-1">$1</h3>')
// Convert bold text
text = text.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold text-white">$1</strong>')
// Convert italic text
text = text.replace(/\*(.*?)\*/g, '<em class="italic text-slate-200">$1</em>')
// Convert code blocks
text = text.replace(/```(.*?)```/gs, '<pre class="bg-slate-900 p-2 rounded text-xs font-mono text-slate-300 mb-2 overflow-x-auto"><code>$1</code></pre>')
// Convert inline code
text = text.replace(/`(.*?)`/g, '<code class="bg-slate-700 px-1 rounded text-xs font-mono text-slate-300">$1</code>')
// Convert lists
text = text.replace(/^- (.*$)/gim, '<li class="ml-4 text-slate-200">• $1</li>')
text = text.replace(/^ - (.*$)/gim, '<li class="ml-8 text-slate-300">◦ $1</li>')
// Convert line breaks
text = text.replace(/\n\n/g, '</p><p class="mb-2 text-slate-200">')
text = text.replace(/\n/g, '<br>')
// Wrap in paragraphs
if (!text.includes('<p>')) {
text = '<p class="mb-2 text-slate-200">' + text + '</p>'
}
return text
})
// Format alarm time
const formatAlarm = (alarm) => {
try {
const date = new Date(alarm)
return date.toLocaleDateString('ru-RU', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
} catch {
return alarm
}
}
</script>
<style scoped>
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #475569 #1e293b;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #1e293b;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
</style>
+89
View File
@@ -0,0 +1,89 @@
<template>
<div
class="w-full h-full overflow-auto p-3 rounded-lg cursor-pointer"
:class="stateClass"
@click="$emit('update-state', { nodeId: id, state: nextState })"
>
<div v-if="data.frontmatter?.alarm" class="text-xs text-yellow-400 mb-1 flex items-center gap-1">
<span>⏰</span>
<span>{{ formatAlarm(data.frontmatter.alarm) }}</span>
</div>
<div v-if="data.frontmatter?.assigned_to" class="text-xs text-slate-400 mb-1">
👤 {{ data.frontmatter.assigned_to }}
</div>
<div v-if="data.text" class="prose prose-invert prose-sm max-w-none" v-html="renderedHtml" />
<div v-else-if="data.file" class="text-sm text-blue-400">
📄 {{ data.file }}
</div>
<div class="mt-2 flex items-center gap-2">
<span
class="text-xs px-2 py-0.5 rounded-full"
:class="badgeClass"
>
{{ stateLabel }}
</span>
<span v-if="data.frontmatter?.script_id" class="text-xs text-purple-400">
⚡ {{ data.frontmatter.script_id }}
</span>
</div>
</div>
</template>
<script setup>
const props = defineProps({
id: { type: String, required: true },
data: { type: Object, required: true },
isFile: { type: Boolean, default: false }
})
const emit = defineEmits(['update-state'])
const { renderMarkdown } = useCanvas()
const renderedHtml = computed(() => {
return renderMarkdown(props.data.text || '')
})
const currentState = computed(() => props.data.state || 'pending')
const nextState = computed(() => {
const s = currentState.value
return s === 'pending' ? 'in_progress' : s === 'in_progress' ? 'completed' : 'pending'
})
const stateClass = computed(() => {
switch (currentState.value) {
case 'completed': return 'bg-emerald-900/40 border-emerald-700'
case 'in_progress': return 'bg-blue-900/40 border-blue-700'
default: return 'bg-slate-800 border-slate-700'
}
})
const badgeClass = computed(() => {
switch (currentState.value) {
case 'completed': return 'bg-emerald-700 text-emerald-100'
case 'in_progress': return 'bg-blue-700 text-blue-100'
default: return 'bg-slate-700 text-slate-300'
}
})
const stateLabel = computed(() => {
switch (currentState.value) {
case 'completed': return '✅ Выполнено'
case 'in_progress': return '🔧 В работе'
default: return '📋 Ожидает'
}
})
const formatAlarm = (alarm) => {
try {
return new Date(alarm).toLocaleString('ru-RU')
} catch {
return alarm
}
}
</script>
+33
View File
@@ -0,0 +1,33 @@
export interface Achievement {
id: string
title: string
description: string
icon?: string
unlockedAt?: string
}
export const useAchievements = () => {
const achievements = ref<Achievement[]>([])
const stats = ref({ total: 0, completedTasks: 0 })
const pending = ref(false)
const loadAchievements = async () => {
pending.value = true
try {
const data = await $fetch<{ achievements: Achievement[], stats: { total: number, completedTasks: number } }>('/api/achievements')
achievements.value = data.achievements || []
stats.value = data.stats || { total: 0, completedTasks: 0 }
} catch (e) {
console.error('Failed to load achievements:', e)
} finally {
pending.value = false
}
}
return {
achievements,
stats,
pending,
loadAchievements
}
}
+72
View File
@@ -0,0 +1,72 @@
import { jwtDecode } from 'jwt-decode'
import { getAuth, signInWithPopup, GoogleAuthProvider, signOut, onAuthStateChanged } from 'firebase/auth'
interface User {
id: string
email: string
username: string
role?: string
}
export const useAuth = () => {
const token = useCookie<string | null>('xflow_token', {
maxAge: 60 * 60 * 24 * 7,
sameSite: 'strict'
})
const user = computed<User | null>(() => {
if (!token.value) return null
try {
return jwtDecode<User>(token.value)
} catch {
return null
}
})
const isAuthenticated = computed(() => !!token.value && !!user.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const googleSignIn = async () => {
const auth = getAuth()
const provider = new GoogleAuthProvider()
const result = await signInWithPopup(auth, provider)
const idToken = await result.user.getIdToken()
const res = await $fetch<{ token: string; user: User }>('/api/auth/google', {
method: 'POST',
body: { idToken }
})
token.value = res.token
await navigateTo('/')
}
const logout = async () => {
const auth = getAuth()
await signOut(auth)
token.value = null
navigateTo('/login')
}
const initAuth = () => {
const auth = getAuth()
onAuthStateChanged(auth, async (firebaseUser) => {
if (firebaseUser) {
const idToken = await firebaseUser.getIdToken()
if (idToken !== token.value) {
token.value = idToken
}
}
})
}
return {
token,
user,
isAuthenticated,
isAdmin,
googleSignIn,
logout,
initAuth
}
}
+85
View File
@@ -0,0 +1,85 @@
import MarkdownIt from 'markdown-it'
export interface CanvasNode {
id: string
type: string
x: number
y: number
width: number
height: number
text?: string
file?: string
frontmatter?: Record<string, any>
state?: 'pending' | 'in_progress' | 'completed'
style?: Record<string, string>
}
export interface CanvasEdge {
id: string
fromNode: string
fromSide: string
toNode: string
toSide: string
}
export interface CanvasData {
nodes: CanvasNode[]
edges: CanvasEdge[]
}
export const useCanvas = () => {
const md = new MarkdownIt({
html: false,
linkify: true,
typographer: true
})
const renderMarkdown = (text: string): string => {
return md.render(text || '')
}
const parseCanvasJson = (json: any): CanvasData => {
const nodes = (json.nodes || []).map((n: any) => ({
id: n.id,
type: n.type,
x: n.x,
y: n.y,
width: n.width || 300,
height: n.height || 200,
text: n.text,
file: n.file,
frontmatter: n.frontmatter,
state: n.state || 'pending',
style: n.style || {}
}))
const edges = (json.edges || []).map((e: any) => ({
id: e.id,
fromNode: e.fromNode || e.source,
fromSide: e.fromSide || 'right',
toNode: e.toNode || e.target,
toSide: e.toSide || 'left'
}))
return { nodes, edges }
}
const loadCanvas = async (id: string): Promise<CanvasData> => {
const data = await $fetch(`/api/canvas/${id}`)
return parseCanvasJson(data)
}
const updateNodeState = async (nodeId: string, state: string) => {
return await $fetch(`/api/nodes/${nodeId}`, {
method: 'PUT',
body: { state }
})
}
return {
renderMarkdown,
parseCanvasJson,
loadCanvas,
updateNodeState
}
}
+175
View File
@@ -0,0 +1,175 @@
// Composable for Git synchronization functionality
interface GitRepository {
id: string
name: string
owner: string
url: string
cloneUrl: string
isPrivate: boolean
type: 'personal' | 'group'
ownerId: string
createdAt: string
updatedAt: string
}
export const useGitSync = () => {
const auth = useAuth()
const user = computed(() => auth.user)
const apiFetch = (url: string, opts: any = {}) => $fetch(url, {
...opts,
headers: { Authorization: `Bearer ${auth.token.value}`, ...opts.headers }
})
const isSyncing = ref(false)
const lastSyncTime = ref<string | null>(null)
const syncStatus = ref<'idle' | 'syncing' | 'success' | 'error'>('idle')
const repository = ref<GitRepository | null>(null)
// Auto-sync canvas changes
const autoSyncCanvas = async (canvasId: string, canvasData: any) => {
if (!auth.user || !canvasId) return
try {
syncStatus.value = 'syncing'
isSyncing.value = true
const response = await apiFetch('/api/git/real-sync', {
method: 'POST',
body: {
canvasId,
action: 'sync',
commitMessage: `Auto-sync: ${new Date().toLocaleString('ru-RU')}`
}
}) as any
if (response.success) {
syncStatus.value = 'success'
lastSyncTime.value = new Date().toISOString()
// Show success notification
console.log('Canvas synchronized with Git:', response.message)
} else {
syncStatus.value = 'error'
console.error('Git sync failed:', response.message)
}
} catch (error) {
syncStatus.value = 'error'
console.error('Auto-sync failed:', error)
} finally {
isSyncing.value = false
// Reset status after delay
setTimeout(() => {
if (syncStatus.value === 'success') {
syncStatus.value = 'idle'
}
}, 3000)
}
}
// Create Git repository for canvas
const createRepository = async (canvasId: string, type: 'personal' | 'group' = 'personal') => {
if (!auth.user || !canvasId) return
try {
const response = await apiFetch('/api/git/real-sync', {
method: 'POST',
body: {
canvasId,
action: 'init'
}
}) as any
if (response.success) {
repository.value = response.repository
console.log('Git repository created:', response.message)
return response.repository
}
} catch (error) {
console.error('Failed to create repository:', error)
throw error
}
}
// Manual sync
const manualSync = async (canvasId: string, commitMessage?: string) => {
if (!auth.user || !canvasId) return
try {
syncStatus.value = 'syncing'
isSyncing.value = true
const response = await apiFetch('/api/git/real-sync', {
method: 'POST',
body: {
canvasId,
action: 'sync',
commitMessage: commitMessage || `Ручная синхронизация: ${new Date().toLocaleString('ru-RU')}`
}
}) as any
if (response.success) {
syncStatus.value = 'success'
lastSyncTime.value = new Date().toISOString()
console.log('Manual sync completed:', response.message)
}
} catch (error) {
syncStatus.value = 'error'
console.error('Manual sync failed:', error)
} finally {
isSyncing.value = false
setTimeout(() => {
if (syncStatus.value === 'success') {
syncStatus.value = 'idle'
}
}, 3000)
}
}
// Check if canvas has repository
const checkRepository = async (canvasId: string) => {
if (!canvasId) return null
try {
const response = await apiFetch(`/api/git/repository/${canvasId}`) as any
repository.value = response.repository
return response.repository
} catch (error) {
repository.value = null
return null
}
}
// Get sync status color
const syncStatusColor = computed(() => {
switch (syncStatus.value) {
case 'syncing': return 'text-yellow-400'
case 'success': return 'text-green-400'
case 'error': return 'text-red-400'
default: return 'text-slate-400'
}
})
// Get sync status icon
const syncStatusIcon = computed(() => {
switch (syncStatus.value) {
case 'syncing': return '⟳'
case 'success': return '✓'
case 'error': return '✗'
default: return '⚡'
}
})
return {
isSyncing: readonly(isSyncing),
lastSyncTime: readonly(lastSyncTime),
syncStatus: readonly(syncStatus),
repository: readonly(repository),
syncStatusColor,
syncStatusIcon,
autoSyncCanvas,
createRepository,
manualSync,
checkRepository
}
}
+102
View File
@@ -0,0 +1,102 @@
export const usePush = () => {
const isSupported = ref(false)
const subscription = ref<PushSubscription | null>(null)
const checkSupport = () => {
isSupported.value = 'serviceWorker' in navigator && 'PushManager' in window
}
const getRegistration = async (): Promise<ServiceWorkerRegistration | null> => {
if (!('serviceWorker' in navigator)) return null
return navigator.serviceWorker.ready
}
const subscribe = async (): Promise<boolean> => {
const registration = await getRegistration()
if (!registration) return false
try {
const existing = await registration.pushManager.getSubscription()
if (existing) {
subscription.value = existing
await sendSubscriptionToServer(existing)
return true
}
// In production, get VAPID public key from server
const vapidKey = await $fetch<string>('/api/push/vapid-public-key').catch(() => null)
const applicationServerKey = vapidKey
? (urlBase64ToUint8Array(vapidKey) as unknown as ArrayBuffer)
: undefined
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey
})
subscription.value = sub
await sendSubscriptionToServer(sub)
return true
} catch (err) {
console.error('Push subscription failed:', err)
return false
}
}
const sendSubscriptionToServer = async (sub: PushSubscription) => {
await $fetch('/api/push/subscribe', {
method: 'POST',
body: { subscription: sub.toJSON() }
})
}
const unsubscribe = async (): Promise<boolean> => {
const registration = await getRegistration()
if (!registration) return false
const existing = await registration.pushManager.getSubscription()
if (existing) {
await existing.unsubscribe()
subscription.value = null
}
return true
}
const urlBase64ToUint8Array = (base64String: string): Uint8Array => {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = atob(base64)
return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)))
}
const showLocalNotification = (title: string, options?: NotificationOptions) => {
if ('Notification' in window && Notification.permission === 'granted') {
navigator.serviceWorker.ready.then(reg => {
reg.showNotification(title, {
icon: '/pwa-192x192.svg',
badge: '/favicon.svg',
...options as any
})
})
}
}
const requestPermission = async (): Promise<boolean> => {
if (!('Notification' in window)) return false
const permission = await Notification.requestPermission()
return permission === 'granted'
}
onMounted(() => {
checkSupport()
})
return {
isSupported,
subscription,
subscribe,
unsubscribe,
requestPermission,
showLocalNotification
}
}
+56
View File
@@ -0,0 +1,56 @@
// Composable for tracking unsaved changes and preventing accidental data loss
export const useUnsavedChanges = () => {
const isDirty = ref(false)
const lastSavedData = ref<string | null>(null)
const currentData = ref<string | null>(null)
// Track changes by comparing current data with last saved data
const trackChanges = (newData: any) => {
currentData.value = JSON.stringify(newData)
if (lastSavedData.value && currentData.value !== lastSavedData.value) {
isDirty.value = true
} else if (!lastSavedData.value) {
isDirty.value = true
}
}
// Mark data as saved
const markAsSaved = (data: any) => {
lastSavedData.value = JSON.stringify(data)
currentData.value = lastSavedData.value
isDirty.value = false
}
// Reset dirty state
const resetDirty = () => {
isDirty.value = false
lastSavedData.value = currentData.value
}
// Warning before leaving page
const beforeUnloadHandler = (event: BeforeUnloadEvent) => {
if (isDirty.value) {
event.preventDefault()
event.returnValue = 'У вас есть несохраненные изменения. Вы уверены, что хотите уйти?'
return event.returnValue
}
}
// Setup warning on mount
onMounted(() => {
window.addEventListener('beforeunload', beforeUnloadHandler)
})
// Cleanup on unmount
onUnmounted(() => {
window.removeEventListener('beforeunload', beforeUnloadHandler)
})
return {
isDirty: readonly(isDirty),
trackChanges,
markAsSaved,
resetDirty
}
}
+31
View File
@@ -0,0 +1,31 @@
<template>
<div class="min-h-screen bg-slate-950 text-slate-100">
<nav v-if="isAuthenticated" class="border-b border-slate-800 bg-slate-900/80 backdrop-blur">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-14">
<div class="flex items-center gap-4">
<NuxtLink to="/" class="text-xl font-bold text-blue-400">XFlow</NuxtLink>
<NuxtLink to="/canvases" class="text-sm text-slate-400 hover:text-white transition">Карты</NuxtLink>
<NuxtLink to="/canvas" class="text-sm text-slate-400 hover:text-white transition">Редактор</NuxtLink>
<NuxtLink to="/flow" class="text-sm text-slate-400 hover:text-white transition">Поток</NuxtLink>
<NuxtLink v-if="isAdmin" to="/admin" class="text-sm text-slate-400 hover:text-white transition">Админ</NuxtLink>
<NuxtLink to="/settings" class="text-sm text-slate-400 hover:text-white transition">Настройки</NuxtLink>
</div>
<div class="flex items-center gap-4">
<NuxtLink :to="`/profile/${user?.id}`" class="text-sm text-slate-400 hover:text-white transition">
{{ user?.username }}
</NuxtLink>
<button @click="logout" class="text-sm text-red-400 hover:text-red-300 transition">Выйти</button>
</div>
</div>
</div>
</nav>
<slot />
<AchievementOverlay :achievements="userAchievements" />
</div>
</template>
<script setup>
const { user, isAuthenticated, isAdmin, logout } = useAuth()
const { achievements: userAchievements } = useAchievements()
</script>
+15
View File
@@ -0,0 +1,15 @@
export default defineNuxtRouteMiddleware((to, from) => {
const token = useCookie('xflow_token')
if (!token.value) {
return navigateTo('/login')
}
try {
const payload = JSON.parse(atob(token.value.split('.')[1]))
if (payload.role !== 'admin') {
return navigateTo('/')
}
} catch {
return navigateTo('/login')
}
})
+6
View File
@@ -0,0 +1,6 @@
export default defineNuxtRouteMiddleware((to, from) => {
const token = useCookie('xflow_token')
if (!token.value && to.path !== '/login') {
return navigateTo('/login')
}
})
+174
View File
@@ -0,0 +1,174 @@
<template>
<div class="min-h-screen bg-slate-950 p-6">
<div class="max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-slate-100">XFlow Admin Panel</h1>
<div class="flex items-center gap-2 text-sm text-slate-400">
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
Online
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-blue-400">{{ stats.users }}</div>
<div class="text-sm text-slate-400 mt-1">Registered Users</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-emerald-400">{{ stats.canvases }}</div>
<div class="text-sm text-slate-400 mt-1">Canvases</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-yellow-400">{{ stats.nodes }}</div>
<div class="text-sm text-slate-400 mt-1">Total Nodes</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-3xl font-bold text-purple-400">{{ stats.pushes }}</div>
<div class="text-sm text-slate-400 mt-1">Push Subscribers</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Users -->
<div class="lg:col-span-2 bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
<h2 class="text-lg font-semibold text-slate-200">Users</h2>
<button @click="refreshData" class="text-sm text-blue-400 hover:text-blue-300">Refresh</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-slate-800 text-slate-400">
<tr>
<th class="text-left p-3">Email</th>
<th class="text-left p-3">Username</th>
<th class="text-left p-3">Role</th>
<th class="text-left p-3">ID</th>
</tr>
</thead>
<tbody class="text-slate-300">
<tr v-for="user in users" :key="user.id" class="border-t border-slate-800">
<td class="p-3">{{ user.email }}</td>
<td class="p-3">{{ user.username }}</td>
<td class="p-3">
<span :class="roleBadge(user.role)">{{ user.role }}</span>
</td>
<td class="p-3 text-xs text-slate-500 font-mono">{{ user.id }}</td>
</tr>
<tr v-if="users.length === 0">
<td colspan="4" class="p-6 text-center text-slate-500">No users found</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Canvases -->
<div class="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
<div class="p-4 border-b border-slate-700">
<h2 class="text-lg font-semibold text-slate-200">Canvases</h2>
</div>
<div class="divide-y divide-slate-800">
<div v-for="canvas in canvases" :key="canvas.id" class="p-3">
<div class="flex items-center justify-between">
<NuxtLink :to="`/canvas/${canvas.id}`" class="text-blue-400 hover:text-blue-300 font-medium">
{{ canvas.name }}
</NuxtLink>
<span class="text-xs text-slate-500">{{ canvas.nodeCount }} nodes</span>
</div>
<div class="text-xs text-slate-500 mt-1">{{ formatDate(canvas.updatedAt) }}</div>
</div>
<div v-if="canvases.length === 0" class="p-6 text-center text-slate-500">
No canvases yet
</div>
</div>
</div>
</div>
<!-- Script Runner -->
<div class="mt-6 bg-slate-900 border border-slate-700 rounded-xl p-6">
<h2 class="text-lg font-semibold text-slate-200 mb-4">Script Runner</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
v-for="script in scripts"
:key="script.id"
class="bg-slate-800 rounded-lg p-4"
>
<div class="flex items-center justify-between mb-2">
<span class="font-medium text-slate-200">{{ script.name }}</span>
<span class="text-xs px-2 py-1 rounded-full" :class="statusClass(script.status)">{{ script.status }}</span>
</div>
<button
@click="runScript(script.id)"
class="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-sm transition"
:disabled="script.status === 'running'"
>
Run
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
middleware: ['auth', 'admin']
})
const users = ref([])
const canvases = ref([])
const stats = reactive({ users: 0, canvases: 0, nodes: 0, pushes: 0 })
const scripts = ref([
{ id: 'sync-git', name: 'Sync Git Server', status: 'idle' },
{ id: 'cleanup-nodes', name: 'Cleanup Orphan Nodes', status: 'idle' },
{ id: 'export-all', name: 'Export All Canvases', status: 'idle' }
])
const refreshData = async () => {
try {
const data = await $fetch('/api/admin/stats')
users.value = data.users || []
canvases.value = data.canvases || []
stats.users = data.stats?.users || 0
stats.canvases = data.stats?.canvases || 0
stats.nodes = data.stats?.nodes || 0
stats.pushes = data.stats?.pushes || 0
} catch (e) {
console.error('Failed to load admin data', e)
}
}
const roleBadge = (role) => {
return role === 'admin'
? 'px-2 py-0.5 bg-red-900/50 text-red-300 rounded text-xs'
: 'px-2 py-0.5 bg-slate-700 text-slate-300 rounded text-xs'
}
const statusClass = (status) => {
if (status === 'running') return 'bg-yellow-900/50 text-yellow-300'
if (status === 'completed') return 'bg-emerald-900/50 text-emerald-300'
return 'bg-slate-700 text-slate-400'
}
const formatDate = (date) => {
if (!date) return 'N/A'
return new Date(date).toLocaleDateString('ru-RU')
}
const runScript = async (scriptId) => {
const script = scripts.value.find(s => s.id === scriptId)
if (!script || script.status === 'running') return
script.status = 'running'
try {
await $fetch(`/api/admin/scripts/${scriptId}`, { method: 'POST' })
script.status = 'completed'
} catch (e) {
script.status = 'failed'
}
setTimeout(() => { script.status = 'idle' }, 3000)
}
onMounted(refreshData)
</script>
+16
View File
@@ -0,0 +1,16 @@
<template>
<div class="h-screen w-full">
<ClientOnly>
<FlowEditor :canvas-id="canvasId" />
</ClientOnly>
</div>
</template>
<script setup>
definePageMeta({
middleware: 'auth'
})
const route = useRoute()
const canvasId = computed(() => route.params.id || 'demo')
</script>
+95
View File
@@ -0,0 +1,95 @@
<template>
<div class="min-h-screen bg-slate-950">
<div class="max-w-6xl mx-auto p-6">
<h1 class="text-3xl font-bold text-slate-100 mb-6">Канвасы</h1>
<!-- Canvas Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="canvas in canvases" :key="canvas.id"
class="bg-slate-900 border border-slate-700 rounded-xl p-6 hover:border-blue-500 transition-colors">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-slate-100">{{ canvas.name }}</h3>
<p class="text-sm text-slate-400">{{ canvas.description }}</p>
</div>
<div class="text-xs text-slate-500">
{{ new Date(canvas.updatedAt).toLocaleDateString('ru-RU') }}
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="px-2 py-1 bg-slate-800 text-slate-300 rounded text-xs">
{{ canvas.isPublic ? 'Публичный' : 'Личный' }}
</span>
<span class="px-2 py-1 bg-slate-800 text-slate-300 rounded text-xs">
{{ canvas.nodes?.length || 0 }} узлов
</span>
</div>
<NuxtLink :to="`/canvas/${canvas.id}`"
class="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm transition-colors">
Открыть
</NuxtLink>
</div>
</div>
<!-- Create New Canvas -->
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 border-dashed hover:border-blue-500 transition-colors">
<div class="flex flex-col items-center justify-center h-full min-h-[200px]">
<div class="text-4xl mb-4">+</div>
<h3 class="text-lg font-semibold text-slate-100 mb-2">Создать канвас</h3>
<p class="text-sm text-slate-400 text-center mb-4">
Создайте новую ментальную карту
</p>
<button @click="createNewCanvas"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm transition-colors">
Создать
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useAuth } from '~/composables/useAuth'
const auth = useAuth()
const router = useRouter()
// Mock canvases data - replace with actual API call
const canvases = ref([
{
id: 'xflow-project',
name: 'Обзор Проекта XFlow',
description: 'Основная ментальная карта проекта',
isPublic: true,
updatedAt: new Date().toISOString(),
nodes: 11
},
{
id: 'demo',
name: 'Демо канвас',
description: 'Пример ментальной карты',
isPublic: true,
updatedAt: new Date().toISOString(),
nodes: 5
}
])
const createNewCanvas = () => {
// Redirect to flow editor with new canvas
router.push('/canvas/new-canvas')
}
// Redirect to specific canvas if ID is provided
onMounted(() => {
if (auth.user) {
// Load user's canvases
console.log('Loading canvases for user:', auth.user.email)
}
})
</script>
+255
View File
@@ -0,0 +1,255 @@
<template>
<div class="min-h-screen bg-slate-950">
<div class="max-w-7xl mx-auto p-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-slate-100">Мои Карты</h1>
<button @click="showCreateModal = true" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition">
+ Новая Карта
</button>
</div>
<!-- Tabs -->
<div class="flex gap-1 mb-6 bg-slate-900 rounded-lg p-1 w-fit">
<button @click="activeTab = 'personal'" :class="tabClass('personal')" class="px-4 py-2 rounded text-sm font-medium transition">
Личные
</button>
<button @click="activeTab = 'shared'" :class="tabClass('shared')" class="px-4 py-2 rounded text-sm font-medium transition">
Общие
</button>
<button @click="activeTab = 'public'" :class="tabClass('public')" class="px-4 py-2 rounded text-sm font-medium transition">
Публичные
</button>
</div>
<!-- Canvas Grid -->
<div v-if="filteredCanvases.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="canvas in filteredCanvases" :key="canvas.id"
class="bg-slate-900 border border-slate-700 rounded-xl p-4 hover:border-slate-600 transition cursor-pointer group"
@click="openCanvas(canvas.id)">
<div class="flex items-start justify-between mb-3">
<div>
<h3 class="font-semibold text-slate-200 group-hover:text-blue-400 transition">{{ canvas.name }}</h3>
<p class="text-xs text-slate-500 mt-1">{{ canvas.nodeCount }} nodes • {{ formatDate(canvas.updatedAt) }}</p>
</div>
<div class="flex items-center gap-1">
<span v-if="canvas.isPublic" class="text-xs px-2 py-1 bg-emerald-900/50 text-emerald-300 rounded">Публичная</span>
<span v-else-if="canvas.groupId" class="text-xs px-2 py-1 bg-blue-900/50 text-blue-300 rounded">Общая</span>
<span v-else class="text-xs px-2 py-1 bg-slate-700 text-slate-400 rounded">Приватная</span>
</div>
</div>
<div class="text-xs text-slate-400">
<span v-if="canvas.ownerId === currentUserId">Ваша карта</span>
<span v-else>Поделился {{ getOwnerName(canvas.ownerId) }}</span>
<span v-if="canvas.groupId" class="ml-2">• {{ getGroupName(canvas.groupId) }}</span>
</div>
<div class="mt-3 pt-3 border-t border-slate-800 flex justify-between items-center">
<div class="flex gap-2">
<button @click.stop="duplicateCanvas(canvas.id)" class="text-xs text-slate-400 hover:text-blue-400 transition">
Дублировать
</button>
<button @click.stop="exportCanvas(canvas.id)" class="text-xs text-slate-400 hover:text-emerald-400 transition">
Экспорт
</button>
</div>
<button v-if="canDeleteCanvas(canvas)" @click.stop="deleteCanvas(canvas.id)"
class="text-xs text-slate-400 hover:text-red-400 transition">
Удалить
</button>
</div>
</div>
</div>
<div v-else class="text-center py-12">
<div class="text-slate-500 mb-4">Карты не найдены</div>
<button @click="showCreateModal = true" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition">
Создать первую карту
</button>
</div>
</div>
<!-- Create Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" @click.self="showCreateModal = false">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-md">
<h2 class="text-lg font-semibold text-slate-200 mb-4">Создать Новую Карту</h2>
<form @submit.prevent="createCanvas" class="space-y-4">
<div>
<label class="text-sm text-slate-400 block mb-1">Название Карты</label>
<input v-model="newCanvas.name" required class="w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white" placeholder="Моя Awesome Карта" />
</div>
<div>
<label class="text-sm text-slate-400 block mb-1">Видимость</label>
<select v-model="newCanvas.visibility" class="w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white">
<option value="private">Приватная (только вы)</option>
<option value="public">Публичная (любой может просматривать)</option>
</select>
</div>
<div v-if="userGroups.length > 0">
<label class="text-sm text-slate-400 block mb-1">Поделиться с Группой (опционально)</label>
<select v-model="newCanvas.groupId" class="w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white">
<option value="">Без группы</option>
<option v-for="group in userGroups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</div>
<div class="flex gap-3 pt-2">
<button type="submit" class="flex-1 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition">
Создать
</button>
<button type="button" @click="showCreateModal = false" class="flex-1 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg font-medium transition">
Отмена
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
middleware: 'auth'
})
const auth = useAuth()
const user = computed(() => auth.user)
const authFetch = (url, opts = {}) => $fetch(url, {
...opts,
headers: { Authorization: `Bearer ${auth.token.value}`, ...opts.headers }
})
const canvases = ref([])
const userGroups = ref([])
const showCreateModal = ref(false)
const activeTab = ref('personal')
const newCanvas = reactive({
name: '',
visibility: 'private',
groupId: ''
})
const currentUserId = computed(() => auth.user.value?.id || '')
// Load data
const loadData = async () => {
try {
const [canvasesRes, groupsRes] = await Promise.all([
authFetch('/api/canvas'),
authFetch('/api/user/groups')
])
canvases.value = canvasesRes.canvases || []
userGroups.value = groupsRes.groups || []
} catch (error) {
console.error('Failed to load data:', error)
}
}
// Filter canvases based on active tab
const filteredCanvases = computed(() => {
const userId = currentUserId.value
switch (activeTab.value) {
case 'personal':
return canvases.value.filter(c => !c.groupId)
case 'shared':
return canvases.value.filter(c =>
c.groupId && userGroups.value.some(g => g.id === c.groupId) ||
c.ownerId !== userId && c.isPublic
)
case 'public':
return canvases.value.filter(c => c.isPublic)
default:
return canvases.value
}
})
// Tab styling
const tabClass = (tab) => {
return activeTab.value === tab
? 'bg-slate-800 text-slate-200'
: 'text-slate-400 hover:text-slate-300'
}
// Canvas actions
const openCanvas = (id) => {
navigateTo(`/canvas/${id}`)
}
const createCanvas = async () => {
try {
const payload = {
name: newCanvas.name,
isPublic: newCanvas.visibility === 'public',
groupId: newCanvas.groupId || undefined
}
const result = await authFetch('/api/canvas', { method: 'POST', body: payload })
showCreateModal.value = false
newCanvas.name = ''
newCanvas.visibility = 'private'
newCanvas.groupId = ''
await loadData()
navigateTo(`/canvas/${result.canvas.id}`)
} catch (error) {
console.error('Failed to create canvas:', error)
}
}
const duplicateCanvas = async (id) => {
try {
const result = await authFetch(`/api/canvas/${id}/duplicate`, { method: 'POST' })
await loadData()
} catch (error) {
console.error('Failed to duplicate canvas:', error)
}
}
const exportCanvas = async (id) => {
try {
const canvas = await authFetch(`/api/canvas/${id}`)
const blob = new Blob([JSON.stringify(canvas, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${canvas.name || 'canvas'}.canvas`
a.click()
URL.revokeObjectURL(url)
} catch (error) {
console.error('Failed to export canvas:', error)
}
}
const canDeleteCanvas = (canvas) => {
return canvas.ownerId === currentUserId.value
}
const deleteCanvas = async (id) => {
if (!confirm('Вы уверены, что хотите удалить эту карту?')) return
try {
await authFetch(`/api/canvas/${id}`, { method: 'DELETE' })
await loadData()
} catch (error) {
console.error('Failed to delete canvas:', error)
}
}
// Helper functions
const formatDate = (date) => {
return new Date(date).toLocaleDateString('ru-RU', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const getOwnerName = (ownerId) => {
return ownerId === currentUserId.value ? 'вы' : `Пользователь ${ownerId}`
}
const getGroupName = (groupId) => {
const group = userGroups.value.find(g => g.id === groupId)
return group?.name || 'Unknown Group'
}
onMounted(loadData)
</script>
+13
View File
@@ -0,0 +1,13 @@
<template>
<div class="h-screen w-full">
<ClientOnly>
<FlowEditor canvas-id="demo" />
</ClientOnly>
</div>
</template>
<script setup>
definePageMeta({
middleware: 'auth'
})
</script>
+55
View File
@@ -0,0 +1,55 @@
<template>
<div class="min-h-[calc(100vh-3.5rem)] flex flex-col items-center justify-center p-4">
<div v-if="!isAuthenticated" class="text-center max-w-md">
<h1 class="text-4xl font-bold text-blue-400 mb-4">XFlow</h1>
<p class="text-slate-400 mb-8">Obsidian Canvas PWA. Управляй задачами визуально.</p>
<NuxtLink
to="/login"
class="inline-block px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition"
>
Войти
</NuxtLink>
</div>
<div v-else class="w-full max-w-6xl">
<h1 class="text-2xl font-bold mb-6">Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<NuxtLink
to="/canvas"
class="p-6 bg-slate-800 border border-slate-700 rounded-lg hover:border-blue-500 transition group"
>
<div class="text-3xl mb-2">🗺️</div>
<h3 class="font-semibold group-hover:text-blue-400 transition">Canvas</h3>
<p class="text-sm text-slate-400">Визуальное управление задачами</p>
</NuxtLink>
<div class="p-6 bg-slate-800 border border-slate-700 rounded-lg">
<div class="text-3xl mb-2">✅</div>
<h3 class="font-semibold">Задачи</h3>
<p class="text-sm text-slate-400">{{ completedTasks }} / {{ totalTasks }} выполнено</p>
</div>
<div class="p-6 bg-slate-800 border border-slate-700 rounded-lg">
<div class="text-3xl mb-2">🏆</div>
<h3 class="font-semibold">Ачивки</h3>
<p class="text-sm text-slate-400">{{ achievements }} разблокировано</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
const { isAuthenticated } = useAuth()
const { stats: achievementStats, loadAchievements } = useAchievements()
const completedTasks = computed(() => achievementStats.value.completedTasks)
const totalTasks = computed(() => 3) // mock total from canvas
const achievements = computed(() => achievementStats.value.total)
onMounted(() => {
if (isAuthenticated.value) {
loadAchievements()
}
})
</script>
+54
View File
@@ -0,0 +1,54 @@
<template>
<div class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-sm bg-slate-800 border border-slate-700 rounded-xl p-6 shadow-xl text-center">
<div class="mb-6">
<div class="text-5xl mb-4">🚀</div>
<h1 class="text-2xl font-bold mb-2">XFlow</h1>
<p class="text-slate-400 text-sm">Визуальный менеджер задач</p>
</div>
<button
@click="signInWithGoogle"
:disabled="loading"
class="w-full py-3 bg-white hover:bg-gray-100 text-slate-900 rounded-lg font-medium transition flex items-center justify-center gap-3 disabled:opacity-50"
>
<svg class="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
{{ loading ? 'Вход...' : 'Войти через Google' }}
</button>
<p class="mt-4 text-xs text-slate-500">
Авторизация через Firebase Google Sign-In
</p>
</div>
</div>
</template>
<script setup>
definePageMeta({
layout: 'default'
})
const { googleSignIn, initAuth } = useAuth()
const loading = ref(false)
const signInWithGoogle = async () => {
try {
loading.value = true
await googleSignIn()
} catch (error) {
console.error('Google sign-in failed:', error)
alert('Ошибка входа через Google: ' + (error.message || 'Неизвестная ошибка'))
} finally {
loading.value = false
}
}
onMounted(() => {
initAuth()
})
</script>
+334
View File
@@ -0,0 +1,334 @@
<template>
<div class="min-h-screen bg-slate-950">
<div class="max-w-4xl mx-auto p-6">
<!-- Profile Header -->
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 mb-6">
<div class="flex items-start gap-4">
<img :src="profile.avatar" :alt="profile.displayName"
class="w-20 h-20 rounded-full bg-slate-800" />
<div class="flex-1">
<h1 class="text-2xl font-bold text-slate-100">{{ profile.displayName }}</h1>
<p class="text-slate-400 mt-1">{{ profile.bio }}</p>
<div class="flex items-center gap-4 mt-3 text-sm text-slate-500">
<span>Joined {{ formatDate(profile.joinedAt) }}</span>
<span>•</span>
<span>{{ profile.canvasCount }} canvases</span>
<span>•</span>
<span>{{ profile.followerCount }} followers</span>
<span>•</span>
<span>{{ profile.followingCount }} following</span>
</div>
</div>
<button v-if="!isOwnProfile" @click="toggleFollow"
:class="followButtonClass"
class="px-4 py-2 rounded-lg font-medium transition">
{{ isFollowing ? 'Following' : 'Follow' }}
</button>
</div>
</div>
<!-- Tabs -->
<div class="flex gap-1 mb-6 bg-slate-900 rounded-lg p-1 w-fit">
<button @click="activeTab = 'canvases'" :class="tabClass('canvases')" class="px-4 py-2 rounded text-sm font-medium transition">
Canvases
</button>
<button @click="activeTab = 'activity'" :class="tabClass('activity')" class="px-4 py-2 rounded text-sm font-medium transition">
Activity
</button>
<button v-if="isOwnProfile" @click="activeTab = 'groups'" :class="tabClass('groups')" class="px-4 py-2 rounded text-sm font-medium transition">
Groups
</button>
</div>
<!-- Canvases Tab -->
<div v-if="activeTab === 'canvases'" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="canvas in userCanvases" :key="canvas.id"
class="bg-slate-900 border border-slate-700 rounded-xl p-4 hover:border-slate-600 transition cursor-pointer"
@click="openCanvas(canvas.id)">
<div class="flex items-start justify-between mb-3">
<div>
<h3 class="font-semibold text-slate-200 hover:text-blue-400 transition">{{ canvas.name }}</h3>
<p class="text-xs text-slate-500 mt-1">{{ canvas.nodeCount }} nodes • {{ formatDate(canvas.updatedAt) }}</p>
</div>
<div class="flex items-center gap-1">
<span v-if="canvas.isPublic" class="text-xs px-2 py-1 bg-emerald-900/50 text-emerald-300 rounded">Public</span>
<span v-else-if="canvas.groupId" class="text-xs px-2 py-1 bg-blue-900/50 text-blue-300 rounded">Shared</span>
<span v-else class="text-xs px-2 py-1 bg-slate-700 text-slate-400 rounded">Private</span>
</div>
</div>
<div class="text-xs text-slate-400">
{{ formatDate(canvas.updatedAt) }}
</div>
</div>
</div>
<!-- Activity Tab -->
<div v-if="activeTab === 'activity'" class="space-y-3">
<div v-for="activity in userActivities" :key="activity.id"
class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="flex items-center gap-3">
<div class="w-2 h-2 bg-blue-500 rounded-full"></div>
<div class="flex-1">
<p class="text-sm text-slate-300">
<span class="font-medium">{{ formatActivity(activity.action) }}</span>
<span v-if="activity.targetType === 'canvas'" class="text-blue-400"> canvas</span>
<span v-if="activity.targetType === 'group'" class="text-emerald-400"> group</span>
<span v-if="activity.targetType === 'user'" class="text-purple-400"> user</span>
</p>
<p class="text-xs text-slate-500 mt-1">{{ formatDate(activity.timestamp) }}</p>
</div>
</div>
</div>
</div>
<!-- Groups Tab -->
<div v-if="activeTab === 'groups' && isOwnProfile" class="space-y-6">
<!-- My Groups -->
<div>
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold text-slate-400 uppercase tracking-wider">Мои группы</h2>
<button @click="showCreateGroup = true" class="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs font-medium">
+ Создать группу
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div v-for="group in userGroups" :key="group.id"
class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="flex items-start justify-between mb-2">
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-slate-200 truncate">{{ group.name }}</h3>
<p class="text-xs text-slate-500 mt-0.5">{{ group.description }}</p>
</div>
<span :class="group.membershipRole === 'admin' ? 'bg-purple-900/50 text-purple-300' : 'bg-blue-900/50 text-blue-300'"
class="text-xs px-2 py-1 rounded ml-2 shrink-0">{{ group.membershipRole }}</span>
</div>
<div class="flex items-center justify-between mt-3">
<span class="text-xs text-slate-500">{{ group.isPublic ? '🌐 Публичная' : '🔒 Приватная' }}</span>
<button v-if="group.ownerId !== currentUserId" @click="leaveGroup(group.id)"
class="text-xs text-red-400 hover:text-red-300 transition">Выйти</button>
<button v-else @click="deleteGroup(group.id)"
class="text-xs text-red-400 hover:text-red-300 transition">Удалить</button>
</div>
</div>
<div v-if="userGroups.length === 0" class="text-slate-500 text-sm col-span-2 py-4 text-center">
Вы не состоите ни в одной группе
</div>
</div>
</div>
<!-- All Public Groups -->
<div>
<h2 class="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-3">Все публичные группы</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div v-for="group in availableGroups" :key="group.id"
class="bg-slate-900 border border-slate-700 rounded-xl p-4 flex items-center justify-between">
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-slate-200 truncate">{{ group.name }}</h3>
<p class="text-xs text-slate-500 mt-0.5">{{ group.description }}</p>
</div>
<button v-if="!isMember(group.id)" @click="joinGroup(group.id)"
class="ml-3 px-3 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs shrink-0">Вступить</button>
<span v-else class="ml-3 text-xs text-slate-500 shrink-0">✓ Участник</span>
</div>
</div>
</div>
</div>
<!-- Create Group Modal -->
<div v-if="showCreateGroup" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" @click.self="showCreateGroup = false">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-md">
<h2 class="text-lg font-semibold text-slate-200 mb-4">Создать группу</h2>
<form @submit.prevent="createGroup" class="space-y-4">
<div>
<label class="text-sm text-slate-400 block mb-1">Название *</label>
<input v-model="newGroup.name" required class="w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white" placeholder="Название группы" />
</div>
<div>
<label class="text-sm text-slate-400 block mb-1">Описание</label>
<input v-model="newGroup.description" class="w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white" placeholder="Краткое описание" />
</div>
<div class="flex items-center gap-2">
<input type="checkbox" v-model="newGroup.isPublic" id="isPublic" class="rounded" />
<label for="isPublic" class="text-sm text-slate-300">Публичная (другие могут вступить)</label>
</div>
<div class="flex gap-3 pt-2">
<button type="submit" class="flex-1 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium">Создать</button>
<button type="button" @click="showCreateGroup = false" class="flex-1 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg">Отмена</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
middleware: 'auth'
})
const auth = useAuth()
const route = useRoute()
const authFetch = (url, opts = {}) => $fetch(url, {
...opts,
headers: { Authorization: `Bearer ${auth.token.value}`, ...opts.headers }
})
const profileId = computed(() => route.params.id || auth.user.value?.id)
const currentUserId = computed(() => auth.user.value?.id || '')
const isOwnProfile = computed(() => profileId.value === currentUserId.value)
const profile = ref({
userId: profileId.value,
displayName: 'Loading...',
bio: '',
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${profileId.value}`,
joinedAt: new Date().toISOString(),
canvasCount: 0,
followerCount: 0,
followingCount: 0
})
const userCanvases = ref([])
const userActivities = ref([])
const userGroups = ref([])
const allGroups = ref([])
const isFollowing = ref(false)
const activeTab = ref('canvases')
const showCreateGroup = ref(false)
const newGroup = reactive({ name: '', description: '', isPublic: true })
const availableGroups = computed(() => allGroups.value.filter(g => g.isPublic))
const isMember = (groupId) => userGroups.value.some(g => g.id === groupId)
// Load profile data
const loadProfile = async () => {
try {
const [profileData, canvasesData, activitiesData] = await Promise.all([
authFetch(`/api/users/${profileId.value}/profile`),
authFetch(`/api/users/${profileId.value}/canvases`),
authFetch(`/api/users/${profileId.value}/activity`)
])
profile.value = profileData.profile
userCanvases.value = canvasesData.canvases
userActivities.value = activitiesData.activities
if (isOwnProfile.value) {
const [groupsData, allGroupsData] = await Promise.all([
authFetch('/api/user/groups'),
authFetch('/api/groups')
])
userGroups.value = groupsData.groups
allGroups.value = allGroupsData.groups
}
if (!isOwnProfile.value) {
try {
const followData = await authFetch(`/api/users/${profileId.value}/follow-status`)
isFollowing.value = followData.isFollowing
} catch {}
}
} catch (error) {
console.error('Failed to load profile:', error)
}
}
// Actions
const openCanvas = (id) => {
navigateTo(`/canvas/${id}`)
}
const toggleFollow = async () => {
try {
const method = isFollowing.value ? 'DELETE' : 'POST'
await authFetch(`/api/users/${profileId.value}/follow`, { method })
isFollowing.value = !isFollowing.value
profile.value.followerCount += isFollowing.value ? 1 : -1
} catch (error) {
console.error('Failed to toggle follow:', error)
}
}
const createGroup = async () => {
try {
const res = await authFetch('/api/groups', { method: 'POST', body: { ...newGroup } })
userGroups.value.push({ ...res.group, membershipRole: 'admin' })
allGroups.value.push(res.group)
showCreateGroup.value = false
newGroup.name = ''
newGroup.description = ''
newGroup.isPublic = true
} catch (e) {
console.error('Failed to create group:', e)
}
}
const joinGroup = async (groupId) => {
try {
await authFetch(`/api/groups/${groupId}/join`, { method: 'POST' })
const group = allGroups.value.find(g => g.id === groupId)
if (group) userGroups.value.push({ ...group, membershipRole: 'member' })
} catch (e) {
console.error('Failed to join group:', e)
}
}
const leaveGroup = async (groupId) => {
if (!confirm('Выйти из группы?')) return
try {
await authFetch(`/api/groups/${groupId}/leave`, { method: 'POST' })
userGroups.value = userGroups.value.filter(g => g.id !== groupId)
} catch (e) {
console.error('Failed to leave group:', e)
}
}
const deleteGroup = async (groupId) => {
if (!confirm('Удалить группу? Это действие необратимо.')) return
try {
await authFetch(`/api/groups/${groupId}`, { method: 'DELETE' })
userGroups.value = userGroups.value.filter(g => g.id !== groupId)
allGroups.value = allGroups.value.filter(g => g.id !== groupId)
} catch (e) {
console.error('Failed to delete group:', e)
}
}
// Helper functions
const formatDate = (date) => {
return new Date(date).toLocaleDateString('ru-RU', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
const formatActivity = (action) => {
const actions = {
created_canvas: 'created',
updated_canvas: 'updated',
joined_group: 'joined',
left_group: 'left',
followed_user: 'followed',
unfollowed_user: 'unfollowed'
}
return actions[action] || action
}
const tabClass = (tab) => {
return activeTab.value === tab
? 'bg-slate-800 text-slate-200'
: 'text-slate-400 hover:text-slate-300'
}
const followButtonClass = computed(() => {
return isFollowing.value
? 'bg-slate-700 hover:bg-slate-600 text-white'
: 'bg-blue-600 hover:bg-blue-500 text-white'
})
onMounted(loadProfile)
watch(profileId, loadProfile)
</script>
+104
View File
@@ -0,0 +1,104 @@
<template>
<div class="min-h-screen bg-slate-950">
<div class="max-w-4xl mx-auto p-6">
<!-- Profile Header -->
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 mb-6">
<div class="flex items-start gap-4">
<img :src="profile.avatar" :alt="profile.displayName"
class="w-20 h-20 rounded-full bg-slate-800" />
<div class="flex-1">
<h1 class="text-2xl font-bold text-slate-100">{{ profile.displayName }}</h1>
<p class="text-slate-400">{{ profile.email }}</p>
<p class="text-sm text-slate-500 mt-2">{{ profile.bio }}</p>
</div>
<div class="text-right">
<div class="text-sm text-slate-500">Участник с</div>
<div class="text-slate-300">{{ new Date(profile.createdAt).toLocaleDateString('ru-RU') }}</div>
</div>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-2xl font-bold text-blue-400">{{ stats.canvases }}</div>
<div class="text-sm text-slate-400">Канвасов</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-2xl font-bold text-green-400">{{ stats.nodes }}</div>
<div class="text-sm text-slate-400">Узлов создано</div>
</div>
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
<div class="text-2xl font-bold text-purple-400">{{ stats.groups }}</div>
<div class="text-sm text-slate-400">Групп</div>
</div>
</div>
<!-- Recent Canvases -->
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6">
<h2 class="text-xl font-semibold text-slate-100 mb-4">Последние канвасы</h2>
<div class="space-y-3">
<div v-for="canvas in recentCanvases" :key="canvas.id"
class="flex items-center justify-between p-3 bg-slate-800 rounded-lg">
<div>
<h3 class="font-medium text-slate-100">{{ canvas.name }}</h3>
<p class="text-sm text-slate-400">{{ canvas.description }}</p>
</div>
<NuxtLink :to="`/canvas/${canvas.id}`"
class="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm transition-colors">
Открыть
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useAuth } from '~/composables/useAuth'
const route = useRoute()
const auth = useAuth()
const userId = route.params.id
// Mock profile data - replace with actual API call
const profile = ref({
id: userId,
displayName: 'Пользователь XFlow',
email: 'user@example.com',
bio: 'Люблю создавать ментальные карты и организовывать идеи',
avatar: 'https://via.placeholder.com/80',
createdAt: new Date('2024-01-01').toISOString()
})
const stats = ref({
canvases: 12,
nodes: 156,
groups: 3
})
const recentCanvases = ref([
{
id: 'xflow-project',
name: 'Обзор Проекта XFlow',
description: 'Основная ментальная карта проекта'
},
{
id: 'demo',
name: 'Демо канвас',
description: 'Пример ментальной карты'
},
{
id: 'ideas',
name: 'Идеи 2024',
description: 'Сбор идей и мыслей'
}
])
onMounted(() => {
// Load profile data
console.log('Loading profile for user:', userId)
})
</script>
+96
View File
@@ -0,0 +1,96 @@
<template>
<div class="max-w-xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-6">Настройки</h1>
<div class="space-y-6">
<!-- Push Notifications -->
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span>🔔</span> Push-уведомления
</h2>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-slate-300">Статус поддержки</span>
<span :class="isSupported ? 'text-emerald-400' : 'text-red-400'">
{{ isSupported ? 'Доступно' : 'Недоступно' }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-slate-300">Разрешение</span>
<span :class="permissionGranted ? 'text-emerald-400' : 'text-yellow-400'">
{{ permissionGranted ? 'Разрешено' : 'Не запрошено' }}
</span>
</div>
<button
v-if="isSupported && !permissionGranted"
@click="enablePush"
class="w-full py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition"
>
Включить уведомления
</button>
<button
v-if="isSupported && permissionGranted && !subscription"
@click="subscribe"
class="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg font-medium transition"
>
Подписаться на пуши
</button>
<div v-if="subscription" class="text-sm text-emerald-400">
✅ Подписка активна
</div>
</div>
</div>
<!-- Achievements -->
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
<span>🏆</span> Ачивки
</h2>
<div v-if="achievements.length === 0" class="text-slate-400 text-sm">
Пока нет разблокированных ачивок. Завершите задачи в Canvas!
</div>
<div v-else class="space-y-2">
<div
v-for="ach in achievements"
:key="ach.id"
class="flex items-center gap-3 p-3 bg-slate-700 rounded-lg"
>
<span class="text-xl">{{ ach.icon || '🏆' }}</span>
<div>
<div class="font-medium">{{ ach.title }}</div>
<div class="text-xs text-slate-400">{{ ach.description }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
middleware: 'auth'
})
const { isSupported, subscription, requestPermission, subscribe: pushSubscribe } = usePush()
const { achievements, loadAchievements } = useAchievements()
const permissionGranted = ref(false)
const enablePush = async () => {
const granted = await requestPermission()
permissionGranted.value = granted
if (granted) {
await pushSubscribe()
}
}
const subscribe = async () => {
await pushSubscribe()
}
onMounted(async () => {
permissionGranted.value = Notification.permission === 'granted'
await loadAchievements()
})
</script>
+23
View File
@@ -0,0 +1,23 @@
import { initializeApp, getApps } from 'firebase/app'
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig()
const firebaseConfig = {
apiKey: config.public.firebaseApiKey,
authDomain: config.public.firebaseAuthDomain,
projectId: config.public.firebaseProjectId,
appId: config.public.firebaseAppId,
messagingSenderId: config.public.firebaseMessagingSenderId
}
if (!firebaseConfig.apiKey) {
console.warn('[Firebase] API key not configured. Google Sign-In will not work.')
return
}
if (!getApps().length) {
initializeApp(firebaseConfig)
console.log('[Firebase] Initialized')
}
})
+26
View File
@@ -0,0 +1,26 @@
export default defineNuxtPlugin(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
console.log('[PWA] Service Worker ready:', registration.scope)
})
// Handle push messages when app is open
navigator.serviceWorker.addEventListener('message', event => {
if (event.data?.type === 'PUSH_RECEIVED') {
const { title, body } = event.data.payload || {}
// Local alarm: vibrate and show notification
if (navigator.vibrate) {
navigator.vibrate([200, 100, 200])
}
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(title || 'XFlow Alarm', {
body: body || 'Alarm triggered',
icon: '/pwa-192x192.svg',
badge: '/favicon.svg',
...({ vibrate: [200, 100, 200] } as any)
})
}
}
})
}
})
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#0f172a"/>
<text x="16" y="22" font-size="18" text-anchor="middle" fill="#3b82f6" font-family="system-ui">N</text>
</svg>

After

Width:  |  Height:  |  Size: 252 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#0f172a"/>
<text x="256" y="350" font-size="280" text-anchor="middle" fill="#3b82f6" font-family="system-ui" font-weight="bold">N</text>
</svg>

After

Width:  |  Height:  |  Size: 273 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
<rect width="192" height="192" rx="24" fill="#0f172a"/>
<text x="96" y="128" font-size="96" text-anchor="middle" fill="#3b82f6" font-family="system-ui" font-weight="bold">N</text>
</svg>

After

Width:  |  Height:  |  Size: 279 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="64" fill="#0f172a"/>
<text x="256" y="350" font-size="280" text-anchor="middle" fill="#3b82f6" font-family="system-ui" font-weight="bold">N</text>
</svg>

After

Width:  |  Height:  |  Size: 281 B

+18
View File
@@ -0,0 +1,18 @@
import { requireAuth } from '~/server/utils/auth'
import { getUserAchievements, achievements } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
// Calculate stats
const userAchievements = getUserAchievements(user.id)
const totalCompleted = userAchievements.filter((a: any) => a.id?.startsWith('completed-')).length
return {
achievements: userAchievements,
stats: {
total: userAchievements.length,
completedTasks: totalCompleted
}
}
})
+28
View File
@@ -0,0 +1,28 @@
import { requireAuth } from '~/server/utils/auth'
import { users, canvases, pushSubscriptions } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
if (user.role !== 'admin') {
throw createError({ statusCode: 403, message: 'Admin access required' })
}
const usersList = Array.from(users.values())
const canvasesList = Array.from(canvases.values()).map(c => ({
id: c.id,
name: c.name,
nodeCount: c.nodes.length,
updatedAt: c.updatedAt
}))
return {
users: usersList,
canvases: canvasesList,
stats: {
users: usersList.length,
canvases: canvasesList.length,
nodes: canvasesList.reduce((sum, c) => sum + c.nodeCount, 0),
pushes: pushSubscriptions.size
}
}
})
+85
View File
@@ -0,0 +1,85 @@
import { generateToken } from '~/server/utils/auth'
import { users } from '~/server/utils/db'
interface FirebaseUser {
localId: string
email: string
displayName?: string
photoUrl?: string
}
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { idToken } = body
if (!idToken) {
throw createError({ statusCode: 400, message: 'Firebase ID token required' })
}
const config = useRuntimeConfig()
const apiKey = config.firebaseApiKey || config.public.firebaseApiKey
if (!apiKey) {
throw createError({ statusCode: 500, message: 'Firebase API key not configured' })
}
// Verify token via Firebase Auth REST API
const verifyRes = await $fetch<any>(
`https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=${apiKey}`,
{
method: 'POST',
body: { idToken }
}
).catch(() => null)
if (!verifyRes?.users?.[0]) {
throw createError({ statusCode: 401, message: 'Invalid Firebase token' })
}
const fbUser: FirebaseUser = verifyRes.users[0]
const email = fbUser.email
const existing = users.get(email)
let role = 'user'
let userId = fbUser.localId
if (existing) {
// Update existing
users.set(email, {
...existing,
id: userId,
email,
username: fbUser.displayName || email.split('@')[0]
})
role = existing.role || 'user'
} else {
// Create new user
users.set(email, {
id: userId,
email,
username: fbUser.displayName || email.split('@')[0],
password: 'firebase-managed',
role: 'user'
})
}
const token = generateToken({
id: userId,
email,
username: fbUser.displayName || email.split('@')[0],
role
})
return {
message: 'Google sign-in successful',
token,
user: {
id: userId,
email,
username: fbUser.displayName || email.split('@')[0],
role,
photoUrl: fbUser.photoUrl
}
}
})
+17
View File
@@ -0,0 +1,17 @@
import { verifyToken } from '~/server/utils/auth'
export default defineEventHandler(async (event) => {
const authHeader = getHeader(event, 'authorization')
const token = authHeader?.split(' ')[1]
if (!token) {
return { valid: false, error: 'No token provided' }
}
try {
const decoded = verifyToken(token)
return { valid: true, user: decoded }
} catch {
return { valid: false, error: 'Invalid token' }
}
})
+15
View File
@@ -0,0 +1,15 @@
import { requireAuth } from '~/server/utils/auth'
import { canvases } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
requireAuth(event)
const id = getRouterParam(event, 'id')
if (!canvases.has(id || '')) {
throw createError({ statusCode: 404, message: 'Canvas not found' })
}
canvases.delete(id || '')
return { success: true, message: 'Canvas deleted' }
})
+62
View File
@@ -0,0 +1,62 @@
import { requireAuth } from '~/server/utils/auth'
import { canvases, userGroups } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({ statusCode: 400, message: 'Canvas ID is required' })
}
const canvas = canvases.get(id)
if (!canvas) {
throw createError({ statusCode: 404, message: 'Canvas not found' })
}
// Check if canvas is public
if (canvas.isPublic) {
return {
id: canvas.id,
name: canvas.name,
nodes: canvas.nodes,
edges: canvas.edges,
isPublic: true,
ownerId: canvas.ownerId
}
}
// For private canvases, require authentication and check permissions
const user = requireAuth(event)
// Check if user owns the canvas
if (canvas.ownerId === user.id) {
return {
id: canvas.id,
name: canvas.name,
nodes: canvas.nodes,
edges: canvas.edges,
isPublic: false,
ownerId: canvas.ownerId
}
}
// Check if user has access through group membership
if (canvas.groupId) {
const membership = Array.from(userGroups.values()).find(
m => m.userId === user.id && m.groupId === canvas.groupId
)
if (membership) {
return {
id: canvas.id,
name: canvas.name,
nodes: canvas.nodes,
edges: canvas.edges,
isPublic: false,
ownerId: canvas.ownerId,
groupId: canvas.groupId
}
}
}
throw createError({ statusCode: 403, message: 'Access denied' })
})
+24
View File
@@ -0,0 +1,24 @@
import { requireAuth } from '~/server/utils/auth'
import { canvases } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
requireAuth(event)
const id = getRouterParam(event, 'id')
const body = await readBody(event)
const existing = canvases.get(id || '')
if (!existing) {
throw createError({ statusCode: 404, message: 'Canvas not found' })
}
const updated = {
...existing,
...body,
id: existing.id,
updatedAt: new Date().toISOString()
}
canvases.set(id || '', updated)
return { canvas: updated }
})
@@ -0,0 +1,46 @@
import { requireAuth } from '~/server/utils/auth'
import { canvases } from '~/server/utils/db'
import type { CanvasData } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({ statusCode: 400, message: 'Canvas ID is required' })
}
const original = canvases.get(id)
if (!original) {
throw createError({ statusCode: 404, message: 'Canvas not found' })
}
// Check permissions
if (original.ownerId !== user.id && !original.isPublic) {
throw createError({ statusCode: 403, message: 'Access denied' })
}
const newId = `canvas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const duplicate: CanvasData = {
...original,
id: newId,
name: `${original.name} (Copy)`,
ownerId: user.id,
groupId: undefined, // Duplicates are personal
isPublic: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
nodes: original.nodes.map(node => ({
...node,
id: `${node.id}-copy-${Date.now()}`
})),
edges: original.edges.map(edge => ({
...edge,
id: `${edge.id}-copy-${Date.now()}`
}))
}
canvases.set(newId, duplicate)
return { canvas: duplicate }
})
+34
View File
@@ -0,0 +1,34 @@
import { requireAuth } from '~/server/utils/auth'
import { canvases, userGroups } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
// Get all canvases the user can access
const userGroupIds = Array.from(userGroups.values())
.filter(membership => membership.userId === user.id)
.map(membership => membership.groupId)
const accessibleCanvases = Array.from(canvases.values()).filter(canvas => {
// User can see their own canvases
if (canvas.ownerId === user.id) return true
// User can see public canvases
if (canvas.isPublic) return true
// User can see group canvases they're a member of
if (canvas.groupId && userGroupIds.includes(canvas.groupId)) return true
return false
})
const canvasList = accessibleCanvases.map(canvas => ({
id: canvas.id,
name: canvas.name,
ownerId: canvas.ownerId,
createdAt: canvas.createdAt,
updatedAt: canvas.updatedAt,
isPublic: canvas.isPublic,
groupId: canvas.groupId,
nodeCount: canvas.nodes.length
}))
return { canvases: canvasList }
})
+36
View File
@@ -0,0 +1,36 @@
import { requireAuth } from '~/server/utils/auth'
import { canvases, userGroups } from '~/server/utils/db'
import type { CanvasData } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { name = 'New Canvas', nodes = [], edges = [], isPublic = false, groupId } = body
// Check group permissions if groupId is provided
if (groupId) {
const membership = Array.from(userGroups.values()).find(
m => m.userId === user.id && m.groupId === groupId
)
if (!membership) {
throw createError({ statusCode: 403, message: 'Not a member of this group' })
}
}
const id = `canvas-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const canvas: CanvasData = {
id,
name,
nodes,
edges,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
ownerId: user.id,
groupId,
isPublic
}
canvases.set(id, canvas)
return { canvas }
})
+36
View File
@@ -0,0 +1,36 @@
import { requireAuth } from '~/server/utils/auth'
import { saveCanvasToGitea } from '~/server/utils/giteaApi'
import { canvases } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { canvasId, autoSync = true } = body
if (!canvasId) {
throw createError({ statusCode: 400, message: 'Canvas ID is required' })
}
// Check if canvas exists and user has access
const canvas = canvases.get(canvasId)
if (!canvas) {
throw createError({ statusCode: 404, message: 'Canvas not found' })
}
// Check permissions
if (canvas.ownerId !== user.id && !canvas.isPublic) {
throw createError({ statusCode: 403, message: 'Access denied' })
}
try {
const ok = await saveCanvasToGitea(canvasId, canvas, `Auto-sync: ${canvas.name}`)
return {
success: ok,
message: ok ? 'Canvas synchronized with Gitea' : 'Gitea sync failed',
autoSyncEnabled: autoSync
}
} catch (error) {
console.error('Failed to sync canvas to Gitea:', error)
throw createError({ statusCode: 500, message: 'Failed to sync canvas to Gitea' })
}
})
+56
View File
@@ -0,0 +1,56 @@
import { requireAuth } from '~/server/utils/auth'
import { saveCanvasToGitea, loadCanvasFromGitea } from '~/server/utils/giteaApi'
import { canvases } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { canvasId, action, commitMessage } = body
if (!canvasId || !action) {
throw createError({ statusCode: 400, message: 'Canvas ID and action are required' })
}
try {
switch (action) {
case 'init':
case 'sync': {
const canvas = canvases.get(canvasId)
if (!canvas) {
throw createError({ statusCode: 404, message: 'Canvas not found' })
}
if (canvas.ownerId !== user.id && !canvas.isPublic) {
throw createError({ statusCode: 403, message: 'Access denied' })
}
const ok = await saveCanvasToGitea(canvasId, canvas, commitMessage)
return {
success: ok,
message: ok
? `Canvas ${canvasId} saved to Gitea`
: `Failed to save canvas ${canvasId} to Gitea`
}
}
case 'load': {
const gitCanvas = await loadCanvasFromGitea(canvasId)
if (!gitCanvas) {
return { success: false, message: 'Canvas not found in Gitea', needsInit: true }
}
const existing = canvases.get(canvasId)
if (existing) {
canvases.set(canvasId, { ...existing, nodes: gitCanvas.nodes || [], edges: gitCanvas.edges || [] })
}
return { success: true, canvas: gitCanvas, message: `Canvas ${canvasId} loaded from Gitea` }
}
default:
throw createError({ statusCode: 400, message: 'Invalid action' })
}
} catch (error: any) {
console.error('Gitea sync error:', error)
throw createError({ statusCode: 500, message: error?.message || 'Gitea operation failed' })
}
})
+47
View File
@@ -0,0 +1,47 @@
import { requireAuth } from '~/server/utils/auth'
import { saveCanvasToGitea } from '~/server/utils/giteaApi'
import { canvases, userGroups } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { canvasId, type } = body
if (!canvasId || !type) {
throw createError({ statusCode: 400, message: 'Canvas ID and repository type are required' })
}
// Check if canvas exists and user has access
const canvas = canvases.get(canvasId)
if (!canvas) {
throw createError({ statusCode: 404, message: 'Canvas not found' })
}
if (canvas.ownerId !== user.id && !canvas.isPublic) {
// Check group access
if (canvas.groupId) {
const membership = Array.from(userGroups.values()).find(
m => m.userId === user.id && m.groupId === canvas.groupId
)
if (!membership) {
throw createError({ statusCode: 403, message: 'Access denied' })
}
} else {
throw createError({ statusCode: 403, message: 'Access denied' })
}
}
try {
// Initial save to Gitea creates the canvas file in the repo
const ok = await saveCanvasToGitea(canvasId, canvas, `Init canvas ${canvasId}`)
return {
success: ok,
message: ok
? `Canvas ${canvasId} initialized in Gitea`
: `Failed to initialize canvas ${canvasId} in Gitea`
}
} catch (error) {
console.error('Failed to initialize canvas in Gitea:', error)
throw createError({ statusCode: 500, message: 'Failed to initialize canvas in Gitea' })
}
})
+23
View File
@@ -0,0 +1,23 @@
import { requireAuth } from '~/server/utils/auth'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { canvasId, action, commitMessage } = body
if (!canvasId || !action) {
throw createError({ statusCode: 400, message: 'Canvas ID and action are required' })
}
try {
// Git sync logic will be implemented here
// For now, return success response
return {
success: true,
message: `Git ${action} completed for canvas ${canvasId}`,
commitMessage: commitMessage || `Auto-sync canvas ${canvasId}`
}
} catch (error) {
throw createError({ statusCode: 500, message: 'Git sync failed' })
}
})
+45
View File
@@ -0,0 +1,45 @@
// Test endpoint: GET /api/git/test
// Verifies Gitea connection and repo access
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const { giteaUrl, giteaToken, giteaOrg, giteaMindmapRepo } = config
if (!giteaUrl || !giteaToken) {
return { success: false, message: 'GITEA_URL or GITEA_TOKEN not set in .env' }
}
try {
// Test 1: check repo exists
const repo = await $fetch<any>(`${giteaUrl}/api/v1/repos/${giteaOrg}/${giteaMindmapRepo}`, {
headers: { 'Authorization': `token ${giteaToken}` }
})
// Test 2: check/create canvases folder by listing contents
let folderExists = false
try {
await $fetch(`${giteaUrl}/api/v1/repos/${giteaOrg}/${giteaMindmapRepo}/contents/canvases`, {
headers: { 'Authorization': `token ${giteaToken}` }
})
folderExists = true
} catch {
folderExists = false
}
return {
success: true,
repo: repo.full_name,
private: repo.private,
folderExists,
message: folderExists
? '✅ Gitea connected, canvases/ folder exists'
: '✅ Gitea connected, canvases/ folder will be created on first save'
}
} catch (e: any) {
return {
success: false,
message: `❌ Gitea connection failed: ${e?.message || e}`,
hint: 'Check GITEA_URL, GITEA_TOKEN, GITEA_ORG, GITEA_MINDMAP_REPO in .env'
}
}
})
+20
View File
@@ -0,0 +1,20 @@
import { requireAuth } from '~/server/utils/auth'
import { groups, userGroups } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const groupId = getRouterParam(event, 'id')!
const group = groups.get(groupId)
if (!group) throw createError({ statusCode: 404, message: 'Group not found' })
if (group.ownerId !== user.id && user.role !== 'admin') {
throw createError({ statusCode: 403, message: 'Only owner can delete group' })
}
groups.delete(groupId)
for (const [key] of userGroups) {
if (key.endsWith(`-${groupId}`)) userGroups.delete(key)
}
return { success: true }
})
+17
View File
@@ -0,0 +1,17 @@
import { requireAuth } from '~/server/utils/auth'
import { groups, userGroups } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const groupId = getRouterParam(event, 'id')!
const group = groups.get(groupId)
if (!group) throw createError({ statusCode: 404, message: 'Group not found' })
if (!group.isPublic) throw createError({ statusCode: 403, message: 'Group is private' })
const key = `${user.id}-${groupId}`
if (userGroups.has(key)) return { message: 'Already a member' }
userGroups.set(key, { userId: user.id, groupId, role: 'member', joinedAt: new Date().toISOString() })
return { success: true, message: `Joined group "${group.name}"` }
})
+17
View File
@@ -0,0 +1,17 @@
import { requireAuth } from '~/server/utils/auth'
import { groups, userGroups } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const groupId = getRouterParam(event, 'id')!
const group = groups.get(groupId)
if (!group) throw createError({ statusCode: 404, message: 'Group not found' })
if (group.ownerId === user.id) throw createError({ statusCode: 400, message: 'Owner cannot leave group. Delete it instead.' })
const key = `${user.id}-${groupId}`
if (!userGroups.has(key)) throw createError({ statusCode: 400, message: 'Not a member' })
userGroups.delete(key)
return { success: true, message: `Left group "${group.name}"` }
})
+8
View File
@@ -0,0 +1,8 @@
import { requireAuth } from '~/server/utils/auth'
import { groups } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
requireAuth(event)
const allGroups = Array.from(groups.values()).filter(g => g.isPublic)
return { groups: allGroups }
})
+27
View File
@@ -0,0 +1,27 @@
import { requireAuth } from '~/server/utils/auth'
import { groups, userGroups } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { name, description = '', isPublic = true } = body
if (!name?.trim()) {
throw createError({ statusCode: 400, message: 'Group name is required' })
}
const id = `group-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
const now = new Date().toISOString()
const group = { id, name: name.trim(), description, isPublic, createdAt: now, updatedAt: now, ownerId: user.id }
groups.set(id, group)
userGroups.set(`${user.id}-${id}`, {
userId: user.id,
groupId: id,
role: 'admin',
joinedAt: now
})
return { group }
})
+26
View File
@@ -0,0 +1,26 @@
import { requireAuth } from '~/server/utils/auth'
import { nodes, addAchievement } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const id = getRouterParam(event, 'id')
const body = await readBody(event)
const existing = nodes.get(id || '') || { id, state: 'pending', assigned_to: user.id }
// Update state
const updated = { ...existing, ...body, updatedAt: new Date().toISOString() }
nodes.set(id || '', updated)
// Gamification: achievement on completion
if (body.state === 'completed' && existing.state !== 'completed') {
addAchievement(user.id, {
id: `completed-${id}`,
title: 'Задача выполнена',
description: `Нода ${id} завершена`,
icon: '✅'
})
}
return { node: updated }
})
+14
View File
@@ -0,0 +1,14 @@
import { requireAuth } from '~/server/utils/auth'
import { nodes } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
// RBAC: filter nodes by assigned_to or role
const allNodes = Array.from(nodes.values())
const filtered = user.role === 'admin'
? allNodes
: allNodes.filter((n: any) => n.assigned_to === user.id || n.assigned_to === 'all')
return { nodes: filtered }
})
+21
View File
@@ -0,0 +1,21 @@
import { requireRole } from '~/server/utils/auth'
import { pushSubscriptions } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
requireRole(event, 'admin')
const body = await readBody(event)
const { title, message, userId } = body
const targets = userId
? [pushSubscriptions.get(userId)].filter(Boolean)
: Array.from(pushSubscriptions.values())
// In production, use web-push library with VAPID keys
const results = targets.map((sub: any) => ({
userId: sub.userId,
sent: true,
notification: { title, message, timestamp: new Date().toISOString() }
}))
return { sent: results.length, results }
})
+16
View File
@@ -0,0 +1,16 @@
import { requireAuth } from '~/server/utils/auth'
import { pushSubscriptions } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
const body = await readBody(event)
const { subscription } = body
if (!subscription?.endpoint) {
throw createError({ statusCode: 400, message: 'Invalid subscription' })
}
pushSubscriptions.set(user.id, { userId: user.id, subscription, createdAt: new Date().toISOString() })
return { success: true }
})
+18
View File
@@ -0,0 +1,18 @@
import { requireAuth } from '~/server/utils/auth'
import { userGroups, groups } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const user = requireAuth(event)
// Get all groups the user is a member of
const userGroupMemberships = Array.from(userGroups.values()).filter(
membership => membership.userId === user.id
)
const userGroupsData = userGroupMemberships.map(membership => {
const group = groups.get(membership.groupId)
return group ? { ...group, membershipRole: membership.role } : null
}).filter(Boolean)
return { groups: userGroupsData }
})
+17
View File
@@ -0,0 +1,17 @@
import { activities } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const userId = getRouterParam(event, 'id')
if (!userId) {
throw createError({ statusCode: 400, message: 'User ID is required' })
}
// Get user's recent activities
const userActivities = Array.from(activities.values())
.filter(activity => activity.userId === userId)
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 50) // Limit to 50 most recent
return { activities: userActivities }
})
+36
View File
@@ -0,0 +1,36 @@
import { canvases, userGroups } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const userId = getRouterParam(event, 'id')
if (!userId) {
throw createError({ statusCode: 400, message: 'User ID is required' })
}
// Get user's group memberships
const userGroupIds = Array.from(userGroups.values())
.filter(membership => membership.userId === userId)
.map(membership => membership.groupId)
// Get canvases owned by user or shared with user's groups
const userCanvases = Array.from(canvases.values()).filter(canvas => {
// User's own canvases
if (canvas.ownerId === userId) return true
// Group canvases
if (canvas.groupId && userGroupIds.includes(canvas.groupId)) return true
return false
})
const canvasList = userCanvases.map(canvas => ({
id: canvas.id,
name: canvas.name,
ownerId: canvas.ownerId,
createdAt: canvas.createdAt,
updatedAt: canvas.updatedAt,
isPublic: canvas.isPublic,
groupId: canvas.groupId,
nodeCount: canvas.nodes.length
}))
return { canvases: canvasList }
})
+40
View File
@@ -0,0 +1,40 @@
import { profiles, canvases, follows } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const userId = getRouterParam(event, 'id')
if (!userId) {
throw createError({ statusCode: 400, message: 'User ID is required' })
}
const profile = profiles.get(userId)
if (!profile) {
throw createError({ statusCode: 404, message: 'Profile not found' })
}
// Count user's canvases
const userCanvases = Array.from(canvases.values()).filter(
canvas => canvas.ownerId === userId
)
// Count followers and following
const followers = Array.from(follows.values()).filter(
follow => follow.followingId === userId
).length
const following = Array.from(follows.values()).filter(
follow => follow.followerId === userId
).length
// Update profile with current stats
const updatedProfile = {
...profile,
canvasCount: userCanvases.length,
followerCount: followers,
followingCount: following
}
profiles.set(userId, updatedProfile)
return { profile: updatedProfile }
})
+50
View File
@@ -0,0 +1,50 @@
import jwt from 'jsonwebtoken'
import { H3Event, createError } from 'h3'
const JWT_SECRET = process.env.NUXT_JWT_SECRET || 'your-super-secret-jwt-key'
export interface AuthUser {
id: string
email: string
username: string
role?: string
}
export function generateToken(user: AuthUser): string {
return jwt.sign(
{ id: user.id, email: user.email, username: user.username, role: user.role || 'user' },
JWT_SECRET,
{ expiresIn: '7d' }
)
}
export function verifyToken(token: string): AuthUser {
return jwt.verify(token, JWT_SECRET) as AuthUser
}
export function getAuthUser(event: H3Event): AuthUser | null {
const authHeader = getHeader(event, 'authorization')
const token = authHeader?.split(' ')[1]
if (!token) return null
try {
return verifyToken(token)
} catch {
return null
}
}
export function requireAuth(event: H3Event): AuthUser {
const user = getAuthUser(event)
if (!user) {
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
return user
}
export function requireRole(event: H3Event, role: string): AuthUser {
const user = requireAuth(event)
if (user.role !== role) {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
return user
}
+403
View File
@@ -0,0 +1,403 @@
import type { AuthUser } from './auth'
export interface CanvasNode {
id: string
type: string
x: number
y: number
width: number
height: number
text?: string
file?: string
frontmatter?: Record<string, any>
state?: 'pending' | 'in_progress' | 'completed'
label?: string
assigned_to?: string
alarm?: string
script_id?: string
prompt?: string
url?: string
method?: string
repo?: string
branch?: string
message?: string
style?: Record<string, string>
}
export interface CanvasEdge {
id: string
fromNode: string
fromSide: string
toNode: string
toSide: string
}
export interface CanvasData {
id: string
name: string
nodes: CanvasNode[]
edges: CanvasEdge[]
createdAt: string
updatedAt: string
ownerId: string
groupId?: string
isPublic?: boolean
}
export interface UserGroup {
userId: string
groupId: string
role: 'admin' | 'member' | 'viewer'
joinedAt: string
}
export interface Group {
id: string
name: string
description: string
isPublic: boolean
createdAt: string
updatedAt: string
ownerId: string
}
export interface Profile {
userId: string
displayName: string
bio: string
avatar: string
joinedAt: string
canvasCount: number
followerCount: number
followingCount: number
}
export interface Activity {
id: string
userId: string
action: string
targetId: string
targetType: string
timestamp: string
metadata?: Record<string, any>
}
// In-memory stores
export const users = new Map<string, { password: string } & AuthUser>()
export const nodes = new Map<string, any>()
export const achievements = new Map<string, any[]>()
export const pushSubscriptions = new Map<string, any>()
export const canvases = new Map<string, CanvasData>()
export const userGroups = new Map<string, UserGroup>()
export const groups = new Map<string, Group>()
export const profiles = new Map<string, Profile>()
export const activities = new Map<string, Activity>()
export const follows = new Map<string, { followerId: string, followingId: string, since: string }>()
// Seed admins
const seedAdmins = [
{ id: '1', email: 'admin@xflow.local', username: 'admin' },
{ id: '2', email: 'almalexyz@gmail.com', username: 'almalexyz' },
{ id: '3', email: 'ormix16@gmail.com', username: 'ormix16' }
]
for (const admin of seedAdmins) {
users.set(admin.email, {
...admin,
password: 'admin123',
role: 'admin'
})
// Create profiles for admins
profiles.set(admin.id, {
userId: admin.id,
displayName: admin.username,
bio: 'XFlow Administrator',
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${admin.id}`,
joinedAt: new Date().toISOString(),
canvasCount: 0,
followerCount: 0,
followingCount: 0
})
}
// Create demo groups
const demoGroups: Group[] = [
{ id: 'group-1', name: 'XFlow Team', description: 'Core development team', isPublic: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ownerId: '1' },
{ id: 'group-2', name: 'Beta Testers', description: 'Early access users', isPublic: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ownerId: '1' },
{ id: 'group-3', name: 'AI Research', description: 'AI/LLM integration research', isPublic: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ownerId: '2' }
]
demoGroups.forEach(group => {
groups.set(group.id, group)
// Add admins to groups
seedAdmins.forEach(admin => {
userGroups.set(`${admin.id}-${group.id}`, {
userId: admin.id,
groupId: group.id,
role: admin.id === '1' ? 'admin' : 'member',
joinedAt: new Date().toISOString()
})
})
})
// Seed comprehensive XFlow project mind map
const xflowProjectCanvas: CanvasData = {
id: 'xflow-project',
name: 'Обзор Проекта XFlow',
nodes: [
// Core Architecture
{
id: 'arch-1',
type: 'text',
x: 100,
y: 100,
width: 320,
height: 180,
text: '# 🏗️ Основная Архитектура\n\n- **Nuxt 3** - Vue 3 фреймворк\n- **TailwindCSS** - Стилизация\n- **Vue Flow** - Движок канваса\n- **Firebase** - Аутентификация\n- **In-memory DB** - Хранение данных\n- **PWA** - Progressive Web App',
style: { background: '#1e293b', border: '#3b82f6' }
},
// Frontend Components
{
id: 'fe-1',
type: 'text',
x: 500,
y: 50,
width: 280,
height: 160,
text: '# 🎨 Фронтенд\n\n- **FlowEditor.vue** - Основной редактор\n- **FlowNodeRenderer.vue** - Отображение узлов\n- **CanvasViewer.vue** - Устаревший просмотр\n- **Админ Панель** - Управление интерфейсом\n- **Система Аутентификации** - Вход через Google',
style: { background: '#1e293b', border: '#10b981' }
},
// Backend APIs
{
id: 'be-1',
type: 'text',
x: 500,
y: 250,
width: 280,
height: 160,
text: '# ⚙️ Backend API\n\n- `/api/canvas/*` - CRUD операций с канвасами\n- `/api/nodes/*` - Управление узлами\n- `/api/auth/*` - Аутентификация\n- `/api/admin/*` - Административные операции\n- `/api/push/*` - Уведомления',
style: { background: '#1e293b', border: '#f59e0b' }
},
// Node Types
{
id: 'nodes-1',
type: 'text',
x: 100,
y: 320,
width: 320,
height: 200,
text: '# 📦 Типы Узлов\n\n- **Текстовая Заметка** - Markdown контент\n- **Ссылка на Файл** - Ссылки на файлы\n- **Узел Задачи** - Отслеживание состояния\n- **AI/LLM Узел** - AI обработка\n- **Webhook Узел** - HTTP триггеры\n- **Git Sync Узел** - Контроль версий',
style: { background: '#1e293b', border: '#8b5cf6' }
},
// Features
{
id: 'feat-1',
type: 'text',
x: 850,
y: 150,
width: 300,
height: 220,
text: '# ✨ Ключевые Функции\n\n- **Редактор в стиле Node-RED**\n - Палитра сбоку\n - Панель свойств\n - Drag & drop\n - Соединения проводами\n\n- **Совместимость с Obsidian**\n - Экспорт в .canvas формат\n - Импорт существующих карт\n - Поддержка Markdown\n\n- **Социальные Функции**\n - Группы пользователей\n - Общие канвасы\n - Лента активности',
style: { background: '#1e293b', border: '#ef4444' }
},
// Tech Stack
{
id: 'tech-1',
type: 'text',
x: 100,
y: 560,
width: 320,
height: 180,
text: '# 🛠️ Технологический Стек\n\n```json\n{\n "frontend": "Vue 3 + Nuxt 3",\n "styling": "TailwindCSS",\n "canvas": "Vue Flow",\n "auth": "Firebase Google",\n "storage": "In-memory",\n "pwa": "@vite-pwa/nuxt"\n}\n```',
style: { background: '#1e293b', border: '#06b6d4' }
},
// Future Plans
{
id: 'future-1',
type: 'text',
x: 500,
y: 450,
width: 280,
height: 180,
text: '# 🚀 Будущее Развитие\n\n- **Git Интеграция**\n - Commit/push/pull карт\n - История версий\n - Управление ветками\n\n- **AI Интеграция**\n - OpenClaw агенты\n - Автоматизация LLM узлов\n - Умные предложения\n\n- **Социальная Сеть**\n - Профили пользователей\n - Система подписок\n - Ленты активности',
style: { background: '#1e293b', border: '#ec4899' }
},
// Database Schema
{
id: 'db-1',
type: 'file',
x: 850,
y: 400,
width: 300,
height: 160,
file: 'app/server/utils/db.ts',
text: '## Схема Базы Данных\n\n- `users` - Пользовательские аккаунты\n- `canvases` - Ментальные карты\n- `nodes` - Отдельные узлы\n- `groups` - Группы пользователей\n- `profiles` - Профили пользователей\n- `activities` - Лента активности',
style: { background: '#1e293b', border: '#84cc16' }
},
// Deployment
{
id: 'deploy-1',
type: 'text',
x: 850,
y: 590,
width: 300,
height: 140,
text: '# 🚀 Развертывание\n\n- **Docker Compose**\n- **Nginx** - Обратный прокси\n- **Gitea** - Git сервер\n- **PostgreSQL** - База данных\n- **Certbot** - SSL сертификаты',
style: { background: '#1e293b', border: '#f97316' }
},
// Social Features
{
id: 'social-1',
type: 'text',
x: 100,
y: 780,
width: 320,
height: 160,
text: '# 👥 Social Network\n\n- **User Profiles**\n - Avatar & bio\n - Canvas statistics\n - Following/followers\n\n- **Groups System**\n - Public/private groups\n - Role-based permissions\n - Shared canvases',
style: { background: '#1e293b', border: '#a855f7' }
},
// File Structure
{
id: 'files-1',
type: 'file',
x: 500,
y: 660,
width: 280,
height: 140,
file: 'project-structure.md',
text: '## Project Structure\n\n```\napp/\n├── components/\n│ ├── FlowEditor.vue\n│ └── FlowNodeRenderer.vue\n├── pages/\n│ ├── canvas.vue\n│ └── admin.vue\n└── server/api/\n```',
style: { background: '#1e293b', border: '#0ea5e9' }
}
],
edges: [
{ id: 'e1', fromNode: 'arch-1', fromSide: 'right', toNode: 'fe-1', toSide: 'left' },
{ id: 'e2', fromNode: 'arch-1', fromSide: 'right', toNode: 'be-1', toSide: 'left' },
{ id: 'e3', fromNode: 'arch-1', fromSide: 'bottom', toNode: 'nodes-1', toSide: 'top' },
{ id: 'e4', fromNode: 'fe-1', fromSide: 'right', toNode: 'feat-1', toSide: 'left' },
{ id: 'e5', fromNode: 'be-1', fromSide: 'right', toNode: 'feat-1', toSide: 'left' },
{ id: 'e6', fromNode: 'nodes-1', fromSide: 'bottom', toNode: 'tech-1', toSide: 'top' },
{ id: 'e7', fromNode: 'be-1', fromSide: 'bottom', toNode: 'future-1', toSide: 'top' },
{ id: 'e8', fromNode: 'feat-1', fromSide: 'bottom', toNode: 'db-1', toSide: 'top' },
{ id: 'e9', fromNode: 'db-1', fromSide: 'bottom', toNode: 'deploy-1', toSide: 'top' },
{ id: 'e10', fromNode: 'tech-1', fromSide: 'right', toNode: 'future-1', toSide: 'left' },
{ id: 'e11', fromNode: 'tech-1', fromSide: 'bottom', toNode: 'social-1', toSide: 'top' },
{ id: 'e12', fromNode: 'future-1', fromSide: 'bottom', toNode: 'files-1', toSide: 'top' },
{ id: 'e13', fromNode: 'social-1', fromSide: 'right', toNode: 'files-1', toSide: 'left' }
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
ownerId: '1',
isPublic: true
}
canvases.set('xflow-project', xflowProjectCanvas)
// Create shared canvases for groups
const sharedCanvases = [
{ id: 'team-roadmap', name: 'Team Roadmap 2025', groupId: 'group-1', ownerId: '1' },
{ id: 'beta-feedback', name: 'Beta Feedback Map', groupId: 'group-2', ownerId: '1' },
{ id: 'ai-research', name: 'AI Integration Ideas', groupId: 'group-3', ownerId: '2' }
]
sharedCanvases.forEach(canvas => {
const canvasData: CanvasData = {
id: canvas.id,
name: canvas.name,
nodes: [
{
id: `${canvas.id}-node-1`,
type: 'text',
x: 200,
y: 200,
width: 300,
height: 150,
text: `# ${canvas.name}\n\nThis is a shared canvas for the group.\n\nStart adding your ideas!`,
style: { background: '#1e293b', border: '#3b82f6' }
}
],
edges: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
ownerId: canvas.ownerId,
groupId: canvas.groupId,
isPublic: true
}
canvases.set(canvas.id, canvasData)
})
// Add demo activities
const demoActivities: Activity[] = [
{ id: 'activity-1', userId: '1', action: 'created_canvas', targetId: 'xflow-project', targetType: 'canvas', timestamp: new Date().toISOString() },
{ id: 'activity-2', userId: '2', action: 'joined_group', targetId: 'group-1', targetType: 'group', timestamp: new Date().toISOString() },
{ id: 'activity-3', userId: '3', action: 'created_canvas', targetId: 'ai-research', targetType: 'canvas', timestamp: new Date().toISOString() },
{ id: 'activity-4', userId: '1', action: 'followed_user', targetId: '2', targetType: 'user', timestamp: new Date().toISOString() }
]
demoActivities.forEach(activity => {
activities.set(activity.id, activity)
})
// Add demo follows
follows.set('1-2', { followerId: '1', followingId: '2', since: new Date().toISOString() })
follows.set('2-3', { followerId: '2', followingId: '3', since: new Date().toISOString() })
// Keep the old demo canvas for backward compatibility
canvases.set('demo', {
id: 'demo',
name: 'Demo Canvas',
nodes: [
{
id: 'node-1',
type: 'text',
x: 0,
y: 0,
width: 300,
height: 200,
text: '# Welcome\n\nЭто демо-нода с **Markdown** поддержкой.',
state: 'pending',
style: { background: '#1e293b', border: '#334155' }
},
{
id: 'node-2',
type: 'file',
x: 400,
y: 0,
width: 300,
height: 200,
file: 'tasks.md',
frontmatter: { alarm: '2025-04-24T09:00:00Z', script_id: 'notify', assigned_to: 'user' },
state: 'pending',
style: { background: '#1e293b', border: '#334155' }
},
{
id: 'node-3',
type: 'text',
x: 200,
y: 300,
width: 300,
height: 150,
text: '### Завершенная задача\n- [x] Настроить PWA',
state: 'completed',
style: { background: '#064e3b', border: '#059669' }
}
],
edges: [
{ id: 'edge-1', fromNode: 'node-1', fromSide: 'right', toNode: 'node-2', toSide: 'left' },
{ id: 'edge-2', fromNode: 'node-2', fromSide: 'bottom', toNode: 'node-3', toSide: 'top' }
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
ownerId: '1'
})
export function getUserAchievements(userId: string) {
return achievements.get(userId) || []
}
export function addAchievement(userId: string, achievement: any) {
const list = achievements.get(userId) || []
list.push({ ...achievement, unlockedAt: new Date().toISOString() })
achievements.set(userId, list)
}
+2
View File
@@ -0,0 +1,2 @@
// Deprecated: replaced by giteaApi.ts
export { }
+101
View File
@@ -0,0 +1,101 @@
// Gitea API integration for XFlow canvas sync
// Uses Gitea REST API to store mind maps in MMAVTEX repo
interface GiteaFile {
content: string // base64
sha?: string
}
const getConfig = () => {
const config = useRuntimeConfig()
return {
url: config.giteaUrl,
token: config.giteaToken,
org: config.giteaOrg,
mindmapRepo: config.giteaMindmapRepo,
projectRepo: config.giteaProjectRepo,
}
}
const headers = (token: string) => ({
'Authorization': `token ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
})
// Get file from Gitea (returns null if not found)
export async function giteaGetFile(path: string, repo?: string): Promise<GiteaFile | null> {
const { url, token, org, mindmapRepo } = getConfig()
const repoName = repo || mindmapRepo
try {
const res = await $fetch<any>(`${url}/api/v1/repos/${org}/${repoName}/contents/${path}`, {
headers: headers(token)
})
return { content: res.content, sha: res.sha }
} catch {
return null
}
}
// Create or update file in Gitea
export async function giteaPutFile(path: string, content: string, message: string, sha?: string, repo?: string): Promise<boolean> {
const { url, token, org, mindmapRepo } = getConfig()
const repoName = repo || mindmapRepo
const encoded = Buffer.from(content).toString('base64')
try {
await $fetch(`${url}/api/v1/repos/${org}/${repoName}/contents/${path}`, {
method: sha ? 'PUT' : 'POST',
headers: headers(token),
body: {
message,
content: encoded,
...(sha ? { sha } : {})
}
})
return true
} catch (e) {
console.error('Gitea putFile error:', e)
return false
}
}
// Save canvas to Gitea MMAVTEX repo
export async function saveCanvasToGitea(canvasId: string, canvasData: any, commitMessage?: string): Promise<boolean> {
const path = `canvases/${canvasId}.canvas`
const content = JSON.stringify(canvasData, null, 2)
const message = commitMessage || `Update canvas ${canvasId} at ${new Date().toLocaleString('ru-RU')}`
// Get existing file SHA if exists (required for update)
const existing = await giteaGetFile(path)
return giteaPutFile(path, content, message, existing?.sha)
}
// Load canvas from Gitea
export async function loadCanvasFromGitea(canvasId: string): Promise<any | null> {
const path = `canvases/${canvasId}.canvas`
const file = await giteaGetFile(path)
if (!file) return null
try {
const decoded = Buffer.from(file.content, 'base64').toString('utf-8')
return JSON.parse(decoded)
} catch {
return null
}
}
// List all canvases in Gitea
export async function listCanvasesFromGitea(): Promise<string[]> {
const { url, token, org, mindmapRepo } = getConfig()
try {
const res = await $fetch<any[]>(`${url}/api/v1/repos/${org}/${mindmapRepo}/contents/canvases`, {
headers: headers(token)
})
return res
.filter((f: any) => f.name.endsWith('.canvas'))
.map((f: any) => f.name.replace('.canvas', ''))
} catch {
return []
}
}
+9
View File
@@ -0,0 +1,9 @@
import { PrismaClient } from '.prisma/client'
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error']
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
+264
View File
@@ -0,0 +1,264 @@
// Real Git integration utilities for XFlow with Gitea
// This handles actual Git operations, caching, and change tracking
import { execSync } from 'child_process'
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs'
import { join } from 'path'
export interface GitRepository {
id: string
name: string
owner: string
url: string
cloneUrl: string
isPrivate: boolean
type: 'personal' | 'group'
ownerId: string
localPath: string
createdAt: string
updatedAt: string
}
export interface GitCommit {
id: string
message: string
author: string
timestamp: string
canvasId: string
changes: {
added: string[]
modified: string[]
deleted: string[]
}
}
// Cache for Git repositories
const gitCache = new Map<string, GitRepository>()
const canvasCache = new Map<string, any>()
// Base path for local Git repositories
const GIT_BASE_PATH = process.env.GIT_BASE_PATH || '/tmp/xflow-git'
// Ensure base directory exists
if (!existsSync(GIT_BASE_PATH)) {
mkdirSync(GIT_BASE_PATH, { recursive: true })
}
// Create a new Git repository for a user
export async function createPersonalRepository(userId: string, canvasId: string): Promise<GitRepository> {
const repoId = `repo-${userId}-${canvasId}-${Date.now()}`
const repoName = `canvas-${canvasId}`
const localPath = join(GIT_BASE_PATH, userId, repoName)
// Create local directory
if (!existsSync(localPath)) {
mkdirSync(localPath, { recursive: true })
}
// Initialize Git repository
try {
execSync('git init', { cwd: localPath })
execSync('git config user.name "XFlow System"', { cwd: localPath })
execSync('git config user.email "xflow@system.local"', { cwd: localPath })
} catch (error) {
console.error('Failed to initialize Git repository:', error)
}
const repository: GitRepository = {
id: repoId,
name: repoName,
owner: userId,
url: `https://gitea.example.com/${userId}/${repoName}`,
cloneUrl: `https://gitea.example.com/${userId}/${repoName}.git`,
isPrivate: true,
type: 'personal',
ownerId: userId,
localPath,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
gitCache.set(repoId, repository)
// Initialize with initial commit
await initializeRepository(repository, canvasId)
return repository
}
// Initialize repository with initial canvas data
async function initializeRepository(repository: GitRepository, canvasId: string): Promise<void> {
const canvasFile = join(repository.localPath, `${canvasId}.canvas`)
// Create initial canvas file
const initialCanvas = {
nodes: [],
edges: []
}
writeFileSync(canvasFile, JSON.stringify(initialCanvas, null, 2))
// Create initial commit
try {
execSync('git add .', { cwd: repository.localPath })
execSync(`git commit -m "Initial commit for canvas ${canvasId}"`, { cwd: repository.localPath })
} catch (error) {
console.error('Failed to create initial commit:', error)
}
}
// Sync canvas changes to Git repository
export async function syncCanvasToGit(
canvasId: string,
canvasData: any,
commitMessage?: string
): Promise<GitCommit> {
// Find repository for this canvas
const repository = Array.from(gitCache.values())
.find(repo => repo.name.includes(canvasId))
if (!repository) {
throw new Error(`No Git repository found for canvas ${canvasId}`)
}
const canvasFile = join(repository.localPath, `${canvasId}.canvas`)
// Check if file has changed
const currentContent = JSON.stringify(canvasData, null, 2)
let hasChanges = true
if (existsSync(canvasFile)) {
const existingContent = readFileSync(canvasFile, 'utf-8')
hasChanges = existingContent !== currentContent
}
if (!hasChanges) {
// Return latest commit if no changes
const commits = getRepositoryHistory(repository)
return commits[0] || {
id: 'no-changes',
message: 'No changes detected',
author: 'system',
timestamp: new Date().toISOString(),
canvasId,
changes: { added: [], modified: [], deleted: [] }
}
}
// Write canvas data
writeFileSync(canvasFile, currentContent)
// Commit changes
const message = commitMessage || `Update canvas ${canvasId}`
try {
execSync('git add .', { cwd: repository.localPath })
execSync(`git commit -m "${message}"`, { cwd: repository.localPath })
} catch (error) {
console.error('Failed to commit changes:', error)
}
// Update cache
repository.updatedAt = new Date().toISOString()
canvasCache.set(canvasId, canvasData)
// Get commit hash
let commitHash = 'unknown'
try {
commitHash = execSync('git rev-parse HEAD', { cwd: repository.localPath, encoding: 'utf-8' }).trim()
} catch (error) {
console.error('Failed to get commit hash:', error)
}
const commit: GitCommit = {
id: commitHash,
message,
author: repository.owner,
timestamp: new Date().toISOString(),
canvasId,
changes: {
added: existsSync(canvasFile) ? [`${canvasId}.canvas`] : [],
modified: [`${canvasId}.canvas`],
deleted: []
}
}
return commit
}
// Load canvas from Git repository
export async function loadCanvasFromGit(canvasId: string): Promise<any> {
// Check cache first
if (canvasCache.has(canvasId)) {
return canvasCache.get(canvasId)
}
// Find repository for this canvas
const repository = Array.from(gitCache.values())
.find(repo => repo.name.includes(canvasId))
if (!repository) {
throw new Error(`No Git repository found for canvas ${canvasId}`)
}
const canvasFile = join(repository.localPath, `${canvasId}.canvas`)
if (!existsSync(canvasFile)) {
throw new Error(`Canvas file not found: ${canvasFile}`)
}
const canvasData = JSON.parse(readFileSync(canvasFile, 'utf-8'))
// Cache the data
canvasCache.set(canvasId, canvasData)
return canvasData
}
// Get repository for a canvas
export function getCanvasRepository(canvasId: string): GitRepository | null {
return Array.from(gitCache.values())
.find(repo => repo.name.includes(canvasId)) || null
}
// Get commit history for a repository
export function getRepositoryHistory(repository: GitRepository): GitCommit[] {
try {
const log = execSync('git log --pretty=format:"%H|%s|%an|%ad" --date=iso', {
cwd: repository.localPath,
encoding: 'utf-8'
})
return log.split('\n')
.filter(line => line.trim())
.map(line => {
const [id, message, author, timestamp] = line.split('|')
return {
id,
message,
author,
timestamp,
canvasId: repository.name.replace('canvas-', ''),
changes: { added: [], modified: [], deleted: [] }
}
})
} catch (error) {
console.error('Failed to get repository history:', error)
return []
}
}
// Check if canvas has unsaved changes
export function hasUnsavedChanges(canvasId: string, currentData: any): boolean {
if (!canvasCache.has(canvasId)) {
return true // No cached data, assume changes
}
const cachedData = canvasCache.get(canvasId)
return JSON.stringify(cachedData) !== JSON.stringify(currentData)
}
// Clear cache for a canvas
export function clearCanvasCache(canvasId: string): void {
canvasCache.delete(canvasId)
}