feat: prisma schema, groups API, deploy workflow
@@ -1,6 +1,8 @@
|
||||
# API Keys Configuration
|
||||
# Copy this file to .env and fill in your actual keys
|
||||
|
||||
|
||||
|
||||
# DeepSeek API (recommended - cost effective)
|
||||
# Get from: https://platform.deepseek.com/api_keys
|
||||
DEEPSEEK_API_KEY=sk_your_deepseek_api_key_here
|
||||
@@ -13,15 +15,33 @@ OPENAI_API_KEY=sk_your_openai_api_key_here
|
||||
# Get from: https://console.anthropic.com/
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
|
||||
# Firebase Configuration
|
||||
# These will be in your Firebase console
|
||||
FIREBASE_API_KEY=your_firebase_api_key
|
||||
FIREBASE_APP_ID=your_firebase_app_id
|
||||
FIREBASE_PROJECT_ID=your_firebase_project_id
|
||||
FIREBASE_MESSAGING_SENDER_ID=your_firebase_sender_id
|
||||
FIREBASE_STORAGE_BUCKET=your_firebase_storage_bucket
|
||||
# Firebase Configuration (for Nuxt runtimeConfig)
|
||||
# Get from: https://console.firebase.google.com -> Project Settings -> General
|
||||
NUXT_PUBLIC_FIREBASE_API_KEY=your_firebase_api_key
|
||||
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
|
||||
NUXT_PUBLIC_FIREBASE_PROJECT_ID=your_firebase_project_id
|
||||
NUXT_PUBLIC_FIREBASE_APP_ID=your_firebase_app_id
|
||||
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_firebase_sender_id
|
||||
|
||||
# Server-side Firebase API key (same as public, used for token verification)
|
||||
NUXT_FIREBASE_API_KEY=your_firebase_api_key
|
||||
|
||||
# Google Calendar API
|
||||
# Enable in Google Cloud Console
|
||||
GOOGLE_CLOUD_CLIENT_ID=your_google_cloud_client_id
|
||||
GOOGLE_CLOUD_CLIENT_SECRET=your_google_cloud_client_secret
|
||||
|
||||
# Database (PostgreSQL)
|
||||
# Production: postgresql://xflow:password@postgres:5432/xflow
|
||||
# Dev via SSH tunnel: postgresql://xflow:password@localhost:5432/xflow
|
||||
DATABASE_URL=postgresql://xflow:xflow_pass@localhost:5432/xflow
|
||||
|
||||
# Gitea Integration
|
||||
GITEA_URL=http://git.a-crm.controlsystem.kz
|
||||
GITEA_TOKEN=your_gitea_token
|
||||
GITEA_ORG=AVTEX
|
||||
GITEA_MINDMAP_REPO=MMAVTEX
|
||||
GITEA_PROJECT_REPO=Projects
|
||||
|
||||
# JWT Secret for session tokens
|
||||
NUXT_JWT_SECRET=your_random_secret_here
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
name: Deploy XFlow (AVTEX/Projects)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Nuxt
|
||||
run: npm run build
|
||||
env:
|
||||
NUXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }}
|
||||
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ secrets.FIREBASE_AUTH_DOMAIN }}
|
||||
NUXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }}
|
||||
NUXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }}
|
||||
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
|
||||
|
||||
- name: Setup SSH with jump host
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
cat >> ~/.ssh/config << EOF
|
||||
Host jumphost
|
||||
HostName ${{ secrets.JUMP_HOST }}
|
||||
User ${{ secrets.JUMP_USER }}
|
||||
IdentityFile ~/.ssh/id_rsa
|
||||
StrictHostKeyChecking no
|
||||
Host target
|
||||
HostName ${{ secrets.DEPLOY_HOST }}
|
||||
User ${{ secrets.DEPLOY_USER }}
|
||||
ProxyJump jumphost
|
||||
IdentityFile ~/.ssh/id_rsa
|
||||
StrictHostKeyChecking no
|
||||
EOF
|
||||
|
||||
- name: Copy .output to server
|
||||
run: |
|
||||
rsync -az --delete .output/ target:/opt/xflow/.output/
|
||||
rsync -az prisma/ target:/opt/xflow/prisma/
|
||||
rsync -az package.json target:/opt/xflow/package.json
|
||||
|
||||
- name: Run migrations & restart container
|
||||
run: |
|
||||
ssh target << 'ENDSSH'
|
||||
cd /opt/xflow
|
||||
|
||||
# Запустить миграции Prisma
|
||||
docker run --rm \
|
||||
--network infra \
|
||||
-e DATABASE_URL="$DATABASE_URL" \
|
||||
-v /opt/xflow/prisma:/app/prisma \
|
||||
-v /opt/xflow/node_modules:/app/node_modules \
|
||||
-w /app \
|
||||
node:20-alpine \
|
||||
sh -c "npx prisma migrate deploy"
|
||||
|
||||
# Перезапустить xflow контейнер
|
||||
cd /opt/xflow
|
||||
docker compose up -d --force-recreate xflow
|
||||
|
||||
echo "✅ XFlow deployed at $(date)"
|
||||
ENDSSH
|
||||
@@ -1,4 +1,4 @@
|
||||
name: 🚀 Деплой Nanaxe Server
|
||||
name: 🚀 Деплой XFlow Server
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
- name: 🔄 Синхронизация файлов
|
||||
run: |
|
||||
SERVER_PATH="/opt/nanaxe-server"
|
||||
SERVER_PATH="/opt/xflow-server"
|
||||
|
||||
mkdir -p $SERVER_PATH/api
|
||||
mkdir -p $SERVER_PATH/nginx/html
|
||||
@@ -48,10 +48,10 @@ jobs:
|
||||
|
||||
- name: 🐳 Пересборка и перезапуск
|
||||
run: |
|
||||
cd /opt/nanaxe-server
|
||||
cd /opt/xflow-server
|
||||
|
||||
# Пересобрать API
|
||||
docker compose build nanaxe-api
|
||||
docker compose build xflow-api
|
||||
|
||||
# Перезапустить все сервисы
|
||||
docker compose down
|
||||
@@ -80,6 +80,6 @@ jobs:
|
||||
fi
|
||||
|
||||
# Список контейнеров
|
||||
docker compose -f /opt/nanaxe-server/docker-compose.yml ps
|
||||
docker compose -f /opt/xflow-server/docker-compose.yml ps
|
||||
|
||||
echo "✅ ДЕПЛОЙ ЗАВЕРШЁН УСПЕШНО!"
|
||||
|
||||
@@ -4,13 +4,12 @@
|
||||
.env.*.local
|
||||
!/.env.example
|
||||
|
||||
# Firebase (root level)
|
||||
/google-services.json
|
||||
/GoogleService-Info.plist
|
||||
|
||||
# Firebase (Android - tracked)
|
||||
!android/app/google-services.json
|
||||
!android/app/GoogleService-Info.plist
|
||||
# Nuxt
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
.output
|
||||
dist
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
@@ -19,16 +18,8 @@
|
||||
*.ipr
|
||||
*.iws
|
||||
|
||||
# Build
|
||||
build/
|
||||
**/build/
|
||||
**/android/build/
|
||||
**/ios/build/
|
||||
**/web/build/
|
||||
|
||||
# Dependencies
|
||||
.pub-cache/
|
||||
.packages
|
||||
node_modules/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -41,15 +32,10 @@ Thumbs.db
|
||||
# Coverage
|
||||
coverage/
|
||||
|
||||
# Testing
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
server/api/node_modules/
|
||||
obsidian-git/
|
||||
|
||||
/app/generated/prisma
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "90673a4eef275d1a6692c26ac80d6d746d41a73a"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
- platform: android
|
||||
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
- platform: ios
|
||||
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
- platform: linux
|
||||
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
- platform: macos
|
||||
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
- platform: web
|
||||
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
- platform: windows
|
||||
create_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
base_revision: 90673a4eef275d1a6692c26ac80d6d746d41a73a
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"file-explorer": true,
|
||||
"global-search": true,
|
||||
"switcher": true,
|
||||
"graph": true,
|
||||
"backlink": true,
|
||||
"canvas": true,
|
||||
"outgoing-link": true,
|
||||
"tag-pane": true,
|
||||
"footnotes": false,
|
||||
"properties": true,
|
||||
"page-preview": true,
|
||||
"daily-notes": true,
|
||||
"templates": true,
|
||||
"note-composer": true,
|
||||
"command-palette": true,
|
||||
"slash-command": false,
|
||||
"editor-status": true,
|
||||
"bookmarks": true,
|
||||
"markdown-importer": false,
|
||||
"zk-prefixer": false,
|
||||
"random-note": false,
|
||||
"outline": true,
|
||||
"word-count": true,
|
||||
"slides": false,
|
||||
"audio-recorder": false,
|
||||
"workspaces": false,
|
||||
"file-recovery": true,
|
||||
"publish": false,
|
||||
"sync": true,
|
||||
"bases": true,
|
||||
"webviewer": false
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
{
|
||||
"main": {
|
||||
"id": "5578cc3a84253281",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "08c5d1d78034a917",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "3f38aefa8626b1f8",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "canvas",
|
||||
"state": {
|
||||
"file": "xflow-project-ru.canvas",
|
||||
"viewState": {
|
||||
"x": 680,
|
||||
"y": 867.4276729559749,
|
||||
"zoom": -0.8699251927711932
|
||||
}
|
||||
},
|
||||
"icon": "lucide-layout-dashboard",
|
||||
"title": "xflow-project-ru"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "vertical"
|
||||
},
|
||||
"left": {
|
||||
"id": "51c2518351e40de9",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "e549873bfb898273",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "593bb9c31fd772d7",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "file-explorer",
|
||||
"state": {
|
||||
"sortOrder": "alphabetical",
|
||||
"autoReveal": false
|
||||
},
|
||||
"icon": "lucide-folder-closed",
|
||||
"title": "Файловый менеджер"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "62546ac7efa0da90",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "search",
|
||||
"state": {
|
||||
"query": "",
|
||||
"matchingCase": false,
|
||||
"explainSearch": false,
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical"
|
||||
},
|
||||
"icon": "lucide-search",
|
||||
"title": "Поиск"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "1d80fa437f26e9ac",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "bookmarks",
|
||||
"state": {},
|
||||
"icon": "lucide-bookmark",
|
||||
"title": "Закладки"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 300
|
||||
},
|
||||
"right": {
|
||||
"id": "852b2aecff829521",
|
||||
"type": "split",
|
||||
"children": [
|
||||
{
|
||||
"id": "e7d70e8889ccb870",
|
||||
"type": "tabs",
|
||||
"children": [
|
||||
{
|
||||
"id": "5746c4634649e54f",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "backlink",
|
||||
"state": {
|
||||
"file": "xflow-project-ru.canvas",
|
||||
"collapseAll": false,
|
||||
"extraContext": false,
|
||||
"sortOrder": "alphabetical",
|
||||
"showSearch": false,
|
||||
"searchQuery": "",
|
||||
"backlinkCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-coming-in",
|
||||
"title": "Обратные ссылки для xflow-project-ru"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "75023e9b8d4fc317",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outgoing-link",
|
||||
"state": {
|
||||
"file": "xflow-project-ru.canvas",
|
||||
"linksCollapsed": false,
|
||||
"unlinkedCollapsed": true
|
||||
},
|
||||
"icon": "links-going-out",
|
||||
"title": "Исходящие ссылки из xflow-project-ru"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c8b2ad057ffcc230",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "tag",
|
||||
"state": {
|
||||
"sortOrder": "frequency",
|
||||
"useHierarchy": true,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-tags",
|
||||
"title": "Теги"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "c551c9749ed1b9fd",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "all-properties",
|
||||
"state": {
|
||||
"sortOrder": "frequency",
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-archive",
|
||||
"title": "Все свойства"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "b81e950116c8095d",
|
||||
"type": "leaf",
|
||||
"state": {
|
||||
"type": "outline",
|
||||
"state": {
|
||||
"file": "xflow-project-ru.canvas",
|
||||
"followCursor": false,
|
||||
"showSearch": false,
|
||||
"searchQuery": ""
|
||||
},
|
||||
"icon": "lucide-list",
|
||||
"title": "Структура xflow-project-ru"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"direction": "horizontal",
|
||||
"width": 300,
|
||||
"collapsed": true
|
||||
},
|
||||
"left-ribbon": {
|
||||
"hiddenItems": {
|
||||
"switcher:Меню быстрого перехода": false,
|
||||
"graph:Граф": false,
|
||||
"canvas:Создать новый холст": false,
|
||||
"daily-notes:Сегодняшняя заметка": false,
|
||||
"templates:Вставить шаблон": false,
|
||||
"command-palette:Открыть палитру команд": false,
|
||||
"bases:Создать новую базу": false
|
||||
}
|
||||
},
|
||||
"active": "3f38aefa8626b1f8",
|
||||
"lastOpenFiles": [
|
||||
"app/server/utils/prisma.ts",
|
||||
"prisma.config.ts",
|
||||
"prisma/schema.prisma",
|
||||
"prisma",
|
||||
"node_modules/mysql2/typings/mysql/lib/protocol/sequences/promise/QueryableBase.d.ts",
|
||||
"node_modules/mysql2/typings/mysql/lib/protocol/sequences/promise/ExecutableBase.d.ts",
|
||||
"node_modules/mysql2/typings/mysql/lib/protocol/packets/params/OkPacketParams.d.ts",
|
||||
"node_modules/mysql2/typings/mysql/lib/protocol/packets/params/ErrorPacketParams.d.ts",
|
||||
"node_modules/mysql2/typings/mysql/lib/protocol/sequences/Sequence.d.ts",
|
||||
"node_modules/mysql2/typings/mysql/lib/protocol/sequences/QueryableBase.d.ts",
|
||||
"node_modules/mysql2/typings/mysql/lib/protocol/sequences/Query.d.ts",
|
||||
"node_modules/mysql2/lib/auth_plugins/caching_sha2_password.md",
|
||||
"node_modules/mysql2/README.md",
|
||||
"node_modules/remeda/README.md",
|
||||
"node_modules/valibot/README.md",
|
||||
"node_modules/valibot/LICENSE.md",
|
||||
"node_modules/zeptomatch/readme.md",
|
||||
"node_modules/better-result/README.md",
|
||||
"node_modules/env-paths/readme.md",
|
||||
"node_modules/chart.js/README.md",
|
||||
"node_modules/chart.js/LICENSE.md",
|
||||
"node_modules/@standard-schema/spec/README.md",
|
||||
"node_modules/fast-check/README.md",
|
||||
"node_modules/fast-check/CHANGELOG.md",
|
||||
"node_modules/aws-ssl-profiles/README.md",
|
||||
"node_modules/lru.min/README.md",
|
||||
"node_modules/@radix-ui/react-use-layout-effect/README.md",
|
||||
"node_modules/@radix-ui/react-use-effect-event/README.md",
|
||||
"node_modules/@radix-ui/react-use-controllable-state/README.md",
|
||||
"node_modules/@radix-ui/react-toggle/README.md",
|
||||
"node_modules/@radix-ui/react-slot/README.md",
|
||||
"node_modules/@radix-ui/react-primitive/README.md",
|
||||
"node_modules/@radix-ui/react-compose-refs/README.md",
|
||||
"node_modules/@radix-ui/primitive/README.md",
|
||||
"node_modules/svgo/node_modules/commander/Readme.md",
|
||||
"node_modules/@prisma/streams-local/dist/touch/hash_vendor/NOTICE.md",
|
||||
"node_modules/proper-lockfile/node_modules/retry/equation.gif",
|
||||
"xflow-project.canvas",
|
||||
"xflow-project-ru.canvas"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
# Системный промпт для AI-ассистента проекта XFlow
|
||||
|
||||
## Текущая архитектура
|
||||
Проект реализован на **Nuxt 3 PWA**.
|
||||
|
||||
### Стек
|
||||
- **Frontend/Backend:** Nuxt 3 (Vue 3 + Nitro SSR)
|
||||
- **Стили:** Tailwind CSS
|
||||
- **PWA:** `@vite-pwa/nuxt` (Service Worker, манифест, оффлайн-кэш)
|
||||
- **Аутентификация:** Firebase Auth (Google Sign-In), JWT на Nitro
|
||||
- **Визуализация:** `@vue-flow/core` + `@vue-flow/background` + `@vue-flow/controls` + `@vue-flow/minimap`
|
||||
- **Markdown:** `markdown-it` (рендеринг нод)
|
||||
- **Push:** Web Push API + Notification API + вибро
|
||||
- **База данных:** In-memory (заглушка на Nitro utils/db.ts), миграция на Postgres в планах
|
||||
|
||||
### Файловая структура
|
||||
```
|
||||
app/
|
||||
app.vue # Корневой компонент
|
||||
assets/css/main.css # Tailwind + кастомные стили для Vue Flow
|
||||
components/
|
||||
CanvasViewer.vue # Основной канвас (Vue Flow)
|
||||
MarkdownNode.vue # Рендеринг markdown в нодах
|
||||
AchievementOverlay.vue # Всплывающие ачивки
|
||||
composables/
|
||||
useAuth.ts # Firebase Google Sign-In + JWT decode
|
||||
useCanvas.ts # Загрузка .canvas JSON, парсинг frontmatter
|
||||
usePush.ts # Подписка/отправка Web Push
|
||||
useAchievements.ts # Управление ачивками
|
||||
layouts/
|
||||
default.vue # Навигация + AchievementOverlay
|
||||
middleware/
|
||||
auth.ts # Проверка JWT (cookie xflow_token)
|
||||
admin.ts # Проверка role === 'admin'
|
||||
pages/
|
||||
index.vue # Дашборд (статы задач и ачивок)
|
||||
login.vue # Только Google Sign-In (Firebase)
|
||||
canvas.vue # CanvasViewer страница
|
||||
admin.vue # Панель админа (пуши, скрипты)
|
||||
settings.vue # Настройки пушей и ачивок
|
||||
plugins/
|
||||
firebase.client.ts # Инициализация Firebase SDK (только клиент)
|
||||
push-notifications.client.ts # Обработка пуш-событий
|
||||
server/
|
||||
api/auth/google.post.ts # Верификация Firebase ID token + выдача JWT
|
||||
api/auth/validate.get.ts # Проверка JWT
|
||||
api/canvas/[id].get.ts # Мок .canvas JSON
|
||||
api/nodes/[id].put.ts # Обновление статуса ноды + автовыдача ачивок
|
||||
api/nodes/index.get.ts # Список нод с RBAC (фильтр по assigned_to)
|
||||
api/achievements/index.get.ts # Список ачивок пользователя
|
||||
api/push/subscribe.post.ts # Сохранение подписки
|
||||
api/push/send.post.ts # Отправка пуша (admin only)
|
||||
utils/auth.ts # Генерация/верификация JWT (jose)
|
||||
utils/db.ts # In-memory store (users, nodes, achievements, pushSubs)
|
||||
public/
|
||||
favicon.svg
|
||||
pwa-*.svg # Иконки PWA (SVG заглушки)
|
||||
```
|
||||
|
||||
### Ключевые бизнес-правила
|
||||
1. **Авторизация:** Только Firebase Google Sign-In. Email/password удален. Cookie `xflow_token`.
|
||||
2. **RBAC:** `role: 'admin'` дает доступ к `/admin`. Обычные пользователи видят только ноды с `assigned_to == свой email`.
|
||||
3. **Canvas ноды:** Double-click переключает статус `pending -> in_progress -> completed`. При `completed` сервер автоматически выдает ачивку.
|
||||
4. **Frontmatter:** Ноды могут содержать YAML frontmatter с полями `alarm`, `script_id`, `assigned_to`.
|
||||
5. **Push:** Админ отправляет пуши через `/api/push/send`. Клиентский плагин слушает `PUSH_RECEIVED` и вибрирует.
|
||||
6. **Gamification:** Ачивки выдаются автоматически при завершении нод. Отображаются через AchievementOverlay.
|
||||
|
||||
### Конфигурация (.env)
|
||||
Обязательные переменные для Firebase:
|
||||
```
|
||||
NUXT_PUBLIC_FIREBASE_API_KEY=
|
||||
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
|
||||
NUXT_PUBLIC_FIREBASE_PROJECT_ID=
|
||||
NUXT_PUBLIC_FIREBASE_APP_ID=
|
||||
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
|
||||
NUXT_FIREBASE_API_KEY= # Серверная верификация (тот же ключ)
|
||||
NUXT_JWT_SECRET= # Секрет для подписи JWT
|
||||
```
|
||||
|
||||
## Критически важное правило: документация в .md
|
||||
**При ЛЮБЫХ изменениях архитектуры, API, бизнес-логики или структуры данных — обновляй соответствующие .md файлы.**
|
||||
|
||||
### Структура документации в проекте
|
||||
- `docs/architecture/` — архитектурные решения, ADR
|
||||
- `docs/api/` — контракты API, форматы данных
|
||||
- `docs/features/` — описание функционала
|
||||
- `README.md` — общее описание, setup, структура
|
||||
|
||||
### Правила обновления .md
|
||||
1. **Если добавляешь новый composable/component/API route** — создай или обнови `.md`:
|
||||
- Назначение
|
||||
- Зависимости
|
||||
- Публичный API (методы, параметры, типы)
|
||||
- Пример использования
|
||||
|
||||
2. **Если меняешь формат данных** (.canvas JSON, frontmatter схема):
|
||||
- Обнови документацию формата
|
||||
- Укажи версию схемы
|
||||
- Опиши миграцию если нужна
|
||||
|
||||
3. **Если добавляешь фичу**:
|
||||
- Создай `docs/features/feature-name.md`
|
||||
- Опиши user flow, edge cases
|
||||
|
||||
## Инструкция для сессии
|
||||
1. Перед изменениями — прочитай соответствующий `.md` файл и код
|
||||
2. После изменений — обнови `.md` или создай новый
|
||||
3. Не ломай существующие форматы данных без явного запроса
|
||||
4. При добавлении зависимостей — проверяй совместимость с Nuxt 3 / Vue 3 / TypeScript
|
||||
5. Все Dart/Flutter файлы удалены — не возвращай их
|
||||
6. Cookie называется `xflow_token`
|
||||
7. Проект называется **XFlow**
|
||||
@@ -1,79 +1,74 @@
|
||||
# 🚀 NANAXE CRM
|
||||
# 🚀 XFlow — Nuxt 3 PWA
|
||||
|
||||
CRM-ассистент для команд с AI-планированием, mind-картами и интеграцией с Google Calendar.
|
||||
Визуальный менеджер задач на базе Obsidian Canvas. Работает офлайн, поддерживает уведомления и геймификацию.
|
||||
|
||||
## 📱 Возможности
|
||||
|
||||
- ✅ **Управление задачами** - создание, назначение, приоритеты, статусы
|
||||
- ✅ **Mind-карты проектов** - визуальное дерево задач
|
||||
- ✅ **Календарь** - синхронизация с Google Calendar
|
||||
- ✅ **Будильники/Напоминания** - локальные уведомления
|
||||
- ✅ **AI-ассистент** - умное планирование и генерация подзадач
|
||||
- ✅ **Ролевая модель** - admin, member, viewer
|
||||
- ✅ **Офлайн-режим** - кэширование данных на устройстве
|
||||
- ✅ **Obsidian Canvas** — визуальное управление задачами через граф
|
||||
- ✅ **Markdown ноды** — рендеринг содержимого с подсветкой синтаксиса
|
||||
- ✅ **PWA + Офлайн** — Service Worker, кэширование, установка на homescreen
|
||||
- ✅ **JWT Auth** — регистрация, вход, ролевая модель (RBAC)
|
||||
- ✅ **Web Push** — пуш-уведомления даже при закрытом приложении
|
||||
- ✅ **Будильники** — локальные вибро-уведомления
|
||||
- ✅ **Ачивки** — геймификация выполнения задач
|
||||
- ✅ **Админка** — управление скриптами и рассылкой пушей
|
||||
|
||||
## 🛠 Технологический стек
|
||||
|
||||
| Компонент | Технология |
|
||||
|-----------|------------|
|
||||
| Framework | Flutter |
|
||||
| State Management | Riverpod |
|
||||
| Backend | Firebase (Firestore, Auth, FCM) |
|
||||
| Local Storage | Hive |
|
||||
| Navigation | GoRouter |
|
||||
| AI | OpenAI API (планируется) |
|
||||
| Framework | Nuxt 3 (Vue 3) |
|
||||
| Styling | Tailwind CSS |
|
||||
| Canvas | Vue Flow |
|
||||
| Markdown | markdown-it |
|
||||
| PWA | @vite-pwa/nuxt |
|
||||
| Backend API | Nitro (встроен в Nuxt) |
|
||||
| Auth | JWT |
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### 1. Клонирование
|
||||
### 1. Установка зависимостей
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd NANAXE
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Установка зависимостей
|
||||
### 2. Настройка окружения
|
||||
```bash
|
||||
flutter pub get
|
||||
cp .env.example .env
|
||||
# Отредактируй .env (JWT_SECRET, Postgres и т.д.)
|
||||
```
|
||||
|
||||
### 3. Настройка Firebase
|
||||
1. Создай проект в [Firebase Console](https://console.firebase.google.com)
|
||||
2. Включи Authentication (Google Sign-In)
|
||||
3. Создай Firestore базу данных
|
||||
4. Скачай `google-services.json` и положи в `android/app/`
|
||||
5. Загрузи правила безопасности из `firestore.rules`
|
||||
|
||||
### 4. Запуск
|
||||
### 3. Запуск dev-сервера
|
||||
```bash
|
||||
flutter run
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 📚 Документация
|
||||
|
||||
- [README.md](README.md) - Общая информация
|
||||
- [QUICKSTART.md](QUICKSTART.md) - Подробный быстрый старт
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) - Архитектура проекта
|
||||
- [SETUP_COMPLETE.md](SETUP_COMPLETE.md) - Итоги настройки
|
||||
### 4. Сборка для production
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
lib/
|
||||
├── core/ # Сервисы (Firebase, AI, Storage)
|
||||
├── models/ # Модели данных
|
||||
├── providers/ # State management (Riverpod)
|
||||
├── screens/ # Экраны приложения
|
||||
└── widgets/ # Переиспользуемые виджеты
|
||||
app/
|
||||
├── components/ # Vue компоненты (CanvasViewer, MarkdownNode, ...)
|
||||
├── composables/ # Логика (useAuth, useCanvas, usePush, ...)
|
||||
├── layouts/ # Layout'ы страниц
|
||||
├── middleware/ # Route guards (auth, admin)
|
||||
├── pages/ # Страницы (index, login, canvas, admin)
|
||||
├── plugins/ # Client plugins (push notifications)
|
||||
├── public/ # Статические ассеты (иконки PWA)
|
||||
└── server/ # Nitro API routes
|
||||
|
||||
server/ # Legacy Express API (сохранён)
|
||||
```
|
||||
|
||||
## 🔧 Настройка окружения
|
||||
## � Доступ
|
||||
|
||||
1. Скопируй `.env.example` в `.env`
|
||||
2. Заполни API ключами (OpenAI, Firebase, etc.)
|
||||
|
||||
## 🤝 Вклад
|
||||
|
||||
Этот проект создан для внутренней команды NANAHUI.
|
||||
- **Админ** — `admin@xflow.local` / `admin123`
|
||||
- **Регистрация** — доступна на `/login`
|
||||
|
||||
## 📄 Лицензия
|
||||
|
||||
@@ -81,4 +76,4 @@ Internal use only
|
||||
|
||||
---
|
||||
|
||||
**Создано для команды NANAXE** 🚀
|
||||
**XFlow Team** 🚀
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -1,14 +0,0 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@@ -1,66 +0,0 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
id("com.google.gms.google-services")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.nanaxe.nanaxe_crm"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.nanaxe.nanaxe_crm"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core library desugaring for Java 8+ APIs
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
|
||||
// Import the Firebase BoM
|
||||
implementation(platform("com.google.firebase:firebase-bom:34.11.0"))
|
||||
|
||||
// Firebase Analytics
|
||||
implementation("com.google.firebase:firebase-analytics")
|
||||
|
||||
// Firebase Auth
|
||||
implementation("com.google.firebase:firebase-auth")
|
||||
|
||||
// Firebase Firestore
|
||||
implementation("com.google.firebase:firebase-firestore")
|
||||
|
||||
// Firebase Messaging
|
||||
implementation("com.google.firebase:firebase-messaging")
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "118397847313",
|
||||
"project_id": "nanax26-1f33b",
|
||||
"storage_bucket": "nanax26-1f33b.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:118397847313:android:b1a3443f7a49c42d456eb0",
|
||||
"android_client_info": {
|
||||
"package_name": "com.nanaxe.nanaxe_crm"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "118397847313-jsce6bovvssvpvklaucfbaidk5bs342f.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyA55gXLm9JAzhGEUThgsEmfhZaxUMVnlnM"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "118397847313-jsce6bovvssvpvklaucfbaidk5bs342f.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -1,53 +0,0 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Firebase & Google Sign-In permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<application
|
||||
android:label="NANAXE CRM"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.nanaxe.nanaxe_crm
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
|
Before Width: | Height: | Size: 544 B |
|
Before Width: | Height: | Size: 442 B |
|
Before Width: | Height: | Size: 721 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,7 +0,0 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -1,28 +0,0 @@
|
||||
plugins {
|
||||
id("com.google.gms.google-services") version "4.4.4" apply false
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
@@ -1,5 +0,0 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
@@ -1,26 +0,0 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
const token = useCookie('xflow_token')
|
||||
if (!token.value && to.path !== '/login') {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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' }
|
||||
}
|
||||
})
|
||||
@@ -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' }
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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}"` }
|
||||
})
|
||||
@@ -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}"` }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Deprecated: replaced by giteaApi.ts
|
||||
export { }
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
.gitkeep
|
||||
@@ -1 +0,0 @@
|
||||
.gitkeep
|
||||