feat: prisma schema, groups API, deploy workflow

This commit is contained in:
2026-04-23 21:41:11 +05:00
parent 4c50036172
commit 65cf8ca01f
285 changed files with 24602 additions and 25890 deletions
+27 -7
View File
@@ -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
+80
View File
@@ -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
+5 -5
View File
@@ -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 "✅ ДЕПЛОЙ ЗАВЕРШЁН УСПЕШНО!"
+9 -23
View File
@@ -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
-45
View File
@@ -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'
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+33
View File
@@ -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
}
+231
View File
@@ -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"
]
}
+112
View File
@@ -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**
+44 -49
View File
@@ -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** 🚀
-28
View File
@@ -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
-14
View File
@@ -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
-66
View File
@@ -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")
}
-39
View File
@@ -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>
-53
View File
@@ -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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

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

After

Width:  |  Height:  |  Size: 252 B

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

After

Width:  |  Height:  |  Size: 273 B

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

After

Width:  |  Height:  |  Size: 279 B

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

After

Width:  |  Height:  |  Size: 281 B

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

Some files were not shown because too many files have changed in this diff Show More