Compare commits

..

126 Commits

Author SHA1 Message Date
XFlow Deploy f58ca9b885 feat(branding): update XFlow logo with fused Avtex neon crescent and dynamic X-flow waves 2026-09-28 10:03:53 +05:00
XFlow Deploy aa412ffae9 fix(server): standardize prisma generator and add resilient dev fallback in PrismaListRepository 2026-09-28 09:14:48 +05:00
XFlow Deploy 8db46f4027 feat(deploy): add Coolify docker-compose, multi-stage Dockerfile, and env template matching XCargo and Signage patterns 2026-09-28 08:59:07 +05:00
XFlow Deploy 0837b3f612 feat: restructure into XFlow monorepo with Nuxt 4, Modular Onion Monolith, Android client, and Desktop CLI 2026-09-28 08:57:32 +05:00
XFlow Deploy edcd30c829 fix(ci): trim newline from GIT_TOKEN secret 2026-06-01 17:00:43 +05:00
XFlow Deploy ff8a9c4e53 fix(ci): use env for GIT_TOKEN secret 2026-06-01 16:59:40 +05:00
XFlow Deploy 866ac86665 revert(ci): back to node:22-alpine after network fix 2026-06-01 16:58:42 +05:00
Alexyz fe46f49b67 chore: change job container image to node:22 and update checkout script 2026-06-01 16:02:08 +05:00
Alexyz 2b8b26df3f chore: test empty commit 2026-06-01 15:48:02 +05:00
XFlow Deploy a0bb47b022 fix(ci): use NPB-style checkout (git init + fetch) 2026-06-01 13:02:09 +05:00
XFlow Deploy 64c3928408 fix(ci): force job container to use default docker0 bridge 2026-06-01 12:33:09 +05:00
XFlow Deploy c4401d35ea revert(ci): restore original pipeline — network issue is server-side 2026-06-01 12:05:00 +05:00
XFlow Deploy 8c8c6768bd fix(ci): restore node:22-alpine + fix Alpine DNS for apk/git 2026-06-01 11:49:48 +05:00
XFlow Deploy 190e67eb26 fix(ci): remove container to use runner host directly — no Docker Hub access 2026-06-01 11:39:29 +05:00
XFlow Deploy f740fdd57a fix(ci): use node:22 instead of alpine to fix DNS/apk issues in Gitea Actions 2026-06-01 11:38:39 +05:00
XFlow Deploy cdf064b187 fix(auth): log real Firebase error + add NUXT_FIREBASE_API_KEY to docker-compose 2026-06-01 11:10:53 +05:00
Alexyz 8fe2075b37 feat: set Gemini 3.1 Pro Preview as the default reasoning/thinking model 2026-05-25 09:37:52 +05:00
Alexyz 60d25261c8 fix: update Gemini API models to Gemini 3.5 and 3.1 matching 2026 AI Studio console 2026-05-25 09:36:40 +05:00
Alexyz aeed229184 refactor: clean up top toolbar and integrate direct Gemini API support 2026-05-25 08:56:05 +05:00
Alexyz 74b093b5ad fix: adapt server deployment and compose command execution to mirror NPB bridge container pattern 2026-05-25 08:19:38 +05:00
Alexyz c858b811e9 fix: change git clone URL to use public hostname git.a-crm.controlsystem.kz 2026-05-25 08:16:37 +05:00
Alexyz f7dc1b2f1c fix: run deploy job inside node:22-alpine container to match NPB workflow 2026-05-25 08:15:34 +05:00
Alexyz 33773b373d fix: make workflow package installation environment-agnostic (fix apk command not found) 2026-05-25 08:08:35 +05:00
Alexyz bb971e665f feat: integrate coaching frameworks, Eisenhower matrix, Gantt chart, and Goal Validation test 2026-05-25 07:59:09 +05:00
Alexyz 7161523d01 feat: implement FlowEditor component and add endpoint for user groups retrieval 2026-05-04 04:18:15 +05:00
Alexyz 4ddcaec7c8 1 2026-05-04 04:02:50 +05:00
Alexyz 0c19af34c3 feat: visual access control in FlowEditor (read-only mode for viewers) 2026-05-03 21:27:36 +05:00
Alexyz ace515f18e feat: implement group member role management (Admin, Editor, Viewer) 2026-05-03 21:20:37 +05:00
Alexyz f0b5883084 fix: username sanitization, canvas creation modal fixes, and auth headers for tasks 2026-05-03 21:12:55 +05:00
Alexyz 4a912d972e feat: user search, group members management, and fixed canvases filtering 2026-05-03 20:43:18 +05:00
Alexyz 0598e07823 feat: stabilize admin dashboard, refactor group management, implement PWA, and fix routing 2026-05-03 19:51:18 +05:00
Alexyz 622ca04170 feat: added group management UI 2026-05-03 16:41:17 +05:00
Alexyz 4ed6a832d1 fix: use container env for migrations 2026-05-03 15:41:52 +05:00
Alexyz c979975a47 debug: force dev dependencies for prisma 2026-05-03 15:01:44 +05:00
Alexyz 158f3a738b fix: build order for prisma 7 2026-05-03 14:58:32 +05:00
Alexyz 7dbdbde8aa workflow: remove redundant setup-node step 2026-05-02 17:42:33 +05:00
Alexyz b060153761 trigger: test gitea runner workflow 2026-05-02 17:20:14 +05:00
Alexyz 73c323685f chore: align infrastructure with server 2026-05-02 17:20:14 +05:00
Alexyz e8c924f63a data: add db dump for prod migration 2026-04-29 20:13:28 +05:00
Alexyz bc2f99408d feat: AI context, Webhooks, Kanban drag-drop, and UI fixes 2026-04-29 17:43:18 +05:00
Alexyz 89b3340253 feat: implement cross-canvas linking, enhanced node type selection, and canvas management 2026-04-28 23:16:11 +05:00
Alexyz 1f87f0d6e9 feat: enhance task persistence and implement centralized kanban board 2026-04-28 21:55:37 +05:00
Alexyz 7da3fc8ff4 fix: resolve foreign key constraint by ensuring system user exists in seed script 2026-04-28 18:53:24 +05:00
Alexyz 28bf677a0c fix(db): correct prisma initialization in seed script 2026-04-28 17:58:06 +05:00
Alexyz 699e6bbe9f fix(git): improve error reporting and resolve correct repo in GET endpoint 2026-04-28 16:54:57 +05:00
Alexyz 0c5b58e26b fix(git): correct saveCanvasToGitea payload and use prisma 2026-04-28 16:33:24 +05:00
Alexyz f933c17a90 fix: add seed script and repository check endpoint 2026-04-28 16:10:46 +05:00
Alexyz e8714dd00f fix(config): add COOP headers for firebase auth 2026-04-28 15:24:07 +05:00
Alexyz 68e772d2dd fix(ci): pass firebase secrets to docker compose step 2026-04-28 15:07:25 +05:00
Alexyz 3965b7891f fix: safe logout when firebase is not initialized 2026-04-28 14:59:22 +05:00
Alexyz fad9cacd3e fix: pwa, firebase and auth initial load issues 2026-04-28 14:50:13 +05:00
Alexyz 669d8571ae Fix: Stop and remove container before deploy, add node_modules copy 2026-04-28 14:23:36 +05:00
Alexyz e759e9de0a Fix: Add node_modules volume for Prisma 2026-04-28 14:08:10 +05:00
Alexyz ee6ae24916 fix: mark prisma packages as nitro externals to preserve imports 2026-04-27 00:06:25 +05:00
Alexyz d3eb4b87f2 fix: use PrismaPkg.PrismaClient for Nitro ESM compat 2026-04-26 23:51:30 +05:00
Alexyz 1627faa8df trigger: rebuild with fresh cache 2026-04-26 23:40:32 +05:00
Alexyz 764391dc56 fix: use npm install instead of npm ci for new deps 2026-04-26 20:52:01 +05:00
Alexyz 9dde5c21dd fix: Prisma 7 runtime with PrismaPg adapter + pg driver 2026-04-26 20:49:00 +05:00
Alexyz 275f419a41 fix: use default import for @prisma/client CommonJS compat 2026-04-26 17:59:27 +05:00
Alexyz 65f0e7d374 fix: remove npm init -y to preserve package.json 2026-04-26 17:49:41 +05:00
Alexyz bf910adaa3 fix: Prisma 7 import path .prisma/client -> @prisma/client 2026-04-26 17:49:24 +05:00
Alexyz 42899368ed fix: remove :ro from .output mount for prisma client copy 2026-04-26 17:25:43 +05:00
Alexyz b25efbb135 fix: npm init + install prisma in container for migrate 2026-04-26 16:56:45 +05:00
Alexyz 425d3d1430 fix: install prisma before migrate deploy 2026-04-26 16:41:30 +05:00
Alexyz 472024126d fix: remove dotenv from prisma.config.ts for server compat 2026-04-26 16:36:44 +05:00
Alexyz c29684206d fix: revert to driver bridge, remove depends_on to avoid creating other containers 2026-04-26 15:43:32 +05:00
Alexyz 8ad4388d0b fix: use --no-deps and external network to avoid container conflicts 2026-04-26 15:36:56 +05:00
Alexyz d521f9a6c2 fix: copy server/docker-compose.yml during deploy 2026-04-26 15:29:15 +05:00
Alexyz 2663692fb3 fix: add prisma volumes to docker-compose.yml for migrate 2026-04-26 15:22:14 +05:00
Alexyz d9d6caf78d fix: add --config flag to prisma migrate deploy 2026-04-26 15:14:48 +05:00
Alexyz c0afe4a7a5 fix: Prisma 7 compatibility - remove url from schema, add DATABASE_URL to deploy 2026-04-26 14:48:16 +05:00
Alexyz e040d8cacd feat: migrate Canvas and Groups API to Prisma ORM 2026-04-26 14:37:12 +05:00
Alexyz f9e35cb9e7 fix: add auth headers to loadCanvas, add canvas import 2026-04-24 01:42:35 +05:00
Alexyz ade67c0221 feat: add canvas import from .canvas file 2026-04-24 01:30:17 +05:00
Alexyz eb91d95ec0 feat: support personal user repos for canvas storage 2026-04-24 01:18:27 +05:00
Alexyz 53ae4634b9 add: CI/CD session canvas 2026-04-23 2026-04-24 01:11:44 +05:00
Alexyz ec10fb0efe ci: use docker-compose instead of docker compose 2026-04-23 23:19:18 +05:00
Alexyz e4f02ff1fe ci: deploy via direct file copy, no SSH needed 2026-04-23 23:13:39 +05:00
Alexyz 96e52ba047 ci: fix deploy key secret name 2026-04-23 23:04:02 +05:00
Alexyz 61c4e83a1d ci: install rsync+ssh, fix SSH config 2026-04-23 22:57:09 +05:00
Alexyz 01e88c43df ci: trim newlines from token 2026-04-23 22:48:51 +05:00
Alexyz c9ff1e0a81 ci: fix git clone token interpolation 2026-04-23 22:47:56 +05:00
Alexyz 73f8390a71 ci: use sh shell for Alpine container 2026-04-23 22:44:59 +05:00
Alexyz 8860103557 ci: replace checkout action with direct git clone from Gitea 2026-04-23 22:43:12 +05:00
Alexyz 636687d917 ci: use GIT_TOKEN for checkout 2026-04-23 22:40:38 +05:00
Alexyz 73e23817d7 ci: fix checkout to use Gitea server 2026-04-23 22:38:47 +05:00
Alexyz 54f4a331ed ci: fix workflow branch and deploy config 2026-04-23 22:23:36 +05:00
Alexyz 65cf8ca01f feat: prisma schema, groups API, deploy workflow 2026-04-23 21:41:11 +05:00
Alexyz 4c50036172 Clean up: move docs to Obsidian vault, remove outdated md files 2026-04-21 15:54:20 +05:00
Alexyz 448403d7ee R 2026-04-21 02:01:41 +05:00
Alexyz e75b074d0d Fix Gitea ROOT_URL for subdomain git.a-crm.controlsystem.kz 2026-04-21 01:57:48 +05:00
Alexyz aec030cb61 Fix Gitea ROOT_URL for reverse proxy 2026-04-21 01:43:37 +05:00
Alexyz 91f59eec00 Trigger workflow 2026-04-21 01:28:15 +05:00
Alexyz 55c69f821d Trigger workflow 2026-04-21 01:25:47 +05:00
Alexyz 02c02dd8e7 Fix: remove old workflows, keep only self-hosted deploy 2026-04-20 22:57:59 +05:00
Alexyz 3a557fa77b Add auto-deploy workflow and fix dashboard URLs 2026-04-20 22:44:45 +05:00
Alexyz b281778792 Add auto-deploy workflow and fix dashboard URLs 2026-04-20 22:44:10 +05:00
Alexyz 53bfb305c6 fix: Remove node_modules from git and add to .gitignore 2026-04-20 15:21:01 +05:00
Alexyz 9fce574c50 fix: Update nginx config to use dynamic DNS resolution
- Replace upstream blocks with resolver directive
- Use variables for backend addresses to avoid startup errors
- Simplify configuration with single server block
- Add health check endpoint
2026-04-20 15:20:02 +05:00
Alexyz e436aca25b fix: Add package-lock.json for Docker build 2026-04-20 15:11:46 +05:00
Alexyz 263b0c1b64 fix: Update ports to avoid conflicts with existing Docker projects
- Change Gitea port from 3000 to 3001
- Change API port from 8080 to 8082
- Change Nginx port from 80 to 8083
- Add docker-compose.override.yml for local development
- Add deployment guide for existing servers
2026-04-19 23:00:08 +05:00
Alexyz f1e0f6b5ef feat: Add Nanaxe server with Docker, GitHub Actions CI/CD, and auto-deployment
- Add complete server infrastructure with Gitea, API, PostgreSQL
- Implement Obsidian-compatible markdown mindmap converter
- Add Flutter service for server synchronization
- Configure GitHub Actions for automated build and deploy
- Add production docker-compose with health checks
- Include VPS setup and deployment scripts
- Add comprehensive deployment documentation
2026-04-19 22:18:25 +05:00
Alexyz 52aea9ac87 Fix Hive serialization with toMap() method 2026-03-29 01:44:29 +05:00
Alexyz 9272edda19 Fix Hive Timestamp serialization 2026-03-29 01:41:38 +05:00
Alexyz 15e2e2224a Fix UserRole import 2026-03-28 19:52:27 +05:00
Alexyz 28ef21b2f4 Add alarms to tasks, admin role from DB 2026-03-28 19:39:25 +05:00
Alexyz a2f09de04d Fix auth redirect after login 2026-03-28 12:28:14 +05:00
Alexyz 8e6608f788 Fix MindMapNode import 2026-03-28 12:11:17 +05:00
Alexyz f871d048cd Fix all compilation errors - MindMap, Calendar, Tasks 2026-03-28 11:46:56 +05:00
Alexyz 1b5ab70453 Fix compilation errors 2026-03-27 20:13:03 +05:00
Alexyz f1b887fa8d Add Google Calendar integration with sync 2026-03-27 20:01:20 +05:00
Alexyz 26ef99d194 Add Mind-Maps with AI generation and visual graph 2026-03-27 19:53:20 +05:00
Alexyz 789ecd9d3a Add Local Notifications with deadline reminders 2026-03-27 19:47:27 +05:00
Alexyz 88db5daa6b Add Admin panel for team and projects management 2026-03-27 19:08:43 +05:00
Alexyz 6e428b4ede Add Firestore integration with real-time sync 2026-03-27 19:02:43 +05:00
Alexyz 2336df8e86 Add full CRUD tasks with AI integration 2026-03-27 18:57:56 +05:00
Alexyz c5072a8a84 Add AI test screen with DeepSeek integration 2026-03-27 18:40:01 +05:00
Alexyz bc75dc8de1 Setup DeepSeek API with dotenv 2026-03-27 18:38:12 +05:00
Alexyz 26939984ad Add DeepSeek AI service integration 2026-03-27 17:48:58 +05:00
Alexyz 0ff572548e Fix MainActivity package name to com.nanaxe.nanaxe_crm 2026-03-27 15:52:45 +05:00
Alexyz 80458c6491 Remove workmanager (incompatible with current Flutter version) 2026-03-27 15:30:00 +05:00
Alexyz 63ce850d34 Add google-services.json for Firebase 2026-03-27 15:18:30 +05:00
Alexyz e4a7beae04 Add Java 8 desugaring for flutter_local_notifications 2026-03-27 15:12:25 +05:00
Alexyz 9aa4943b2f Rename project from NANAHUI to NANAXE 2026-03-27 14:47:26 +05:00
Alexyz bc2dc81916 Update README with project description 2026-03-27 14:38:56 +05:00
Alexyz 78d15daa35 Initial commit: NANAHUI CRM - Flutter Firebase CRM assistant 2026-03-27 14:38:10 +05:00
254 changed files with 55204 additions and 123 deletions
+18
View File
@@ -0,0 +1,18 @@
node_modules
.output
.nuxt
node_modules/.cache
.git
.gitignore
.env
.env.*
!.env.example
!.env.example.coolify
dist
*.log
*.canvas
# Exclude non-web monorepo apps from Docker build context
apps/mobile
apps/desktop
packages/shared-contracts/dist
+47
View File
@@ -0,0 +1,47 @@
# 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
# OpenAI API (alternative)
# Get from: https://platform.openai.com/api-keys
OPENAI_API_KEY=sk_your_openai_api_key_here
# Anthropic API (alternative)
# Get from: https://console.anthropic.com/
ANTHROPIC_API_KEY=your_anthropic_api_key_here
# 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
+38
View File
@@ -0,0 +1,38 @@
# ========================================================
# XFlow — Coolify Environment Variables Template
# ========================================================
# В Coolify UI:
# 1. Set Compose file = docker-compose.coolify.yml
# 2. Set Dockerfile = Dockerfile.coolify
# 3. Скопируйте эти переменные в раздел Environment Variables проекта.
APP_NAME=xflow
NODE_ENV=production
SITE_URL=https://xflow.yourdomain.com
TZ=Asia/Almaty
# База данных PostgreSQL
POSTGRES_USER=xflow
POSTGRES_PASSWORD=generate-secure-password
POSTGRES_DB=xflow
DATABASE_URL=postgresql://xflow:${POSTGRES_PASSWORD}@db:5432/xflow
# Redis (кеширование и фоновые задачи)
REDIS_URL=redis://redis:6379
# Безопасность и JWT
JWT_SECRET=generate-super-secret-jwt-key-minimum-32-chars
# ИИ провайдеры (OpenRouter / Qwen / DeepSeek)
OPEN_ROUTER_API_KEY=
NUXT_OPEN_ROUTER_API_KEY=
NUXT_QWEN_API_KEY=
DEEPSEEK_API_KEY=
# Firebase Push Notifications (Web Push PWA)
FIREBASE_API_KEY=
FIREBASE_AUTH_DOMAIN=
FIREBASE_PROJECT_ID=
FIREBASE_STORAGE_BUCKET=
FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_APP_ID=
+104
View File
@@ -0,0 +1,104 @@
name: Deploy XFlow (AVTEX/Projects)
on:
push:
branches:
- master
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
container:
image: node:22-alpine
defaults:
run:
shell: sh
steps:
- name: Checkout
env:
GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
run: |
apk add --no-cache git
CLEAN_TOKEN=$(printf '%s' "${GIT_TOKEN}" | tr -d '\r\n')
if [ ! -d ".git" ]; then
git init
git remote add origin "http://oauth2:${CLEAN_TOKEN}@git.a-crm.controlsystem.kz/AVTEX/XFlow.git"
fi
git fetch origin master
git reset --hard origin/master
- name: Install dependencies
run: |
npm install --include=dev
ls -la node_modules/prisma || echo "Prisma not found in node_modules"
- name: Build Nuxt
run: |
npx prisma generate
npm run build
mkdir -p .output/server/node_modules/@prisma
cp -r node_modules/.prisma .output/server/node_modules/
cp -r node_modules/@prisma/* .output/server/node_modules/@prisma/
cp -r node_modules/pg .output/server/node_modules/
env:
DATABASE_URL: "postgresql://admin:dummy@dummy:5432/dummy" # Нужна заглушка для билда призмы
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: Deploy to server
run: |
if ! command -v docker >/dev/null 2>&1; then
if command -v apk >/dev/null 2>&1; then
apk add --no-cache docker-cli
elif command -v apt-get >/dev/null 2>&1; then
apt-get update && apt-get install -y docker.io
else
echo "Docker not found and no package manager detected"
exit 1
fi
fi
echo "🌉 Создаем мост к серверу через временный контейнер..."
docker stop bridge 2>/dev/null || true
docker rm bridge 2>/dev/null || true
docker run -d --name bridge -v /root/projects:/server alpine sleep 300
echo "🧹 Очистка старых файлов на сервере..."
docker exec bridge sh -c "rm -rf /server/xflow/.output/*"
docker exec bridge sh -c "rm -rf /server/xflow/prisma/*"
echo "🚀 Копирование билда и файлов на сервер..."
docker exec bridge mkdir -p /server/xflow/.output
docker exec bridge mkdir -p /server/xflow/prisma
docker exec bridge mkdir -p /server/xflow/node_modules
docker cp .output/. bridge:/server/xflow/.output/
docker cp prisma/. bridge:/server/xflow/prisma/
docker cp node_modules/. bridge:/server/xflow/node_modules/
docker cp package.json bridge:/server/xflow/package.json
docker cp prisma.config.ts bridge:/server/xflow/prisma.config.ts
docker rm -f bridge
echo "🔄 Перезапуск приложения через единый docker-compose.yml на хосте..."
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /root/projects:/root/projects \
-w /root/projects \
docker:cli \
sh -c "docker compose stop xflow_crm 2>/dev/null || true; docker compose rm -f xflow_crm 2>/dev/null || true; docker compose up -d --force-recreate --no-deps xflow_crm"
echo "⏳ Waiting for container to start..."
sleep 5
# Миграции с использованием новой БД xflow_crm_db
docker exec xflow_crm npx prisma migrate deploy
docker exec xflow_crm npx prisma db seed
echo "✅ XFlow CRM deployed at $(date)"
+39 -11
View File
@@ -1,13 +1,41 @@
*.iml # Environment variables
.gradle .env
/local.properties .env.local
local.properties .env.*.local
/.idea/ !/.env.example
# Nuxt
.nuxt
.nitro
.cache
.output
dist
# IDE
.vscode/
.idea/ .idea/
*.iml
*.ipr
*.iws
# Dependencies
node_modules/
# Logs
*.log
logs/
# OS
.DS_Store .DS_Store
/build Thumbs.db
build/
app/build/ # Coverage
/captures coverage/
.externalNativeBuild
.cxx # Temporary files
*.tmp
*.temp
server/api/node_modules/
obsidian-git/
/app/generated/prisma
+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
}
+232
View File
@@ -0,0 +1,232 @@
{
"main": {
"id": "5578cc3a84253281",
"type": "split",
"children": [
{
"id": "08c5d1d78034a917",
"type": "tabs",
"children": [
{
"id": "3f38aefa8626b1f8",
"type": "leaf",
"state": {
"type": "canvas",
"state": {
"file": "session-2026-04-23.canvas",
"viewState": {
"x": -30,
"y": 220,
"zoom": -1.3211285963682102
}
},
"icon": "lucide-layout-dashboard",
"title": "session-2026-04-23"
}
}
]
}
],
"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": [
"docker-compose.dev.yml",
"xflow-project-ru.canvas",
"session-2026-04-23.canvas",
"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/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"
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(flutter *)",
"Bash(dart *)",
"Bash(taskkill *)",
"Bash(select-object *)",
"Bash(findstr *)",
"Bash(dir *)",
"Bash(select-string *)",
"Bash(timeout *)"
]
},
"$version": 3
}
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(flutter *)"
]
}
}
+156
View File
@@ -0,0 +1,156 @@
{
"nodes": [
{
"id": "center",
"type": "text",
"x": 100,
"y": 300,
"width": 350,
"height": 220,
"label": "📅 Отчет за сессию 25 мая 2026",
"text": "# 📅 Сессия: 25 мая 2026 г.\n\nСегодня мы провели масштабное обновление XFlow, интегрировав новые инструменты планирования, приоритезации, а также готовую интерактивную среду для вашего практического задания!\n\n**Ключевые блоки изменений:**\n1. 📋 Шаблон «Мои цели на год»\n2. ⚖️ Тест на истинность цели\n3. 📅 Диаграмма Ганта\n4. 📊 Матрица Эйзенхауэра\n5. 🧪 Сборка проекта Nuxt\n\n*Изучите подключенные узлы для деталей.*",
"style": {
"background": "#1e1b4b",
"border": "#fbbf24"
}
},
{
"id": "block-goals",
"type": "task",
"x": 550,
"y": 50,
"width": 320,
"height": 180,
"label": "📋 Шаблон целей на год",
"state": "completed",
"priority": "high",
"text": "## 📋 Шаблон «Мои цели на год»\n\n* **Тулбар:** Добавлена кнопка `📋 +Цели на Год` для автоматического развертывания карты из 5 целей.\n* **Структура:** Каждая цель пре-генерируется с шаблонами для ввода по методикам SMART и GROW (с полем для шагов и ресурсов).\n* **Связь с Гантом:** Все цели являются задачами и отображаются в таймлайне при заполнении дат.",
"style": {
"background": "#064e3b",
"border": "#10b981"
}
},
{
"id": "block-test",
"type": "task",
"x": 550,
"y": 250,
"width": 320,
"height": 180,
"label": "⚖️ Тест на истинность цели",
"state": "completed",
"priority": "high",
"text": "## ⚖️ Тест на истинность цели\n\n* **Сайдбар:** В свойства узла добавлен интерактивный опросник с 8 вопросами (шкала 1-10) для проверки мотивации по Заданию №2.\n* **Авторасчет:** Автоматически вычисляется сумма.\n* **Запись в узел:** Результат с подробными баллами записывается прямо в Markdown-текст узла по нажатию одной кнопки.",
"style": {
"background": "#0f172a",
"border": "#10b981"
}
},
{
"id": "block-gantt",
"type": "task",
"x": 550,
"y": 450,
"width": 320,
"height": 180,
"label": "📅 Диаграмма Ганта",
"state": "completed",
"priority": "high",
"text": "## 📅 Диаграмма Ганта\n\n* **Компонент:** Создан `GanttChartDrawer.vue` — выдвижной таймлайн снизу.\n* **Узлы задач:** Отслеживает все узлы типа `task` с заполненными датами.\n* **Фокус камеры:** Клик по задаче в списке Ганта плавно центрирует и приближает камеру Vue Flow на этот узел.",
"style": {
"background": "#1e293b",
"border": "#3b82f6"
}
},
{
"id": "block-eisen",
"type": "task",
"x": 550,
"y": 650,
"width": 320,
"height": 180,
"label": "📊 Матрица Эйзенхауэра",
"state": "completed",
"priority": "high",
"text": "## 📊 Матрица Эйзенхауэра\n\n* **2x2 Сетка:** Размещает четыре квадранта (Важно/Срочно, Важно/Несрочно, Неважно/Срочно, Неважно/Несрочно).\n* **ИИ коучинг:** Подключен бэкенд OpenRouter AI для автоматической сортировки списка задач пользователя по 4 квадрантам.",
"style": {
"background": "#451a03",
"border": "#f59e0b"
}
},
{
"id": "block-build",
"type": "task",
"x": 950,
"y": 350,
"width": 320,
"height": 180,
"label": "🧪 Сборка проекта",
"state": "completed",
"priority": "high",
"text": "## 🧪 Валидация сборки\n\n* **Команда:** `npm run build`\n* **Результат:** Успешно пройдено! Ошибок нет.\n* **Готовность:** 100% готовность к завтрашнему использованию на занятии.",
"style": {
"background": "#312e81",
"border": "#6366f1"
}
}
],
"edges": [
{
"id": "edge-goals",
"fromNode": "center",
"fromSide": "right",
"toNode": "block-goals",
"toSide": "left"
},
{
"id": "edge-test",
"fromNode": "center",
"fromSide": "right",
"toNode": "block-test",
"toSide": "left"
},
{
"id": "edge-gantt",
"fromNode": "center",
"fromSide": "right",
"toNode": "block-gantt",
"toSide": "left"
},
{
"id": "edge-eisen",
"fromNode": "center",
"fromSide": "right",
"toNode": "block-eisen",
"toSide": "left"
},
{
"id": "edge-goals-build",
"fromNode": "block-goals",
"fromSide": "right",
"toNode": "block-build",
"toSide": "left"
},
{
"id": "edge-test-build",
"fromNode": "block-test",
"fromSide": "right",
"toNode": "block-build",
"toSide": "left"
},
{
"id": "edge-gantt-build",
"fromNode": "block-gantt",
"fromSide": "right",
"toNode": "block-build",
"toSide": "left"
},
{
"id": "edge-eisen-build",
"fromNode": "block-eisen",
"fromSide": "right",
"toNode": "block-build",
"toSide": "left"
}
]
}
+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**
+111
View File
@@ -0,0 +1,111 @@
# ==========================================
# Stage 1: builder
# ==========================================
FROM node:22-alpine AS builder
ENV HUSKY=0
ENV NODE_ENV=development
ENV NODE_OPTIONS="--max-old-space-size=4096"
WORKDIR /src
RUN apk add --no-cache libc6-compat git openssl
ARG SITE_URL=""
ARG DATABASE_URL="postgresql://xflow:xflow@db:5432/xflow"
ARG REDIS_URL="redis://redis:6379"
ARG JWT_SECRET="super-secret-jwt-key"
ARG OPEN_ROUTER_API_KEY=""
ARG NUXT_OPEN_ROUTER_API_KEY=""
ARG NUXT_QWEN_API_KEY=""
ARG DEEPSEEK_API_KEY=""
ARG FIREBASE_API_KEY=""
ARG FIREBASE_AUTH_DOMAIN=""
ARG FIREBASE_PROJECT_ID=""
ARG FIREBASE_STORAGE_BUCKET=""
ARG FIREBASE_MESSAGING_SENDER_ID=""
ARG FIREBASE_APP_ID=""
ARG TZ="Asia/Almaty"
ENV SITE_URL=${SITE_URL} \
DATABASE_URL=${DATABASE_URL} \
NUXT_DATABASE_URL=${DATABASE_URL} \
REDIS_URL=${REDIS_URL} \
NUXT_REDIS_URL=${REDIS_URL} \
JWT_SECRET=${JWT_SECRET} \
NUXT_JWT_SECRET=${JWT_SECRET} \
OPEN_ROUTER_API_KEY=${OPEN_ROUTER_API_KEY} \
NUXT_OPEN_ROUTER_API_KEY=${NUXT_OPEN_ROUTER_API_KEY} \
NUXT_QWEN_API_KEY=${NUXT_QWEN_API_KEY} \
DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY} \
FIREBASE_API_KEY=${FIREBASE_API_KEY} \
NUXT_FIREBASE_API_KEY=${FIREBASE_API_KEY} \
NUXT_PUBLIC_FIREBASE_API_KEY=${FIREBASE_API_KEY} \
FIREBASE_AUTH_DOMAIN=${FIREBASE_AUTH_DOMAIN} \
NUXT_FIREBASE_AUTH_DOMAIN=${FIREBASE_AUTH_DOMAIN} \
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=${FIREBASE_AUTH_DOMAIN} \
FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID} \
NUXT_FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID} \
NUXT_PUBLIC_FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID} \
FIREBASE_STORAGE_BUCKET=${FIREBASE_STORAGE_BUCKET} \
NUXT_FIREBASE_STORAGE_BUCKET=${FIREBASE_STORAGE_BUCKET} \
NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET=${FIREBASE_STORAGE_BUCKET} \
FIREBASE_MESSAGING_SENDER_ID=${FIREBASE_MESSAGING_SENDER_ID} \
NUXT_FIREBASE_MESSAGING_SENDER_ID=${FIREBASE_MESSAGING_SENDER_ID} \
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${FIREBASE_MESSAGING_SENDER_ID} \
FIREBASE_APP_ID=${FIREBASE_APP_ID} \
NUXT_FIREBASE_APP_ID=${FIREBASE_APP_ID} \
NUXT_PUBLIC_FIREBASE_APP_ID=${FIREBASE_APP_ID} \
TZ=${TZ}
# Копируем package.json и prisma схемы из apps/web
COPY apps/web/package*.json ./
COPY apps/web/prisma ./prisma
RUN npm ci --ignore-scripts && npx prisma generate
# Копируем исходный код apps/web
COPY apps/web ./
RUN HUSKY=0 NODE_ENV=development npx nuxi prepare && HUSKY=0 NODE_ENV=development npm run build
# ==========================================
# Stage 2: runner
# ==========================================
FROM node:22-alpine AS runner
ENV HUSKY=0
ENV NODE_ENV=production
WORKDIR /src
RUN apk add --no-cache tzdata openssl
ARG SITE_URL=""
ARG DATABASE_URL="postgresql://xflow:xflow@db:5432/xflow"
ARG REDIS_URL="redis://redis:6379"
ARG JWT_SECRET="super-secret-jwt-key"
ARG TZ="Asia/Almaty"
ENV SITE_URL=${SITE_URL} \
DATABASE_URL=${DATABASE_URL} \
NUXT_DATABASE_URL=${DATABASE_URL} \
REDIS_URL=${REDIS_URL} \
NUXT_REDIS_URL=${REDIS_URL} \
JWT_SECRET=${JWT_SECRET} \
NUXT_JWT_SECRET=${JWT_SECRET} \
TZ=${TZ}
COPY --from=builder /src/.output ./.output
COPY --from=builder /src/prisma ./prisma
COPY --from=builder /src/package.json ./
COPY --from=builder /src/node_modules ./node_modules
ENV HOST=0.0.0.0
ENV PORT=3000
ENV NUXT_HOST=0.0.0.0
ENV NUXT_PORT=3000
ENV NITRO_HOST=0.0.0.0
ENV NITRO_PORT=3000
EXPOSE 3000
CMD ["sh", "-c", "npx prisma db push --accept-data-loss --skip-generate && exec node .output/server/index.mjs"]
+60 -72
View File
@@ -1,91 +1,79 @@
# Avtex — Smart Sleep & Motion Engine for Home Assistant # 🚀 XFlow — Nuxt 3 PWA
<p align="center"> Визуальный менеджер задач на базе Obsidian Canvas. Работает офлайн, поддерживает уведомления и геймификацию.
<img src="app/src/main/res/drawable/app_logo.png" width="160" alt="Avtex Logo" style="border-radius: 28px;" />
</p>
<p align="center"> ## 📱 Возможности
<b>Легковесное Android-приложение для мгновенной фиксации сна и пробуждения с интеграцией в Home Assistant (HAC)</b><br>
<i>Оптимизировано для OnePlus (OxygenOS / ColorOS) и устройств с ненормированным рабочим графиком.</i>
</p>
--- - ✅ **Obsidian Canvas** — визуальное управление задачами через граф
- ✅ **Markdown ноды** — рендеринг содержимого с подсветкой синтаксиса
- ✅ **PWA + Офлайн** — Service Worker, кэширование, установка на homescreen
- ✅ **JWT Auth** — регистрация, вход, ролевая модель (RBAC)
- ✅ **Web Push** — пуш-уведомления даже при закрытом приложении
- ✅ **Будильники** — локальные вибро-уведомления
- ✅ **Ачивки** — геймификация выполнения задач
- ✅ **Админка** — управление скриптами и рассылкой пушей
## 🌟 Ключевые возможности ## 🛠 Технологический стек
* **Мгновенная фиксация пробуждения (0% расхода батареи)**: | Компонент | Технология |
Использует аппаратный сопроцессор `Sensor.TYPE_SIGNIFICANT_MOTION`. Как только вы берете телефон в руки после сна, сигнал пробуждения моментально передается в умный дом. |-----------|------------|
* **Детекция сна при ненормированном графике**: | Framework | Nuxt 3 (Vue 3) |
Связка таймера покоя (по умолчанию 20 мин при выключенном экране) и нейросетевых классификаций **Google Play Services Sleep API** (`SleepClassifyEvent`). Никаких жестких привязок к часам (23:00–07:00). | Styling | Tailwind CSS |
* **Полная совместимость с Home Assistant (HAC)**: | Canvas | Vue Flow |
* **Webhook URL**: отправка JSON-событий `{"event": "sleep_started" | "sleep_ended", "state": "asleep" | "awake", ...}`. | Markdown | markdown-it |
* **REST State API**: прямое обновление сенсора `binary_sensor.phone_sleep` через Long-Lived Access Token. | PWA | @vite-pwa/nuxt |
* **Local Broadcast Intent**: `com.example.avtex.SLEEP_STATE_CHANGED` для Tasker / Home Assistant Companion App прямо на телефоне. | Backend API | Nitro (встроен в Nuxt) |
* **Устойчивость к убийцам процессов OnePlus**: | Auth | JWT |
* Реализован как Foreground Service с типом `specialUse`.
* Интерактивный экран со вспомогательными кнопками перехода в системные настройки OxygenOS (отключение оптимизации батареи, включение автозапуска).
* **Премиальный дизайн в стиле Cyber Obsidian / Glassmorphism**:
* Jetpack Compose & Material 3.
* Тонкие неоновые контуры, стеклянные карточки, динамический пульсирующий баннер состояния.
* Интерактивная лента аудита событий сенсоров в реальном времени.
---
## 🚀 Быстрый старт ## 🚀 Быстрый старт
### 1. Сборка APK ### 1. Установка зависимостей
Для сборки отладочной версии:
```bash ```bash
./gradlew assembleDebug npm install
``` ```
Для сборки оптимизированного подписанного Release APK: ### 2. Настройка окружения
```bash ```bash
./gradlew assembleRelease cp .env.example .env
# Отредактируй .env (JWT_SECRET, Postgres и т.д.)
``` ```
Файл будет создан в `app/build/outputs/apk/release/app-release.apk`.
### 2. Установка через ADB ### 3. Запуск dev-сервера
```bash ```bash
adb install -r app/build/outputs/apk/release/app-release.apk npm run dev
``` ```
### 4. Сборка для production
```bash
npm run build
npm run preview
```
## 📁 Структура проекта
```
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 (сохранён)
```
## � Доступ
- **Админ** — `admin@xflow.local` / `admin123`
- **Регистрация** — доступна на `/login`
## 📄 Лицензия
Internal use only
--- ---
## ⚙️ Настройка Home Assistant (Пример Webhook автоматизации) **XFlow Team** 🚀
В файле `automations.yaml` или в визуальном редакторе Home Assistant:
```yaml
alias: "Avtex: Автоматизация сна и пробуждения"
trigger:
- platform: webhook
webhook_id: avtex_sleep
action:
- choose:
- conditions: "{{ trigger.json.state == 'asleep' }}"
sequence:
- service: light.turn_off
target:
entity_id: all
- service: climate.set_preset_mode
data:
preset_mode: sleep
target:
entity_id: climate.bedroom
- conditions: "{{ trigger.json.state == 'awake' }}"
sequence:
- service: scene.turn_on
target:
entity_id: scene.morning_lights
```
---
## 🛠 Стек технологий
* **Язык**: Kotlin 2.3+
* **UI**: Jetpack Compose, Material 3, Navigation
* **Сенсоры**: Android Sensor Hub (`TYPE_SIGNIFICANT_MOTION`), Google Play Services Location / Sleep API
* **Сеть**: OkHttp 4.12
* **Хранилище настроек**: Jetpack DataStore Preferences
@@ -1,22 +0,0 @@
package com.example.avtex
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.example.avtex.theme.AvtexTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
AvtexTheme { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { MainNavigation() } }
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

-3
View File
@@ -1,3 +0,0 @@
<resources>
<string name="app_name">Avtex</string>
</resources>
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const CONFIG_FILE = path.join(os.homedir(), '.xflow.json');
function loadConfig() {
if (fs.existsSync(CONFIG_FILE)) {
try {
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
} catch {
// fallback
}
}
return {
serverUrl: process.env.XFLOW_SERVER_URL || 'http://localhost:3000',
userId: process.env.XFLOW_USER_ID || 'default-user'
};
}
function saveConfig(config) {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
console.log(`[XFlow] Настройки сохранены в ${CONFIG_FILE}`);
}
async function addNote(title, listTitle = 'Фильмы на просмотр', category = 'movies') {
const config = loadConfig();
const url = `${config.serverUrl.replace(/\/$/, '')}/api/v1/lists/items`;
console.log(`[XFlow Desktop] Отправка заметки: "${title}" в список "${listTitle}"...`);
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title,
listTitle,
category,
ownerId: config.userId
})
});
const data = await res.json();
if (res.ok && data.success) {
console.log(`\x1b[32m✔ Успешно добавлено!\x1b[0m ID: ${data.item.id}, Статус: ${data.item.status}`);
console.log(` Синхронизировано с сервером и отправлено на мобильный клиент.`);
} else {
console.error(`\x1b[31m✖ Ошибка сервера:\x1b[0m`, data.message || data);
}
} catch (err) {
console.error(`\x1b[31m✖ Не удалось связаться с сервером XFlow (${config.serverUrl}):\x1b[0m ${err.message}`);
console.log(` Убедитесь, что сервер XFlow запущен на ${config.serverUrl}`);
}
}
async function listNotes(category) {
const config = loadConfig();
const query = category ? `?category=${encodeURIComponent(category)}` : '';
const url = `${config.serverUrl.replace(/\/$/, '')}/api/v1/lists${query}`;
try {
const res = await fetch(url);
const data = await res.json();
if (res.ok && data.success) {
console.log(`\n\x1b[1m=== Списки XFlow ===\x1b[0m`);
for (const list of data.lists) {
console.log(`\n📂 \x1b[36m${list.title}\x1b[0m (${list.category}) [${list.items?.length || 0} элементов]:`);
for (const item of list.items || []) {
const check = item.isCompleted ? '☑' : '☐';
console.log(` ${check} ${item.title} \x1b[90m(статус: ${item.status})\x1b[0m`);
}
}
console.log('');
} else {
console.error(`Ошибка:`, data.message);
}
} catch (err) {
console.error(`Ошибка связи с сервером: ${err.message}`);
}
}
async function main() {
const args = process.argv.slice(2);
const command = args[0];
if (!command || command === '--help' || command === '-h') {
console.log(`
\x1b[1m🚀 XFlow CLI — Native Desktop & Terminal Agent Bridge\x1b[0m
Использование:
xflow movie "<название фильма>" Быстро добавить фильм в список на просмотр
xflow add "<заметка>" [--list <список>] Добавить элемент в указанный список
xflow list [категория] Показать списки и элементы
xflow config --url <url> --user <id> Настроить подключение к серверу XFlow
Примеры:
xflow movie "Интерстеллар"
xflow add "Купить молоко" --list "Покупки"
xflow list
`);
return;
}
if (command === 'movie') {
const title = args.slice(1).join(' ');
if (!title) {
console.error('Укажите название фильма: xflow movie "Интерстеллар"');
return;
}
await addNote(title, 'Фильмы на просмотр', 'movies');
return;
}
if (command === 'add') {
const title = args[1];
let listTitle = 'Общий список';
let category = 'general';
for (let i = 2; i < args.length; i++) {
if (args[i] === '--list' && args[i + 1]) {
listTitle = args[i + 1];
if (listTitle.toLowerCase().includes('фильм')) category = 'movies';
if (listTitle.toLowerCase().includes('покупк')) category = 'shopping';
}
}
if (!title) {
console.error('Укажите текст заметки: xflow add "Заметка"');
return;
}
await addNote(title, listTitle, category);
return;
}
if (command === 'list') {
const category = args[1];
await listNotes(category);
return;
}
if (command === 'config') {
const config = loadConfig();
for (let i = 1; i < args.length; i++) {
if (args[i] === '--url' && args[i + 1]) config.serverUrl = args[i + 1];
if (args[i] === '--user' && args[i + 1]) config.userId = args[i + 1];
}
saveConfig(config);
return;
}
console.error(`Неизвестная команда: ${command}. Используйте --help`);
}
main().catch(console.error);
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@xflow/desktop-cli",
"version": "1.0.0",
"description": "XFlow Windows/Linux Native CLI and Agent Bridge",
"type": "module",
"bin": {
"xflow": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts"
},
"dependencies": {
"commander": "^12.0.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const CONFIG_FILE = path.join(os.homedir(), '.xflow.json');
interface XFlowConfig {
serverUrl: string;
userId: string;
apiKey?: string;
}
function loadConfig(): XFlowConfig {
if (fs.existsSync(CONFIG_FILE)) {
try {
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
} catch {
// fallback
}
}
return {
serverUrl: process.env.XFLOW_SERVER_URL || 'http://localhost:3000',
userId: process.env.XFLOW_USER_ID || 'default-user'
};
}
function saveConfig(config: XFlowConfig) {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
console.log(`[XFlow] Настройки сохранены в ${CONFIG_FILE}`);
}
async function addNote(title: string, listTitle: string = 'Фильмы', category: string = 'movies') {
const config = loadConfig();
const url = `${config.serverUrl.replace(/\/$/, '')}/api/v1/lists/items`;
console.log(`[XFlow Desktop] Отправка заметки: "${title}" в список "${listTitle}"...`);
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title,
listTitle,
category,
ownerId: config.userId
})
});
const data = await res.json() as any;
if (res.ok && data.success) {
console.log(`\x1b[32m✔ Успешно добавлено!\x1b[0m ID: ${data.item.id}, Статус: ${data.item.status}`);
console.log(` Синхронизировано с сервером и отправлено на мобильный клиент.`);
} else {
console.error(`\x1b[31m✖ Ошибка сервера:\x1b[0m`, data.message || data);
}
} catch (err: any) {
console.error(`\x1b[31m✖ Не удалось связаться с сервером XFlow (${config.serverUrl}):\x1b[0m ${err.message}`);
console.log(` Убедитесь, что сервер XFlow запущен на ${config.serverUrl}`);
}
}
async function listNotes(category?: string) {
const config = loadConfig();
const query = category ? `?category=${encodeURIComponent(category)}` : '';
const url = `${config.serverUrl.replace(/\/$/, '')}/api/v1/lists${query}`;
try {
const res = await fetch(url);
const data = await res.json() as any;
if (res.ok && data.success) {
console.log(`\n\x1b[1m=== Списки XFlow ===\x1b[0m`);
for (const list of data.lists) {
console.log(`\n📂 \x1b[36m${list.title}\x1b[0m (${list.category}) [${list.items?.length || 0} элементов]:`);
for (const item of list.items || []) {
const check = item.isCompleted ? '☑' : '☐';
console.log(` ${check} ${item.title} \x1b[90m(статус: ${item.status})\x1b[0m`);
}
}
console.log('');
} else {
console.error(`Ошибка:`, data.message);
}
} catch (err: any) {
console.error(`Ошибка связи с сервером: ${err.message}`);
}
}
async function main() {
const args = process.argv.slice(2);
const command = args[0];
if (!command || command === '--help' || command === '-h') {
console.log(`
\x1b[1m🚀 XFlow CLI — Native Desktop & Terminal Agent Bridge\x1b[0m
Использование:
xflow movie "<название фильма>" Быстро добавить фильм в список на просмотр
xflow add "<заметка>" [--list <список>] Добавить элемент в указанный список
xflow list [категория] Показать списки и элементы
xflow config --url <url> --user <id> Настроить подключение к серверу XFlow
Примеры:
xflow movie "Интерстеллар"
xflow add "Купить молоко" --list "Покупки"
xflow list
`);
return;
}
if (command === 'movie') {
const title = args.slice(1).join(' ');
if (!title) {
console.error('Укажите название фильма: xflow movie "Интерстеллар"');
return;
}
await addNote(title, 'Фильмы на просмотр', 'movies');
return;
}
if (command === 'add') {
const title = args[1];
let listTitle = 'Общий список';
let category = 'general';
for (let i = 2; i < args.length; i++) {
if (args[i] === '--list' && args[i + 1]) {
listTitle = args[i + 1];
if (listTitle.toLowerCase().includes('фильм')) category = 'movies';
if (listTitle.toLowerCase().includes('покупк')) category = 'shopping';
}
}
if (!title) {
console.error('Укажите текст заметки: xflow add "Заметка"');
return;
}
await addNote(title, listTitle, category);
return;
}
if (command === 'list') {
const category = args[1];
await listNotes(category);
return;
}
if (command === 'config') {
const config = loadConfig();
for (let i = 1; i < args.length; i++) {
if (args[i] === '--url' && args[i + 1]) config.serverUrl = args[i + 1];
if (args[i] === '--user' && args[i + 1]) config.userId = args[i + 1];
}
saveConfig(config);
return;
}
console.error(`Неизвестная команда: ${command}. Используйте --help`);
}
main().catch(console.error);
+13
View File
@@ -0,0 +1,13 @@
*.iml
.gradle
/local.properties
local.properties
/.idea/
.idea/
.DS_Store
/build
build/
app/build/
/captures
.externalNativeBuild
.cxx
+91
View File
@@ -0,0 +1,91 @@
# Avtex — Smart Sleep & Motion Engine for Home Assistant
<p align="center">
<img src="app/src/main/res/drawable/app_logo.png" width="160" alt="Avtex Logo" style="border-radius: 28px;" />
</p>
<p align="center">
<b>Легковесное Android-приложение для мгновенной фиксации сна и пробуждения с интеграцией в Home Assistant (HAC)</b><br>
<i>Оптимизировано для OnePlus (OxygenOS / ColorOS) и устройств с ненормированным рабочим графиком.</i>
</p>
---
## 🌟 Ключевые возможности
* **Мгновенная фиксация пробуждения (0% расхода батареи)**:
Использует аппаратный сопроцессор `Sensor.TYPE_SIGNIFICANT_MOTION`. Как только вы берете телефон в руки после сна, сигнал пробуждения моментально передается в умный дом.
* **Детекция сна при ненормированном графике**:
Связка таймера покоя (по умолчанию 20 мин при выключенном экране) и нейросетевых классификаций **Google Play Services Sleep API** (`SleepClassifyEvent`). Никаких жестких привязок к часам (23:00–07:00).
* **Полная совместимость с Home Assistant (HAC)**:
* **Webhook URL**: отправка JSON-событий `{"event": "sleep_started" | "sleep_ended", "state": "asleep" | "awake", ...}`.
* **REST State API**: прямое обновление сенсора `binary_sensor.phone_sleep` через Long-Lived Access Token.
* **Local Broadcast Intent**: `com.example.avtex.SLEEP_STATE_CHANGED` для Tasker / Home Assistant Companion App прямо на телефоне.
* **Устойчивость к убийцам процессов OnePlus**:
* Реализован как Foreground Service с типом `specialUse`.
* Интерактивный экран со вспомогательными кнопками перехода в системные настройки OxygenOS (отключение оптимизации батареи, включение автозапуска).
* **Премиальный дизайн в стиле Cyber Obsidian / Glassmorphism**:
* Jetpack Compose & Material 3.
* Тонкие неоновые контуры, стеклянные карточки, динамический пульсирующий баннер состояния.
* Интерактивная лента аудита событий сенсоров в реальном времени.
---
## 🚀 Быстрый старт
### 1. Сборка APK
Для сборки отладочной версии:
```bash
./gradlew assembleDebug
```
Для сборки оптимизированного подписанного Release APK:
```bash
./gradlew assembleRelease
```
Файл будет создан в `app/build/outputs/apk/release/app-release.apk`.
### 2. Установка через ADB
```bash
adb install -r app/build/outputs/apk/release/app-release.apk
```
---
## ⚙️ Настройка Home Assistant (Пример Webhook автоматизации)
В файле `automations.yaml` или в визуальном редакторе Home Assistant:
```yaml
alias: "Avtex: Автоматизация сна и пробуждения"
trigger:
- platform: webhook
webhook_id: avtex_sleep
action:
- choose:
- conditions: "{{ trigger.json.state == 'asleep' }}"
sequence:
- service: light.turn_off
target:
entity_id: all
- service: climate.set_preset_mode
data:
preset_mode: sleep
target:
entity_id: climate.bedroom
- conditions: "{{ trigger.json.state == 'awake' }}"
sequence:
- service: scene.turn_on
target:
entity_id: scene.morning_lights
```
---
## 🛠 Стек технологий
* **Язык**: Kotlin 2.3+
* **UI**: Jetpack Compose, Material 3, Navigation
* **Сенсоры**: Android Sensor Hub (`TYPE_SIGNIFICANT_MOTION`), Google Play Services Location / Sleep API
* **Сеть**: OkHttp 4.12
* **Хранилище настроек**: Jetpack DataStore Preferences
@@ -29,8 +29,22 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Deep link intent-filter for Gemini / Assistant App Actions -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="xflow"
android:host="lists" />
</intent-filter>
</activity> </activity>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<service <service
android:name=".engine.SleepTrackingService" android:name=".engine.SleepTrackingService"
android:enabled="true" android:enabled="true"
@@ -6,16 +6,26 @@ import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import com.example.avtex.data.SettingsRepository import com.example.avtex.data.SettingsRepository
import com.example.avtex.notes.NotesRepository
import com.example.avtex.notes.XFlowSyncClient
class AvtexApplication : Application() { class AvtexApplication : Application() {
lateinit var settingsRepository: SettingsRepository lateinit var settingsRepository: SettingsRepository
private set private set
lateinit var notesRepository: NotesRepository
private set
lateinit var xflowSyncClient: XFlowSyncClient
private set
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
settingsRepository = SettingsRepository(this) settingsRepository = SettingsRepository(this)
notesRepository = NotesRepository(this)
xflowSyncClient = XFlowSyncClient(this)
createNotificationChannels() createNotificationChannels()
} }
@@ -23,10 +33,10 @@ class AvtexApplication : Application() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID_TRACKING, CHANNEL_ID_TRACKING,
"Мониторинг сна Avtex", "Мониторинг XFlow",
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_LOW
).apply { ).apply {
description = "Отображает текущий статус отслеживания сна и активности" description = "Отображает текущий статус мониторинга и синхронизации"
setShowBadge(false) setShowBadge(false)
} }
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -35,7 +45,7 @@ class AvtexApplication : Application() {
} }
companion object { companion object {
const val CHANNEL_ID_TRACKING = "avtex_sleep_tracking_channel" const val CHANNEL_ID_TRACKING = "xflow_tracking_channel"
lateinit var instance: AvtexApplication lateinit var instance: AvtexApplication
private set private set
} }
@@ -0,0 +1,86 @@
package com.example.avtex
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.example.avtex.theme.AvtexTheme
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIncomingIntent(intent)
enableEdgeToEdge()
setContent {
AvtexTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
MainNavigation()
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIncomingIntent(intent)
}
private fun handleIncomingIntent(intent: Intent?) {
if (intent == null) return
// 1. Обработка App Actions через Deep Link xflow://lists/add
val data = intent.data
if (data != null && data.scheme == "xflow") {
val listName = data.getQueryParameter("list_name") ?: data.getQueryParameter("name") ?: "Фильмы на просмотр"
val itemName = data.getQueryParameter("item_name") ?: data.getQueryParameter("item")
if (!itemName.isNullOrBlank()) {
saveItemFromAssistant(itemName, listName)
return
}
}
// 2. Обработка через Intent Extras (Google Assistant Built-in Intents)
val extras = intent.extras
if (extras != null) {
val itemName = extras.getString("itemListElement.name") ?: extras.getString("item_name")
val listName = extras.getString("itemList.name") ?: extras.getString("list_name") ?: "Фильмы на просмотр"
if (!itemName.isNullOrBlank()) {
saveItemFromAssistant(itemName, listName)
}
}
}
private fun saveItemFromAssistant(itemName: String, listName: String) {
lifecycleScope.launch {
val repo = AvtexApplication.instance.notesRepository
val category = if (listName.contains("фильм", ignoreCase = true)) "movies" else "general"
repo.addNoteItem(
title = itemName,
listTitle = listName,
category = category
)
Toast.makeText(
this@MainActivity,
"✅ Записано в XFlow: $itemName ($listName)",
Toast.LENGTH_LONG
).show()
}
}
}
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bedtime import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -28,8 +29,8 @@ import com.example.avtex.theme.NeonViolet
import com.example.avtex.theme.ObsidianBg import com.example.avtex.theme.ObsidianBg
import com.example.avtex.theme.ObsidianSurfaceElevated import com.example.avtex.theme.ObsidianSurfaceElevated
import com.example.avtex.theme.TextMuted import com.example.avtex.theme.TextMuted
import com.example.avtex.theme.TextPrimary
import com.example.avtex.ui.DashboardScreen import com.example.avtex.ui.DashboardScreen
import com.example.avtex.ui.ListsScreen
import com.example.avtex.ui.OnePlusGuideScreen import com.example.avtex.ui.OnePlusGuideScreen
import com.example.avtex.ui.SettingsScreen import com.example.avtex.ui.SettingsScreen
@@ -57,10 +58,10 @@ fun MainNavigation() {
NavigationBarItem( NavigationBarItem(
selected = selectedTab == 0, selected = selectedTab == 0,
onClick = { selectedTab = 0 }, onClick = { selectedTab = 0 },
icon = { Icon(Icons.Default.Bedtime, contentDescription = "Статус") }, icon = { Icon(Icons.Default.Bedtime, contentDescription = "Мониторинг") },
label = { label = {
Text( Text(
"Статус", "Сон",
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = if (selectedTab == 0) FontWeight.Bold else FontWeight.Normal fontWeight = if (selectedTab == 0) FontWeight.Bold else FontWeight.Normal
) )
@@ -70,10 +71,10 @@ fun MainNavigation() {
NavigationBarItem( NavigationBarItem(
selected = selectedTab == 1, selected = selectedTab == 1,
onClick = { selectedTab = 1 }, onClick = { selectedTab = 1 },
icon = { Icon(Icons.Default.Settings, contentDescription = "Настройки") }, icon = { Icon(Icons.AutoMirrored.Filled.FormatListBulleted, contentDescription = "Списки") },
label = { label = {
Text( Text(
"Настройки", "Списки",
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = if (selectedTab == 1) FontWeight.Bold else FontWeight.Normal fontWeight = if (selectedTab == 1) FontWeight.Bold else FontWeight.Normal
) )
@@ -83,12 +84,25 @@ fun MainNavigation() {
NavigationBarItem( NavigationBarItem(
selected = selectedTab == 2, selected = selectedTab == 2,
onClick = { selectedTab = 2 }, onClick = { selectedTab = 2 },
icon = { Icon(Icons.Default.Settings, contentDescription = "Настройки") },
label = {
Text(
"Настройки",
fontSize = 11.sp,
fontWeight = if (selectedTab == 2) FontWeight.Bold else FontWeight.Normal
)
},
colors = navColors
)
NavigationBarItem(
selected = selectedTab == 3,
onClick = { selectedTab = 3 },
icon = { Icon(Icons.Default.Info, contentDescription = "OnePlus") }, icon = { Icon(Icons.Default.Info, contentDescription = "OnePlus") },
label = { label = {
Text( Text(
"OnePlus", "OnePlus",
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = if (selectedTab == 2) FontWeight.Bold else FontWeight.Normal fontWeight = if (selectedTab == 3) FontWeight.Bold else FontWeight.Normal
) )
}, },
colors = navColors colors = navColors
@@ -98,8 +112,9 @@ fun MainNavigation() {
) { innerPadding -> ) { innerPadding ->
when (selectedTab) { when (selectedTab) {
0 -> DashboardScreen(modifier = Modifier.padding(innerPadding)) 0 -> DashboardScreen(modifier = Modifier.padding(innerPadding))
1 -> SettingsScreen(modifier = Modifier.padding(innerPadding)) 1 -> ListsScreen(modifier = Modifier.padding(innerPadding))
2 -> OnePlusGuideScreen(modifier = Modifier.padding(innerPadding)) 2 -> SettingsScreen(modifier = Modifier.padding(innerPadding))
3 -> OnePlusGuideScreen(modifier = Modifier.padding(innerPadding))
} }
} }
} }
@@ -18,10 +18,14 @@ class SleepReceiver : BroadcastReceiver() {
val motion = event.motion val motion = event.motion
val light = event.light val light = event.light
Log.d(TAG, "SleepClassifyEvent: confidence=$confidence, motion=$motion, light=$light") Log.d(TAG, "SleepClassifyEvent: confidence=$confidence, motion=$motion, light=$light")
AvtexApplication.instance.settingsRepository.addLog( val now = System.currentTimeMillis()
tag = "SleepAPI", if (now - lastClassifyLogTimestamp >= MIN_CLASSIFY_LOG_INTERVAL_MS) {
message = "Классификация: уверенность $confidence%, движение $motion/6, свет $light/6" lastClassifyLogTimestamp = now
) AvtexApplication.instance.settingsRepository.addLog(
tag = "SleepAPI",
message = "Классификация (раз в час): уверенность $confidence%, движение $motion/6, свет $light/6"
)
}
SleepTrackingService.onSleepClassifyReceived(context, confidence, motion, light) SleepTrackingService.onSleepClassifyReceived(context, confidence, motion, light)
} }
} }
@@ -43,5 +47,8 @@ class SleepReceiver : BroadcastReceiver() {
companion object { companion object {
private const val TAG = "SleepReceiver" private const val TAG = "SleepReceiver"
const val ACTION_SLEEP_API_EVENT = "com.example.avtex.ACTION_SLEEP_API_EVENT" const val ACTION_SLEEP_API_EVENT = "com.example.avtex.ACTION_SLEEP_API_EVENT"
private const val MIN_CLASSIFY_LOG_INTERVAL_MS = 60 * 60 * 1000L // Минимум 1 раз в час
@Volatile
private var lastClassifyLogTimestamp: Long = 0L
} }
} }
@@ -0,0 +1,24 @@
package com.example.avtex.notes
data class NoteItem(
val id: String,
val listId: String,
val title: String,
val description: String = "",
val isCompleted: Boolean = false,
val status: String = "todo", // "want_to_watch", "watched", "todo", "completed"
val priority: String = "normal",
val metadata: String = "{}",
val createdAt: Long = System.currentTimeMillis()
)
data class NoteList(
val id: String,
val title: String,
val category: String = "general", // "movies", "shopping", "tasks", "general"
val description: String = "",
val color: String? = null,
val icon: String? = null,
val items: List<NoteItem> = emptyList(),
val createdAt: Long = System.currentTimeMillis()
)
@@ -0,0 +1,189 @@
package com.example.avtex.notes
import android.content.ContentValues
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
class NotesRepository(context: Context) {
private val dbHelper = XFlowDatabaseHelper(context)
private val scope = CoroutineScope(Dispatchers.IO)
private val _listsFlow = MutableStateFlow<List<NoteList>>(emptyList())
val listsFlow: StateFlow<List<NoteList>> = _listsFlow.asStateFlow()
init {
reload()
}
fun reload() {
scope.launch {
val lists = loadListsFromDb()
_listsFlow.value = lists
}
}
private suspend fun loadListsFromDb(): List<NoteList> = withContext(Dispatchers.IO) {
val db = dbHelper.readableDatabase
val lists = mutableListOf<NoteList>()
val cursorLists = db.query(
XFlowDatabaseHelper.TABLE_LISTS,
null, null, null, null, null, "created_at DESC"
)
cursorLists.use { cLists ->
while (cLists.moveToNext()) {
val listId = cLists.getString(cLists.getColumnIndexOrThrow("id"))
val title = cLists.getString(cLists.getColumnIndexOrThrow("title"))
val category = cLists.getString(cLists.getColumnIndexOrThrow("category"))
val description = cLists.getString(cLists.getColumnIndexOrThrow("description")) ?: ""
val color = cLists.getString(cLists.getColumnIndexOrThrow("color"))
val icon = cLists.getString(cLists.getColumnIndexOrThrow("icon"))
val createdAt = cLists.getLong(cLists.getColumnIndexOrThrow("created_at"))
// Загружаем элементы списка
val items = mutableListOf<NoteItem>()
val cursorItems = db.query(
XFlowDatabaseHelper.TABLE_ITEMS,
null, "list_id = ?", arrayOf(listId), null, null, "created_at DESC"
)
cursorItems.use { cItems ->
while (cItems.moveToNext()) {
val itemId = cItems.getString(cItems.getColumnIndexOrThrow("id"))
val itemTitle = cItems.getString(cItems.getColumnIndexOrThrow("title"))
val itemDesc = cItems.getString(cItems.getColumnIndexOrThrow("description")) ?: ""
val isCompleted = cItems.getInt(cItems.getColumnIndexOrThrow("is_completed")) == 1
val status = cItems.getString(cItems.getColumnIndexOrThrow("status"))
val priority = cItems.getString(cItems.getColumnIndexOrThrow("priority"))
val metadata = cItems.getString(cItems.getColumnIndexOrThrow("metadata")) ?: "{}"
val itemCreatedAt = cItems.getLong(cItems.getColumnIndexOrThrow("created_at"))
items.add(
NoteItem(
id = itemId,
listId = listId,
title = itemTitle,
description = itemDesc,
isCompleted = isCompleted,
status = status,
priority = priority,
metadata = metadata,
createdAt = itemCreatedAt
)
)
}
}
lists.add(
NoteList(
id = listId,
title = title,
category = category,
description = description,
color = color,
icon = icon,
items = items,
createdAt = createdAt
)
)
}
}
lists
}
suspend fun addNoteItem(
title: String,
listTitle: String = "Фильмы на просмотр",
category: String = "movies",
description: String = ""
): NoteItem = withContext(Dispatchers.IO) {
val db = dbHelper.writableDatabase
// Ищем список по названию
var targetListId: String? = null
val c = db.query(
XFlowDatabaseHelper.TABLE_LISTS,
arrayOf("id"), "LOWER(title) = LOWER(?)", arrayOf(listTitle.trim()),
null, null, null
)
c.use {
if (it.moveToFirst()) {
targetListId = it.getString(0)
}
}
// Если список не найден, создаем его
if (targetListId == null) {
val newListId = "list-" + UUID.randomUUID().toString()
val cvList = ContentValues().apply {
put("id", newListId)
put("title", listTitle.trim())
put("category", category)
put("description", "Список $listTitle")
put("created_at", System.currentTimeMillis())
}
db.insert(XFlowDatabaseHelper.TABLE_LISTS, null, cvList)
targetListId = newListId
}
val newItemId = "item-" + UUID.randomUUID().toString()
val now = System.currentTimeMillis()
val cvItem = ContentValues().apply {
put("id", newItemId)
put("list_id", targetListId)
put("title", title.trim())
put("description", description)
put("is_completed", 0)
put("status", if (category == "movies") "want_to_watch" else "todo")
put("priority", "normal")
put("metadata", "{}")
put("created_at", now)
}
db.insert(XFlowDatabaseHelper.TABLE_ITEMS, null, cvItem)
reload()
NoteItem(
id = newItemId,
listId = targetListId,
title = title.trim(),
description = description,
isCompleted = false,
status = if (category == "movies") "want_to_watch" else "todo",
createdAt = now
)
}
suspend fun toggleItem(item: NoteItem) = withContext(Dispatchers.IO) {
val db = dbHelper.writableDatabase
val newCompleted = !item.isCompleted
val newStatus = if (newCompleted) {
if (item.status == "want_to_watch") "watched" else "completed"
} else {
if (item.status == "watched") "want_to_watch" else "todo"
}
val cv = ContentValues().apply {
put("is_completed", if (newCompleted) 1 else 0)
put("status", newStatus)
}
db.update(XFlowDatabaseHelper.TABLE_ITEMS, cv, "id = ?", arrayOf(item.id))
reload()
}
suspend fun deleteItem(itemId: String) = withContext(Dispatchers.IO) {
val db = dbHelper.writableDatabase
db.delete(XFlowDatabaseHelper.TABLE_ITEMS, "id = ?", arrayOf(itemId))
reload()
}
}
@@ -0,0 +1,72 @@
package com.example.avtex.notes
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
class XFlowDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL("""
CREATE TABLE $TABLE_LISTS (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
category TEXT NOT NULL,
description TEXT,
color TEXT,
icon TEXT,
created_at INTEGER NOT NULL
)
""".trimIndent())
db.execSQL("""
CREATE TABLE $TABLE_ITEMS (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
is_completed INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
priority TEXT NOT NULL,
metadata TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY(list_id) REFERENCES $TABLE_LISTS(id) ON DELETE CASCADE
)
""".trimIndent())
// Создаем дефолтные списки для удобства пользователя
val now = System.currentTimeMillis()
val cvMovies = ContentValues().apply {
put("id", "list-movies-default")
put("title", "Фильмы на просмотр")
put("category", "movies")
put("description", "Список фильмов и сериалов")
put("created_at", now)
}
db.insert(TABLE_LISTS, null, cvMovies)
val cvShopping = ContentValues().apply {
put("id", "list-shopping-default")
put("title", "Покупки")
put("category", "shopping")
put("description", "Список покупок")
put("created_at", now)
}
db.insert(TABLE_LISTS, null, cvShopping)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("DROP TABLE IF EXISTS $TABLE_ITEMS")
db.execSQL("DROP TABLE IF EXISTS $TABLE_LISTS")
onCreate(db)
}
companion object {
private const val DATABASE_NAME = "xflow_notes.db"
private const val DATABASE_VERSION = 1
const val TABLE_LISTS = "lists"
const val TABLE_ITEMS = "items"
}
}
@@ -0,0 +1,65 @@
package com.example.avtex.notes
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class XFlowSyncClient(private val context: Context) {
private val httpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
suspend fun syncItemToServer(
serverUrl: String,
title: String,
listTitle: String,
category: String,
ownerId: String = "default-user"
): Result<String> = withContext(Dispatchers.IO) {
if (serverUrl.isBlank()) {
return@withContext Result.failure(IllegalArgumentException("URL сервера XFlow не указан"))
}
try {
val endpoint = "${serverUrl.trimEnd('/')}/api/v1/lists/items"
val json = JSONObject().apply {
put("title", title)
put("listTitle", listTitle)
put("category", category)
put("ownerId", ownerId)
}
val request = Request.Builder()
.url(endpoint)
.post(json.toString().toRequestBody(jsonMediaType))
.build()
val response = httpClient.newCall(request).execute()
response.use {
if (it.isSuccessful) {
Result.success("Синхронизировано с XFlow")
} else {
Result.failure(Exception("HTTP ${it.code}: ${it.message}"))
}
}
} catch (e: Exception) {
Log.e(TAG, "Sync to XFlow server failed", e)
Result.failure(e)
}
}
companion object {
private const val TAG = "XFlowSyncClient"
}
}
@@ -0,0 +1,407 @@
package com.example.avtex.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material.icons.filled.TaskAlt
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.avtex.AvtexApplication
import com.example.avtex.notes.NoteItem
import com.example.avtex.notes.NoteList
import com.example.avtex.theme.CyanAccent
import com.example.avtex.theme.ElectricIndigo
import com.example.avtex.theme.EmeraldMint
import com.example.avtex.theme.GlassBorder
import com.example.avtex.theme.NeonViolet
import com.example.avtex.theme.ObsidianBg
import com.example.avtex.theme.ObsidianSurface
import com.example.avtex.theme.ObsidianSurfaceElevated
import com.example.avtex.theme.ObsidianSurfaceHigh
import com.example.avtex.theme.SolarAmber
import com.example.avtex.theme.TextMuted
import com.example.avtex.theme.TextPrimary
import com.example.avtex.theme.TextSecondary
import kotlinx.coroutines.launch
@Composable
fun ListsScreen(modifier: Modifier = Modifier) {
val repository = remember { AvtexApplication.instance.notesRepository }
val lists by repository.listsFlow.collectAsState()
val coroutineScope = rememberCoroutineScope()
var selectedCategory by remember { mutableStateOf("all") }
var newItemText by remember { mutableStateOf("") }
var isAddingActive by remember { mutableStateOf(false) }
val categories = listOf(
"all" to "Все",
"movies" to "🎬 Фильмы",
"shopping" to "🛒 Покупки",
"tasks" to "⚡ Дела"
)
val filteredLists = remember(lists, selectedCategory) {
if (selectedCategory == "all") lists else lists.filter { it.category == selectedCategory }
}
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(ObsidianBg)
.padding(horizontal = 16.dp),
contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Header
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "XFlow Заметки & Списки",
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = TextPrimary
)
Text(
text = "Мульти-записная книжка • Голос Gemini • Синхронизация",
fontSize = 12.sp,
color = TextMuted
)
}
IconButton(
onClick = { repository.reload() },
modifier = Modifier
.size(38.dp)
.clip(CircleShape)
.background(ObsidianSurfaceElevated)
.border(1.dp, GlassBorder, CircleShape)
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "Обновить",
tint = CyanAccent,
modifier = Modifier.size(18.dp)
)
}
}
}
// Category Filter Chips
item {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
items(categories) { (key, label) ->
val isSelected = selectedCategory == key
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(if (isSelected) NeonViolet.copy(alpha = 0.25f) else ObsidianSurfaceElevated)
.border(
1.dp,
if (isSelected) NeonViolet else GlassBorder,
RoundedCornerShape(12.dp)
)
.clickable { selectedCategory = key }
.padding(horizontal = 14.dp, vertical = 8.dp)
) {
Text(
text = label,
fontSize = 12.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = if (isSelected) TextPrimary else TextSecondary
)
}
}
}
}
// Quick Add Item Bar
item {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(ObsidianSurface)
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
.padding(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = newItemText,
onValueChange = { newItemText = it },
placeholder = {
Text(
if (selectedCategory == "movies") "Название фильма на просмотр..." else "Новая запись...",
fontSize = 13.sp,
color = TextMuted
)
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NeonViolet,
unfocusedBorderColor = GlassBorder,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
cursorColor = NeonViolet
),
singleLine = true,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp)
)
Button(
onClick = {
if (newItemText.isNotBlank()) {
coroutineScope.launch {
val targetCategory = if (selectedCategory == "all") "movies" else selectedCategory
val listTitle = when (targetCategory) {
"movies" -> "Фильмы на просмотр"
"shopping" -> "Покупки"
else -> "Общий список"
}
repository.addNoteItem(
title = newItemText.trim(),
listTitle = listTitle,
category = targetCategory
)
newItemText = ""
}
}
},
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.height(52.dp)
) {
Icon(Icons.Default.Add, contentDescription = "Добавить", tint = Color.White)
}
}
}
}
// Lists and Items
if (filteredLists.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 40.dp),
contentAlignment = Alignment.Center
) {
Text(
"Нет записей. Добавьте первую заметку или скажите Gemini!",
fontSize = 13.sp,
color = TextMuted
)
}
}
} else {
items(filteredLists, key = { it.id }) { list ->
ListCard(
list = list,
onToggleItem = { item ->
coroutineScope.launch { repository.toggleItem(item) }
},
onDeleteItem = { item ->
coroutineScope.launch { repository.deleteItem(item.id) }
}
)
}
}
}
}
@Composable
private fun ListCard(
list: NoteList,
onToggleItem: (NoteItem) -> Unit,
onDeleteItem: (NoteItem) -> Unit
) {
val accentColor = when (list.category) {
"movies" -> CyanAccent
"shopping" -> EmeraldMint
"tasks" -> SolarAmber
else -> NeonViolet
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(ObsidianSurface)
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// List Title Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(accentColor)
)
Text(
text = list.title,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = TextPrimary
)
}
Text(
text = "${list.items.count { !it.isCompleted }} активных",
fontSize = 11.sp,
color = TextMuted
)
}
// Items list
if (list.items.isEmpty()) {
Text(
text = "Список пуст",
fontSize = 12.sp,
color = TextMuted,
modifier = Modifier.padding(start = 16.dp)
)
} else {
list.items.forEach { item ->
ItemRow(
item = item,
accentColor = accentColor,
onToggle = { onToggleItem(item) },
onDelete = { onDeleteItem(item) }
)
}
}
}
}
@Composable
private fun ItemRow(
item: NoteItem,
accentColor: Color,
onToggle: () -> Unit,
onDelete: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(ObsidianSurfaceElevated)
.border(1.dp, GlassBorder.copy(alpha = 0.5f), RoundedCornerShape(10.dp))
.clickable { onToggle() }
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = item.isCompleted,
onCheckedChange = { onToggle() },
colors = CheckboxDefaults.colors(
checkedColor = accentColor,
uncheckedColor = TextMuted,
checkmarkColor = ObsidianBg
),
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
fontSize = 14.sp,
color = if (item.isCompleted) TextMuted else TextPrimary,
textDecoration = if (item.isCompleted) TextDecoration.LineThrough else TextDecoration.None
)
if (item.status == "want_to_watch") {
Text(
text = "🎬 Буду смотреть",
fontSize = 10.sp,
color = CyanAccent
)
} else if (item.status == "watched") {
Text(
text = "✔ Просмотрено",
fontSize = 10.sp,
color = EmeraldMint
)
}
}
IconButton(
onClick = onDelete,
modifier = Modifier.size(28.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Удалить",
tint = TextMuted,
modifier = Modifier.size(15.dp)
)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Before

Width:  |  Height:  |  Size: 688 B

After

Width:  |  Height:  |  Size: 688 B

Before

Width:  |  Height:  |  Size: 866 B

After

Width:  |  Height:  |  Size: 866 B

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 434 B

Before

Width:  |  Height:  |  Size: 570 B

After

Width:  |  Height:  |  Size: 570 B

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">XFlow</string>
</resources>
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Добавление элемента в список через Gemini / Google Assistant -->
<capability android:name="actions.intent.ADD_ITEM_TO_LIST">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="com.example.avtex"
android:targetClass="com.example.avtex.MainActivity"
android:data="xflow://lists/add">
<parameter
android:name="itemList.name"
android:key="list_name" />
<parameter
android:name="itemListElement.name"
android:key="item_name" />
</intent>
</capability>
<!-- Создание списка через Gemini / Google Assistant -->
<capability android:name="actions.intent.CREATE_ITEM_LIST">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="com.example.avtex"
android:targetClass="com.example.avtex.MainActivity"
android:data="xflow://lists/create">
<parameter
android:name="itemList.name"
android:key="list_name" />
</intent>
</capability>
<!-- Просмотр списка -->
<capability android:name="actions.intent.GET_ITEM_LIST">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="com.example.avtex"
android:targetClass="com.example.avtex.MainActivity"
android:data="xflow://lists/view">
<parameter
android:name="itemList.name"
android:key="list_name" />
</intent>
</capability>
</shortcuts>
Vendored Executable → Regular
View File
View File
+47
View File
@@ -0,0 +1,47 @@
# 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
# OpenAI API (alternative)
# Get from: https://platform.openai.com/api-keys
OPENAI_API_KEY=sk_your_openai_api_key_here
# Anthropic API (alternative)
# Get from: https://console.anthropic.com/
ANTHROPIC_API_KEY=your_anthropic_api_key_here
# 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
+22
View File
@@ -0,0 +1,22 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<script setup>
const { initAuth } = useAuth()
initAuth()
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;
}
}
@@ -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>
+46
View File
@@ -0,0 +1,46 @@
<template>
<nav class="md:hidden fixed bottom-0 left-0 right-0 bg-slate-900/80 backdrop-blur-xl border-t border-slate-800 pb-safe z-40">
<div class="flex justify-around items-center h-16">
<NuxtLink to="/" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
<span class="text-xl">🏠</span>
<span class="text-[10px] font-medium">Главная</span>
</NuxtLink>
<NuxtLink to="/canvases" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
<span class="text-xl">🗺️</span>
<span class="text-[10px] font-medium">Канвасы</span>
</NuxtLink>
<NuxtLink to="/tasks" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
<span class="text-xl">✅</span>
<span class="text-[10px] font-medium">Задачи</span>
</NuxtLink>
<NuxtLink v-if="isAdmin" to="/admin" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-red-500">
<span class="text-xl">🛡️</span>
<span class="text-[10px] font-medium">Админ</span>
</NuxtLink>
<NuxtLink to="/settings" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
<span class="text-xl">⚙️</span>
<span class="text-[10px] font-medium">Опции</span>
</NuxtLink>
<NuxtLink to="/profile" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
<span class="text-xl">👤</span>
<span class="text-[10px] font-medium">Профиль</span>
</NuxtLink>
</div>
</nav>
</template>
<script setup>
const auth = useAuth()
const isAdmin = computed(() => auth.user.value?.role === 'admin')
</script>
<style scoped>
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
</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>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,303 @@
<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="type === 'task' && data.state" :class="stateBadgeClass">
{{ stateLabel }}
</div>
<div v-if="type === 'task' && data.priority" :class="priorityBadgeClass">
{{ priorityLabel }}
</div>
<!-- Кнопка запуска для спец-узлов -->
<button
v-if="['ai', 'webhook'].includes(type)"
@click.stop="handleExecute"
:disabled="isExecuting"
class="ml-auto p-1.5 hover:bg-slate-700 rounded-md transition-colors group relative"
title="Запустить выполнение"
>
<span v-if="isExecuting" class="block animate-spin text-blue-400">🌀</span>
<span v-else class="block group-hover:scale-110 transition-transform">▶️</span>
</button>
</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', 'execute'])
const isExecuting = ref(false)
const handleExecute = async () => {
if (isExecuting.value) return
isExecuting.value = true
try {
emit('execute', { id: props.id, type: props.type })
} finally {
// Состояние загрузки сбрасывается внешним событием или по таймауту для безопасности
setTimeout(() => { isExecuting.value = false }, 10000)
}
}
// 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' // Green
} else if (props.data.state === 'in_progress') {
baseStyle.borderColor = '#3b82f6' // Blue
} else if (props.data.state === 'review') {
baseStyle.borderColor = '#a855f7' // Purple
} else if (props.data.state === 'blocked') {
baseStyle.borderColor = '#ef4444' // Red
} else if (props.data.state === 'todo') {
baseStyle.borderColor = '#64748b' // Slate
}
return baseStyle
})
// 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-blue-600 bg-blue-950/20'
if (state === 'review') return 'border-purple-600 bg-purple-950/20'
if (state === 'blocked') return 'border-red-600 bg-red-950/20'
if (state === 'todo') return 'border-slate-500 bg-slate-900/50'
return ''
})
const stateBadgeClass = computed(() => {
const state = props.data.state
if (state === 'completed') return 'px-1.5 py-0.5 bg-emerald-800/50 text-emerald-300 rounded text-[10px] font-medium'
if (state === 'in_progress') return 'px-1.5 py-0.5 bg-blue-800/50 text-blue-300 rounded text-[10px] font-medium'
if (state === 'review') return 'px-1.5 py-0.5 bg-purple-800/50 text-purple-300 rounded text-[10px] font-medium'
if (state === 'blocked') return 'px-1.5 py-0.5 bg-red-800/50 text-red-300 rounded text-[10px] font-medium'
return 'px-1.5 py-0.5 bg-slate-700 text-slate-300 rounded text-[10px] font-medium'
})
const stateLabel = computed(() => {
const state = props.data.state
if (state === 'completed') return '✅ Done'
if (state === 'in_progress') return '⏳ In Progress'
if (state === 'review') return '👀 Review'
if (state === 'blocked') return '⛔ Blocked'
return '📋 To Do'
})
// Priority handling
const priorityBadgeClass = computed(() => {
const prio = props.data.priority
if (prio === 'high') return 'px-1.5 py-0.5 bg-red-800/50 text-red-300 rounded text-[10px] font-bold'
if (prio === 'low') return 'px-1.5 py-0.5 bg-slate-800/50 text-slate-400 rounded text-[10px] font-medium'
return 'px-1.5 py-0.5 bg-blue-800/50 text-blue-300 rounded text-[10px] font-medium' // medium
})
const priorityLabel = computed(() => {
const prio = props.data.priority
if (prio === 'high') return '🔥 High'
if (prio === 'low') return '🧊 Low'
return '⚡ Med'
})
// Get node title from text content
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>
+51
View File
@@ -0,0 +1,51 @@
<template>
<div v-if="deferredPrompt && !isStandalone" class="fixed bottom-6 right-6 z-50">
<button
@click="installApp"
class="bg-blue-600 hover:bg-blue-500 text-white px-6 py-3 rounded-2xl font-bold shadow-2xl flex items-center gap-3 transition-all transform hover:scale-105 active:scale-95 group"
>
<span class="text-xl group-hover:rotate-12 transition-transform">📲</span>
<span>Установить XFlow</span>
</button>
</div>
</template>
<script setup>
const deferredPrompt = ref(null)
const isStandalone = ref(false)
onMounted(() => {
isStandalone.value = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
deferredPrompt.value = e
})
window.addEventListener('appinstalled', () => {
deferredPrompt.value = null
})
})
const installApp = async () => {
if (!deferredPrompt.value) {
alert('Браузер еще не подготовил установку. Пожалуйста, подождите 10-20 секунд или проверьте вкладку Application в консоли.')
return
}
deferredPrompt.value.prompt()
const { outcome } = await deferredPrompt.value.userChoice
console.log(`PWA: user choice outcome: ${outcome}`)
deferredPrompt.value = null
}
</script>
<style scoped>
@keyframes bounce-subtle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
.animate-bounce-subtle {
animation: bounce-subtle 3s ease-in-out infinite;
}
</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>
@@ -0,0 +1,266 @@
<template>
<Teleport to="body">
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 backdrop-blur-sm p-4 font-sans">
<div class="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl overflow-hidden">
<!-- Modal Header -->
<div class="px-6 py-4 border-b border-slate-800 flex justify-between items-center bg-slate-900/50">
<h2 class="text-base font-bold text-slate-200 flex items-center gap-2">
<span>🧙‍♂️</span> Коуч-Ассистент: Конструктор SMART & GROW
</h2>
<button @click="$emit('close')" class="text-slate-400 hover:text-white transition">✕</button>
</div>
<!-- Tabs -->
<div class="flex border-b border-slate-800 bg-slate-950/40">
<button
@click="wizardType = 'smart'"
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'smart' ? 'text-indigo-400 border-indigo-500 bg-indigo-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
>
🎯 SMART Цель
</button>
<button
@click="wizardType = 'grow'"
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'grow' ? 'text-teal-400 border-teal-500 bg-teal-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
>
🌱 GROW Сессия
</button>
<button
@click="wizardType = 'dilts'"
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'dilts' ? 'text-violet-400 border-violet-500 bg-violet-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
>
📐 Пирамида Дилтса
</button>
<button
@click="wizardType = 'eisenhower'"
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'eisenhower' ? 'text-amber-400 border-amber-500 bg-amber-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
>
📊 Матрица Эйзенхауэра
</button>
</div>
<!-- Modal Body (Forms) -->
<div class="p-6 overflow-y-auto flex-1 space-y-4">
<!-- S.M.A.R.T Form -->
<div v-if="wizardType === 'smart'" class="space-y-4">
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">S (Specific) — Какая конкретная задача?</label>
<textarea v-model="smartForm.specific" rows="2" placeholder="Что именно вы хотите сделать? (например: Разработать модуль экспорта canvas)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">M (Measurable) — Как вы измерите успех?</label>
<textarea v-model="smartForm.measurable" rows="2" placeholder="Укажите критерии готовности (каждую метрику с новой строки)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">A (Achievable) — За счет чего цель достижима?</label>
<textarea v-model="smartForm.achievable" rows="2" placeholder="Какими ресурсами и навыками вы располагаете?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">R (Relevant) — Почему эта цель важна?</label>
<textarea v-model="smartForm.relevant" rows="2" placeholder="Какую пользу принесет выполнение этой цели общему проекту?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">T (Time-bound) — Каков дедлайн?</label>
<input v-model="smartForm.deadline" type="datetime-local" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none" />
</div>
</div>
<!-- G.R.O.W Form -->
<div v-if="wizardType === 'grow'" class="space-y-4">
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">G (Goal) — Цель</label>
<textarea v-model="growForm.goal" rows="3" placeholder="Какой именно цели я хочу достичь?&#10;Какой результат я хочу получить?&#10;Почему достижение этой цели важно?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">O (Options) — Варианты</label>
<textarea v-model="growForm.options" rows="4" placeholder="Что именно я могу изменить?&#10;Какие ресурсы мне нужны?&#10;Кто мне может помочь?&#10;А что еще?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">R (Reality) — Реальность</label>
<textarea v-model="growForm.reality" rows="4" placeholder="Какова текущая ситуация?&#10;Какие препятствия я вижу?&#10;Что мне мешает начать?&#10;Какие у меня уже есть ресурсы?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">W (Way Forward) — План</label>
<textarea v-model="growForm.wayForward" rows="3" placeholder="Какие самые важные действия необходимо предпринять?&#10;Что я сделаю в первую очередь? Какие сроки?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
</div>
</div>
<!-- Dilts Form -->
<div v-if="wizardType === 'dilts'" class="space-y-4">
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">🌟 Миссия — Ради кого? Кому еще это важно? (БЫТЬ)</label>
<textarea v-model="diltsForm.mission" rows="2" placeholder="Ради кого? Кому еще это важно? (БЫТЬ)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">👤 Я — Кто я?</label>
<textarea v-model="diltsForm.identity" rows="2" placeholder="Кто я?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">💎 Ценности — Почему это важно?</label>
<textarea v-model="diltsForm.values" rows="2" placeholder="Почему это важно?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">🧠 Навыки — Как? Каким образом?</label>
<textarea v-model="diltsForm.skills" rows="2" placeholder="Как? Каким образом?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">🎬 Действия — Что делать? (ДЕЛАТЬ)</label>
<textarea v-model="diltsForm.actions" rows="2" placeholder="Что делать? (ДЕЛАТЬ)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">🌍 Окружение — Где? Когда? С кем? (ИМЕТЬ)</label>
<textarea v-model="diltsForm.environment" rows="2" placeholder="Где? Когда? С кем? (ИМЕТЬ)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
</div>
</div>
<!-- Eisenhower Form -->
<div v-if="wizardType === 'eisenhower'" class="space-y-4">
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">🔥 Квадрант I: Важно и Срочно (Сделать сейчас)</label>
<textarea v-model="eisenhowerForm.q1" rows="2" placeholder="Кризисы, горящие дедлайны, важные неотложные задачи" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">⚡ Квадрант II: Важно, но Несрочно (Запланировать)</label>
<textarea v-model="eisenhowerForm.q2" rows="2" placeholder="Долгосрочное планирование, здоровье, обучение, стратегические цели" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">🧊 Квадрант III: Неважно, но Срочно (Делегировать)</label>
<textarea v-model="eisenhowerForm.q3" rows="2" placeholder="Входящие рутинные звонки/письма, мелкие поручения, задачи, которые можно перепоручить" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">💤 Квадрант IV: Неважно и Несрочно (Отложить/Удалить)</label>
<textarea v-model="eisenhowerForm.q4" rows="2" placeholder="Пожиратели времени, мелкие развлечения, неважная рутина" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
</div>
</div>
</div>
<!-- Modal Footer -->
<div class="px-6 py-4 border-t border-slate-800 bg-slate-900/50 flex justify-end gap-3">
<button @click="$emit('close')" class="px-4 py-2 border border-slate-700 hover:bg-slate-800 text-slate-300 rounded-lg text-xs transition">
Отмена
</button>
<button
@click="submitManual"
:disabled="isSubmitting"
class="px-4 py-2 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-white rounded-lg text-xs transition"
>
Создать вручную
</button>
<button
@click="submitAI"
:disabled="isSubmitting"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition flex items-center gap-1.5"
>
<span v-if="isSubmitting" class="animate-spin text-xs">🌀</span>
<span v-else>✨</span>
Оптимизировать через AI
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
show: { type: Boolean, default: false },
isSubmitting: { type: Boolean, default: false }
})
const emit = defineEmits([
'close',
'submit-smart-manual',
'submit-smart-ai',
'submit-grow-manual',
'submit-grow-ai',
'submit-dilts-manual',
'submit-dilts-ai',
'submit-eisenhower-manual',
'submit-eisenhower-ai'
])
const wizardType = ref('smart')
const smartForm = ref({
specific: '',
measurable: '',
achievable: '',
relevant: '',
deadline: ''
})
const growForm = ref({
goal: '',
reality: '',
options: '',
wayForward: ''
})
const diltsForm = ref({
mission: '',
identity: '',
values: '',
skills: '',
actions: '',
environment: ''
})
const eisenhowerForm = ref({
q1: '',
q2: '',
q3: '',
q4: ''
})
const resetForms = () => {
smartForm.value = { specific: '', measurable: '', achievable: '', relevant: '', deadline: '' }
growForm.value = { goal: '', reality: '', options: '', wayForward: '' }
diltsForm.value = { mission: '', identity: '', values: '', skills: '', actions: '', environment: '' }
eisenhowerForm.value = { q1: '', q2: '', q3: '', q4: '' }
}
const submitManual = () => {
if (wizardType.value === 'smart') {
emit('submit-smart-manual', { ...smartForm.value })
} else if (wizardType.value === 'grow') {
emit('submit-grow-manual', { ...growForm.value })
} else if (wizardType.value === 'dilts') {
emit('submit-dilts-manual', { ...diltsForm.value })
} else if (wizardType.value === 'eisenhower') {
emit('submit-eisenhower-manual', { ...eisenhowerForm.value })
}
resetForms()
}
const submitAI = () => {
if (wizardType.value === 'smart') {
if (!smartForm.value.specific.trim()) {
alert('Пожалуйста, введите хотя бы описание цели!')
return
}
emit('submit-smart-ai', { ...smartForm.value })
} else if (wizardType.value === 'grow') {
if (!growForm.value.goal.trim()) {
alert('Пожалуйста, введите хотя бы цель (Goal)!')
return
}
emit('submit-grow-ai', { ...growForm.value })
} else if (wizardType.value === 'dilts') {
if (!diltsForm.value.mission.trim()) {
alert('Пожалуйста, введите хотя бы описание миссии!')
return
}
emit('submit-dilts-ai', { ...diltsForm.value })
} else if (wizardType.value === 'eisenhower') {
if (!eisenhowerForm.value.q1.trim() && !eisenhowerForm.value.q2.trim() && !eisenhowerForm.value.q3.trim() && !eisenhowerForm.value.q4.trim()) {
alert('Пожалуйста, введите задачи хотя бы в один из квадрантов!')
return
}
emit('submit-eisenhower-ai', { ...eisenhowerForm.value })
}
}
// Expose reset in case parent wants to reset from outside
defineExpose({ resetForms })
</script>
@@ -0,0 +1,318 @@
<template>
<div
class="fixed bottom-0 left-0 right-0 z-30 transition-all duration-300 ease-in-out font-sans flex flex-col"
:class="[isOpen ? 'h-80' : 'h-10']"
>
<!-- Handle / Toggle Bar -->
<div
@click="isOpen = !isOpen"
class="h-10 bg-slate-900 border-t border-slate-700/80 hover:bg-slate-800 cursor-pointer flex items-center px-4 justify-between select-none transition"
>
<div class="flex items-center gap-2 text-slate-200 font-bold text-xs">
<span>📅</span>
<span>Диаграмма Ганта</span>
<span class="px-1.5 py-0.5 bg-slate-800 text-[10px] text-slate-400 rounded-full border border-slate-700 font-medium">
Задач: {{ tasks.length }}
</span>
</div>
<div class="text-slate-400 text-xs">
{{ isOpen ? '▼ Свернуть' : '▲ Развернуть' }}
</div>
</div>
<!-- Timeline Body -->
<div
v-if="isOpen"
class="flex-1 bg-slate-950/95 backdrop-blur-md border-t border-slate-800 flex overflow-hidden min-w-0"
>
<!-- Task Titles Sidebar (Left) -->
<div class="w-64 border-r border-slate-800 flex flex-col shrink-0">
<div class="h-8 border-b border-slate-800 bg-slate-900/40 px-3 flex items-center text-[10px] font-bold text-slate-500 uppercase tracking-wider">
Задача
</div>
<div class="flex-1 overflow-y-auto divide-y divide-slate-900/60 scrollbar-thin">
<div
v-for="task in tasks"
:key="task.id"
@click="$emit('select-node', task.id)"
class="h-10 px-3 flex items-center text-xs text-slate-300 hover:text-white hover:bg-slate-800/30 cursor-pointer transition select-none truncate font-medium"
title="Кликните для фокусировки на холсте"
>
<span class="mr-1.5">{{ getStatusIcon(task.data.state) }}</span>
<span class="truncate">{{ task.data.label || 'Без названия' }}</span>
</div>
<div v-if="tasks.length === 0" class="p-6 text-center text-xs text-slate-600">
Нет задач на холсте
</div>
</div>
</div>
<!-- Timeline Chart (Right) -->
<div class="flex-1 flex flex-col overflow-hidden min-w-0">
<!-- Date Columns Header -->
<div
class="h-8 border-b border-slate-800 bg-slate-900/40 flex overflow-x-auto overflow-y-hidden select-none scrollbar-none"
ref="timelineHeader"
@scroll="syncScrollLeft"
>
<div
v-for="(day, idx) in timelineDays"
:key="idx"
class="h-full border-r border-slate-900/40 shrink-0 flex flex-col items-center justify-center text-[9px] font-semibold text-slate-400"
:style="{ width: `${dayWidth}px` }"
>
<span>{{ day.dayOfWeek }}</span>
<span class="text-slate-500 font-normal">{{ day.dateStr }}</span>
</div>
</div>
<!-- Task Bars Body -->
<div
class="flex-1 overflow-auto divide-y divide-slate-900/60 scrollbar-thin"
ref="timelineBody"
@scroll="syncScrollRight"
>
<div
v-for="task in tasks"
:key="task.id"
class="h-10 relative flex items-center bg-slate-950/20 hover:bg-slate-900/30 transition group"
>
<!-- Timeline Grid Background Lines -->
<div class="absolute inset-0 flex pointer-events-none">
<div
v-for="idx in timelineDays.length"
:key="idx"
class="h-full border-r border-slate-900/40 shrink-0"
:style="{ width: `${dayWidth}px` }"
></div>
</div>
<!-- Task Gantt Bar -->
<div
v-if="hasValidDates(task)"
@click="$emit('select-node', task.id)"
class="absolute h-5 rounded-md border text-[10px] font-bold text-white px-2 flex items-center truncate cursor-pointer transition shadow-md hover:scale-[1.01] z-10"
:class="getBarClasses(task)"
:style="getBarStyles(task)"
>
<span class="truncate">{{ task.data.label }}</span>
<!-- Tooltip on hover -->
<div class="absolute bottom-6 left-0 hidden group-hover:block bg-slate-900 border border-slate-700 text-[10px] rounded p-2 text-slate-300 w-48 shadow-xl pointer-events-none whitespace-normal leading-relaxed z-20">
<div class="font-bold text-white truncate mb-1">{{ task.data.label }}</div>
<div>Статус: <span class="font-medium text-slate-200">{{ getStatusLabel(task.data.state) }}</span></div>
<div>Приоритет: <span class="font-medium text-slate-200">{{ getPriorityLabel(task.data.priority) }}</span></div>
<div v-if="task.data.assigned_to">Исполнитель: <span class="font-medium text-slate-200">{{ task.data.assigned_to }}</span></div>
<div class="text-[9px] text-slate-500 mt-1 border-t border-slate-800 pt-1">
С: {{ formatDate(task.data.start_date) }}<br/>
По: {{ formatDate(task.data.alarm) }}
</div>
</div>
</div>
</div>
<div v-if="tasks.length === 0" class="h-full flex items-center justify-center text-xs text-slate-600">
Создайте задачи и задайте им даты начала/дедлайны
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
elements: { type: Array, default: () => [] }
})
defineEmits(['select-node'])
const isOpen = ref(false)
const dayWidth = 60 // Width of one day column in pixels
const timelineHeader = ref(null)
const timelineBody = ref(null)
// Scroll synchronization between header and body
const syncScrollLeft = (e) => {
if (timelineBody.value) {
timelineBody.value.scrollLeft = e.target.scrollLeft
}
}
const syncScrollRight = (e) => {
if (timelineHeader.value) {
timelineHeader.value.scrollLeft = e.target.scrollLeft
}
}
// Filter tasks
const tasks = computed(() => {
return props.elements.filter(el => el.type === 'task')
})
// Calculate overall min/max dates
const dateRange = computed(() => {
let minDate = new Date()
let maxDate = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) // Default 14 days from now
const taskDates = []
tasks.value.forEach(t => {
if (t.data.start_date) taskDates.push(new Date(t.data.start_date))
if (t.data.alarm) taskDates.push(new Date(t.data.alarm))
})
if (taskDates.length > 0) {
minDate = new Date(Math.min(...taskDates))
maxDate = new Date(Math.max(...taskDates))
}
// Add 2 days padding to sides
minDate.setDate(minDate.getDate() - 2)
maxDate.setDate(maxDate.getDate() + 5)
// Cap timeline length at 30 days to prevent rendering overflow
const diffTime = Math.abs(maxDate - minDate)
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
if (diffDays > 30) {
maxDate = new Date(minDate.getTime() + 30 * 24 * 60 * 60 * 1000)
}
// Reset to midnight
minDate.setHours(0, 0, 0, 0)
maxDate.setHours(23, 59, 59, 999)
return { minDate, maxDate }
})
// Generate list of days for timeline header
const timelineDays = computed(() => {
const days = []
const current = new Date(dateRange.value.minDate)
const end = dateRange.value.maxDate
const ruDays = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']
while (current <= end) {
days.push({
date: new Date(current),
dateStr: `${current.getDate().toString().padStart(2, '0')}.${(current.getMonth() + 1).toString().padStart(2, '0')}`,
dayOfWeek: ruDays[current.getDay()]
})
current.setDate(current.getDate() + 1)
}
return days
})
// Check if task has valid start and end date
const hasValidDates = (task) => {
return !!(task.data.start_date || task.data.alarm)
}
// Format single date for tooltips
const formatDate = (dateStr) => {
if (!dateStr) return 'Не указано'
const date = new Date(dateStr)
return date.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
// Calculate absolute left offset and width in percent or pixels for Gantt Bar
const getBarStyles = (task) => {
const minTime = dateRange.value.minDate.getTime()
const maxTime = dateRange.value.maxDate.getTime()
const totalDuration = maxTime - minTime
let start = task.data.start_date ? new Date(task.data.start_date).getTime() : Date.now()
let end = task.data.alarm ? new Date(task.data.alarm).getTime() : start + 2 * 24 * 60 * 60 * 1000 // 2 days default
if (start > end) {
// Correct order if user reversed dates
const tmp = start
start = end
end = tmp
}
// Boundary checks
start = Math.max(start, minTime)
end = Math.min(end, maxTime)
const leftPercent = ((start - minTime) / totalDuration) * 100
const widthPercent = ((end - start) / totalDuration) * 100
// Calculate actual pixel offsets based on timeline width
const totalDays = timelineDays.value.length
const totalPixels = totalDays * dayWidth
const leftPx = (leftPercent / 100) * totalPixels
const widthPx = Math.max((widthPercent / 100) * totalPixels, 20) // min 20px width
return {
left: `${leftPx}px`,
width: `${widthPx}px`
}
}
// Style colors based on task status
const getBarClasses = (task) => {
const state = task.data.state || 'todo'
switch (state) {
case 'completed':
return 'bg-emerald-600/30 border-emerald-500 hover:bg-emerald-500/40 text-emerald-300'
case 'in_progress':
return 'bg-amber-600/30 border-amber-500 hover:bg-amber-500/40 text-amber-300'
case 'review':
return 'bg-indigo-600/30 border-indigo-500 hover:bg-indigo-500/40 text-indigo-300'
case 'blocked':
return 'bg-red-600/30 border-red-500 hover:bg-red-500/40 text-red-300'
default:
return 'bg-blue-600/30 border-blue-500 hover:bg-blue-500/40 text-blue-300'
}
}
const getStatusIcon = (state) => {
switch (state) {
case 'completed': return '✅'
case 'in_progress': return '⏳'
case 'review': return '👀'
case 'blocked': return '⛔'
default: return '📋'
}
}
const getStatusLabel = (state) => {
switch (state) {
case 'completed': return 'Завершено'
case 'in_progress': return 'В работе'
case 'review': return 'На проверке'
case 'blocked': return 'Заблокировано'
default: return 'К выполнению'
}
}
const getPriorityLabel = (priority) => {
switch (priority) {
case 'high': return 'Высокий'
case 'low': return 'Низкий'
default: return 'Средний'
}
}
</script>
<style scoped>
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: #475569;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
</style>
@@ -0,0 +1,38 @@
<template>
<div v-if="userRole !== 'viewer'" class="w-56 bg-slate-900 border-r border-slate-700 flex flex-col shrink-0 z-20 font-sans">
<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>
</template>
<script setup>
const props = defineProps({
userRole: { type: String, default: 'viewer' }
})
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 синхронизация' }
]
const onDragStart = (event, nodeType) => {
event.dataTransfer.setData('application/vueflow', JSON.stringify(nodeType))
}
</script>
@@ -0,0 +1,291 @@
<template>
<div class="w-60 bg-slate-900 border-l border-slate-700 flex flex-col shrink-0 z-20 font-sans">
<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="node" class="space-y-3">
<!-- SMART & GROW AI Coaching tools -->
<div v-if="['text', 'task'].includes(node.type)" class="p-2.5 bg-slate-950/60 border border-indigo-500/30 rounded-lg space-y-2 mb-2">
<div class="text-[10px] uppercase font-bold text-indigo-400 flex items-center gap-1">
<span>✨</span> AI Коуч-Ассистент
</div>
<div class="grid grid-cols-2 gap-1.5">
<button
@click="$emit('apply-smart-ai')"
:disabled="isCoaching"
class="py-1.5 px-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
title="Преобразовать цель в формат SMART при помощи ИИ"
>
<span v-if="isCoaching && currentCoachAction === 'smart'" class="animate-spin text-xs">🌀</span>
<span v-else>🎯</span>
<span>SMART</span>
</button>
<button
@click="$emit('apply-grow-ai')"
:disabled="isCoaching"
class="py-1.5 px-2 bg-teal-650 hover:bg-teal-600 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
title="Построить цепочку GROW для проблемы при помощи ИИ"
>
<span v-if="isCoaching && currentCoachAction === 'grow'" class="animate-spin text-xs">🌀</span>
<span v-else>🌱</span>
<span>GROW</span>
</button>
<button
@click="$emit('apply-dilts-ai')"
:disabled="isCoaching"
class="py-1.5 px-2 bg-violet-600 hover:bg-violet-500 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
title="Разложить проблему по логическим уровням Дилтса при помощи ИИ"
>
<span v-if="isCoaching && currentCoachAction === 'dilts'" class="animate-spin text-xs">🌀</span>
<span v-else>📐</span>
<span>Дилтс</span>
</button>
<button
@click="$emit('apply-eisenhower-ai')"
:disabled="isCoaching"
class="py-1.5 px-2 bg-amber-600 hover:bg-amber-500 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
title="Разложить задачи по матрице Эйзенхауэра при помощи ИИ"
>
<span v-if="isCoaching && currentCoachAction === 'eisenhower'" class="animate-spin text-xs">🌀</span>
<span v-else>📊</span>
<span>Эйзенхауэр</span>
</button>
</div>
</div>
<!-- Goal Test Section -->
<div v-if="['text', 'task'].includes(node.type)" class="p-2.5 bg-slate-950/60 border border-emerald-500/30 rounded-lg space-y-2 mb-2">
<div class="text-[10px] uppercase font-bold text-emerald-400 flex items-center justify-between cursor-pointer select-none" @click="showGoalTest = !showGoalTest">
<span class="flex items-center gap-1">⚖️ Тест истинности цели</span>
<span>{{ showGoalTest ? '▼' : '▲' }}</span>
</div>
<div v-if="showGoalTest" class="space-y-2 pt-1.5 border-t border-slate-800">
<div v-for="(q, key) in goalTestQuestions" :key="key" class="space-y-1">
<div class="flex justify-between items-center text-[9px] text-slate-400 font-medium leading-tight">
<span :title="q.full" class="cursor-help border-b border-dashed border-slate-600">{{ q.short }}</span>
<span class="text-emerald-400 font-bold shrink-0 ml-1">{{ goalTestScores[key] }}/10</span>
</div>
<input v-model.number="goalTestScores[key]" type="range" min="1" max="10" class="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-emerald-500" />
</div>
<div class="pt-2 border-t border-slate-800 flex flex-col gap-1.5">
<div class="flex justify-between items-center text-xs">
<span class="text-slate-400 font-semibold">Итоговый балл:</span>
<span class="font-bold" :class="goalTestTotalScore >= 60 ? 'text-emerald-400' : 'text-red-400'">
{{ goalTestTotalScore }} / 80
</span>
</div>
<div class="text-[9px] p-1.5 rounded leading-normal border" :class="goalTestTotalScore >= 60 ? 'bg-emerald-950/40 text-emerald-300 border-emerald-800/30' : 'bg-red-950/40 text-red-300 border-red-800/30'">
{{ goalTestTotalScore >= 60 ? '🟢 Цель истинно Ваша! Отличная мотивация.' : '🔴 Внимание! Возможны проблемы с мотивацией (менее 60 баллов).' }}
</div>
<button @click="writeGoalTestToNode" class="w-full py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-[10px] font-bold transition">
✍️ Записать результат в узел
</button>
</div>
</div>
</div>
<div>
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Тип узла</label>
<select v-model="node.type" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option v-for="nt in nodeTypes" :key="nt.type" :value="nt.type">
{{ nt.icon }} {{ nt.name }}
</option>
</select>
</div>
<div>
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Название</label>
<input v-model="node.data.label" @blur="handleChange" 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="node.data.text !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Текст</label>
<textarea v-model="node.data.text" @blur="handleChange" 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="node.data.file !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Файл</label>
<input v-model="node.data.file" @blur="handleChange" 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="node.type === 'task'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Статус Задачи</label>
<select v-model="node.data.state" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option value="todo">📋 К выполнению (To Do)</option>
<option value="in_progress">⏳ В работе (In Progress)</option>
<option value="review">👀 На проверке (Review)</option>
<option value="completed">✅ Завершено (Done)</option>
<option value="blocked">⛔ Заблокировано (Blocked)</option>
</select>
</div>
<div v-if="node.type === 'task'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Приоритет</label>
<select v-model="node.data.priority" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option value="low">🧊 Низкий</option>
<option value="medium">⚡ Средний</option>
<option value="high">🔥 Высокий</option>
</select>
</div>
<div v-if="node.type === 'task'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Исполнитель</label>
<select v-model="node.data.assigned_to" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option value="">Не назначено</option>
<option v-for="u in usersList" :key="u.username" :value="u.username">
{{ u.displayName || u.username }}
</option>
</select>
</div>
<div v-if="node.type === 'task'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Дата начала</label>
<input v-model="node.data.start_date" @blur="handleChange" 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="node.type === 'task'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Дедлайн (Срок)</label>
<input v-model="node.data.alarm" @blur="handleChange" 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="node.type === 'webhook'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Webhook URL</label>
<input v-model="node.data.url" @blur="handleChange" placeholder="https://api.example.com/..." 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="node.type === 'webhook'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Метод</label>
<select v-model="node.data.method" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
</div>
<div v-if="node.type === 'ai'">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">AI Промпт (Инструкция)</label>
<textarea v-model="node.data.prompt" @blur="handleChange" rows="3" placeholder="Что должен сделать ИИ? (например: Отредактируй этот текст...)" 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="node.data.script_id !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Скрипт</label>
<input v-model="node.data.script_id" @blur="handleChange" 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="node.type === 'file' || node.data.file !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Ссылка на Карту</label>
<select v-model="node.data.file" @change="handleChange" 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">
<option value="">Не выбрано</option>
<option v-for="c in canvasesList" :key="c.id" :value="c.id">
🗺️ {{ c.name || c.id }}
</option>
</select>
</div>
<div v-if="node.data.prompt !== undefined">
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">AI Запрос</label>
<textarea v-model="node.data.prompt" @blur="handleChange" 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="node.data.width" @blur="handleChange" 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="node.data.height" @blur="handleChange" 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>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
node: { type: Object, default: null },
usersList: { type: Array, default: () => [] },
canvasesList: { type: Array, default: () => [] },
isCoaching: { type: Boolean, default: false },
currentCoachAction: { type: String, default: '' }
})
const emit = defineEmits(['change', 'apply-smart-ai', 'apply-grow-ai', 'apply-dilts-ai', 'apply-eisenhower-ai'])
const nodeTypes = [
{ type: 'text', name: 'Текст', icon: '📝' },
{ type: 'file', name: 'Файл', icon: '📄' },
{ type: 'task', name: 'Задача', icon: '✅' },
{ type: 'ai', name: 'AI Узел', icon: '🤖' },
{ type: 'webhook', name: 'Webhook', icon: '🔗' },
{ type: 'git', name: 'Git Синхр', icon: '🔄' }
]
const handleChange = () => {
emit('change')
}
// Goal Validation Test logic
const showGoalTest = ref(false)
const goalTestScores = ref({
q1: 5, q2: 5, q3: 5, q4: 5, q5: 5, q6: 5, q7: 5, q8: 5
})
const goalTestQuestions = {
q1: { short: '1. Видение успеха', full: 'Насколько легко и с удовольствием я вижу себя, достигнувшим цель?' },
q2: { short: '2. Страсть к процессу', full: 'Эта цель вызывает у меня страсть сама по себе? Мне нравится процесс ее достижения? (Без учета внешних наград)' },
q3: { short: '3. Доп. польза', full: 'Достижение этой цели принесет мне дополнительную пользу в жизни/других сферах?' },
q4: { short: '4. Баланс жизни', full: 'Смогу ли я достигнуть эту цель, не обделяя другие сферы жизни (время, силы, здоровье)?' },
q5: { short: '5. Радость сразу', full: 'Я смогу получать радость и пользу от достижения цели сразу, по ходу процесса?' },
q6: { short: '6. Зона контроля', full: 'Действия по достижению этой цели находятся в рамках моей личной собственной деятельности?' },
q7: { short: '7. Поддержка окружения', full: 'Мое окружение будет поддерживать меня и мои усилия?' },
q8: { short: '8. Ресурсы и автономия', full: 'У меня есть достаточно ресурсов для достижения? Достижима ли она без посторонней помощи?' }
}
const goalTestTotalScore = computed(() => {
return Object.values(goalTestScores.value).reduce((a, b) => a + b, 0)
})
// Watch selected node and try to parse existing test scores if they exist
watch(() => props.node?.id, () => {
showGoalTest.value = false
// Reset scores to default
goalTestScores.value = { q1: 5, q2: 5, q3: 5, q4: 5, q5: 5, q6: 5, q7: 5, q8: 5 }
// Try to parse existing score from node text if present
if (props.node?.data?.text) {
const text = props.node.data.text
const match = text.match(/Результат теста цели: (\d+)\/80/)
if (match) {
const qMatches = {
q1: text.match(/Видение успеха:\s*(\d+)/),
q2: text.match(/Страсть к процессу:\s*(\d+)/),
q3: text.match(/Доп. польза:\s*(\d+)/) || text.match(/Дополнительная польза:\s*(\d+)/),
q4: text.match(/Баланс жизни:\s*(\d+)/),
q5: text.match(/Радость сразу:\s*(\d+)/),
q6: text.match(/Зона контроля:\s*(\d+)/) || text.match(/Личная зона контроля:\s*(\d+)/),
q7: text.match(/Поддержка окружения:\s*(\d+)/),
q8: text.match(/Ресурсы и автономия:\s*(\d+)/) || text.match(/Хватает ресурсов:\s*(\d+)/)
}
for (const [key, m] of Object.entries(qMatches)) {
if (m) goalTestScores.value[key] = parseInt(m[1])
}
showGoalTest.value = true
}
}
}, { immediate: true })
const writeGoalTestToNode = () => {
if (!props.node) return
const text = props.node.data.text || props.node.data.label || ''
// Strip previous test results if present
let cleanText = text.split('\n\n---\n### ⚖️ Результат теста цели:')[0]
cleanText = cleanText.split('\n---\n### ⚖️ Результат теста цели:')[0]
const verdict = goalTestTotalScore.value >= 60 ? 'Ваша цель' : 'Чужая цель / Риск потери мотивации'
const testMarkdown = `\n\n---\n### ⚖️ Результат теста цели: ${goalTestTotalScore.value}/80 (${verdict})\n` +
`* **Видение успеха:** ${goalTestScores.value.q1}/10\n` +
`* **Страсть к процессу:** ${goalTestScores.value.q2}/10\n` +
`* **Доп. польза:** ${goalTestScores.value.q3}/10\n` +
`* **Баланс жизни:** ${goalTestScores.value.q4}/10\n` +
`* **Радость сразу:** ${goalTestScores.value.q5}/10\n` +
`* **Зона контроля:** ${goalTestScores.value.q6}/10\n` +
`* **Поддержка окружения:** ${goalTestScores.value.q7}/10\n` +
`* **Ресурсы и автономия:** ${goalTestScores.value.q8}/10`
props.node.data.text = cleanText + testMarkdown
handleChange()
}
</script>
@@ -0,0 +1,36 @@
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 auth = useAuth()
const data = await $fetch<{ achievements: Achievement[], stats: { total: number, completedTasks: number } }>('/api/achievements', {
headers: { Authorization: `Bearer ${auth.token.value}` }
})
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
}
}
+96
View File
@@ -0,0 +1,96 @@
import { jwtDecode } from 'jwt-decode'
import { getAuth, signInWithPopup, GoogleAuthProvider, signOut, onAuthStateChanged } from 'firebase/auth'
interface User {
id: string
email: string
username: string
displayName?: string
role?: string
}
export const useAuth = () => {
const token = useCookie<string | null>('xflow_token', {
maxAge: 60 * 60 * 24 * 7,
sameSite: 'lax'
})
const isInitializing = useState('auth_initializing', () => true)
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 () => {
try {
const auth = getAuth()
await signOut(auth)
} catch (e) {
console.warn('[Firebase] Sign out failed or not initialized', e)
} finally {
token.value = null
navigateTo('/login')
}
}
const initAuth = () => {
if (process.server) return
const auth = getAuth()
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
// If user is authenticated in Firebase but we lost our backend token, try to restore
if (firebaseUser && !token.value) {
try {
const idToken = await firebaseUser.getIdToken()
const res = await $fetch<{ token: string; user: User }>('/api/auth/google', {
method: 'POST',
body: { idToken }
})
token.value = res.token
if (useRoute().path === '/login') {
await navigateTo('/')
}
} catch (e) {
console.error('Failed to restore backend auth from Firebase', e)
}
}
isInitializing.value = false
})
return unsubscribe
}
return {
token,
user,
isAuthenticated,
isAdmin,
isInitializing,
googleSignIn,
logout,
initAuth
}
}
+116
View File
@@ -0,0 +1,116 @@
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 {
id?: string
name?: string
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 => {
if (!json) return { nodes: [], edges: [] }
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,
label: n.label,
file: n.file,
frontmatter: n.frontmatter,
state: n.state || 'pending',
priority: n.priority,
assigned_to: n.assigned_to,
alarm: n.alarm,
script_id: n.script_id,
prompt: n.prompt,
url: n.url,
method: n.method,
repo: n.repo,
branch: n.branch,
message: n.message,
style: n.style || {}
}))
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 {
id: json.id,
name: json.name,
nodes,
edges
}
}
const getAuthHeaders = (): Record<string, string> => {
const auth = useAuth()
const token = auth.token?.value
return token ? { Authorization: `Bearer ${token}` } : {}
}
const loadCanvas = async (id: string): Promise<{ canvas: CanvasData, role: string }> => {
const data = await $fetch<{ canvas: any, role: string }>(`/api/canvas/${id}`, {
headers: getAuthHeaders()
})
return {
canvas: parseCanvasJson(data.canvas),
role: data.role
}
}
const updateNodeState = async (nodeId: string, state: string) => {
return await $fetch(`/api/nodes/${nodeId}`, {
method: 'PUT',
headers: getAuthHeaders(),
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
}
}
@@ -0,0 +1,62 @@
// 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
console.log('[DEBUG Unsaved] Changed to dirty because data is different')
} else if (!lastSavedData.value) {
isDirty.value = true
console.log('[DEBUG Unsaved] Changed to dirty because no lastSavedData')
} else {
isDirty.value = false
console.log('[DEBUG Unsaved] Not dirty, data is identical')
}
}
// Mark data as saved
const markAsSaved = (data: any) => {
lastSavedData.value = JSON.stringify(data)
currentData.value = lastSavedData.value
isDirty.value = false
console.log('[DEBUG Unsaved] Marked as saved, isDirty = 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
}
}

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