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
This commit is contained in:
2026-04-19 22:18:25 +05:00
parent 52aea9ac87
commit f1e0f6b5ef
56 changed files with 8892 additions and 397 deletions
+178
View File
@@ -0,0 +1,178 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:uuid/uuid.dart';
import '../models/mindmap_model.dart';
import '../core/admin_access_service.dart';
import '../core/deepseek_service.dart';
// Provider для получения текущего email пользователя
final currentUserEmailProvider = Provider<String?>((ref) {
return FirebaseAuth.instance.currentUser?.email;
});
// Provider проверки является ли пользователь админом
final isAdminProvider = Provider<bool>((ref) {
final email = ref.watch(currentUserEmailProvider);
if (email == null) return false;
return AdminAccessService.adminEmails.contains(email.toLowerCase());
});
// Provider списка mind maps
final mindMapsProvider = StreamProvider<List<MindMapModel>>((ref) {
return FirebaseFirestore.instance
.collection('mind_maps')
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) {
return snapshot.docs.map((doc) {
return MindMapModel.fromFirestore(doc);
}).toList();
});
});
// Notifier для управления mind maps
class MindMapsNotifier extends StateNotifier<AsyncValue<List<MindMapModel>>> {
final Ref ref;
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final FirebaseAuth _auth = FirebaseAuth.instance;
MindMapsNotifier(this.ref) : super(const AsyncValue.loading()) {
_listenToMindMaps();
}
void _listenToMindMaps() {
_firestore
.collection('mind_maps')
.orderBy('createdAt', descending: true)
.snapshots()
.listen(
(snapshot) {
final mindMaps = snapshot.docs.map((doc) {
return MindMapModel.fromFirestore(doc);
}).toList();
state = AsyncValue.data(mindMaps);
},
onError: (error, st) {
state = AsyncValue.error(error, st);
},
);
}
// Создание новой mind map
Future<String> createMindMap({
required String title,
String description = '',
required List<Map<String, dynamic>> nodes,
bool isMain = false,
}) async {
try {
final userEmail = _auth.currentUser?.email ?? 'unknown';
final now = DateTime.now();
final id = const Uuid().v4();
final mindMap = MindMapModel(
id: id,
title: title,
description: description,
ownerEmail: userEmail,
isMain: isMain,
nodes: nodes,
createdAt: now,
updatedAt: now,
);
await _firestore.collection('mind_maps').doc(id).set(mindMap.toFirestore());
return id;
} catch (e, st) {
state = AsyncValue.error(e, st);
rethrow;
}
}
// Удаление mind map (админы могут удалять любые, обычные - только свои)
Future<void> deleteMindMap(String id, String ownerEmail) async {
try {
final isAdmin = ref.read(isAdminProvider);
final currentUserEmail = _auth.currentUser?.email ?? '';
// Проверка прав
if (!isAdmin && ownerEmail != currentUserEmail) {
throw Exception('Нет прав для удаления этой mind map');
}
await _firestore.collection('mind_maps').doc(id).delete();
} catch (e, st) {
state = AsyncValue.error(e, st);
rethrow;
}
}
// Обновление mind map
Future<void> updateMindMap(String id, MindMapModel mindMap) async {
try {
final isAdmin = ref.read(isAdminProvider);
final currentUserEmail = _auth.currentUser?.email ?? '';
// Проверка прав
if (!isAdmin && mindMap.ownerEmail != currentUserEmail) {
throw Exception('Нет прав для редактирования этой mind map');
}
await _firestore
.collection('mind_maps')
.doc(id)
.update(mindMap.copyWith(updatedAt: DateTime.now()).toFirestore());
} catch (e, st) {
state = AsyncValue.error(e, st);
rethrow;
}
}
// AI генерация mind map
Future<String> generateWithAI({
required String title,
required String description,
}) async {
try {
final nodesData = await deepSeekService.generateMindMap(
projectName: title,
description: description,
);
final nodes = <Map<String, dynamic>>[];
for (var i = 0; i < nodesData.length; i++) {
final nodeData = nodesData[i];
nodes.add({
'id': 'node_$i',
'level': nodeData['level'] ?? 0,
'parentId': nodeData['parentId'],
'title': nodeData['title'] ?? '',
'color': _getColorForLevel(nodeData['level'] ?? 0),
});
}
return await createMindMap(
title: title,
description: description,
nodes: nodes,
);
} catch (e, st) {
state = AsyncValue.error(e, st);
rethrow;
}
}
String _getColorForLevel(int level) {
final colors = [
'#2196F3', '#4CAF50', '#FF9800', '#E91E63', '#9C27B0', '#00BCD4'
];
return colors[level % colors.length];
}
}
final mindMapsNotifierProvider =
StateNotifierProvider<MindMapsNotifier, AsyncValue<List<MindMapModel>>>(
(ref) => MindMapsNotifier(ref),
);
+42 -26
View File
@@ -1,13 +1,15 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import 'package:reactive_mind_map/reactive_mind_map.dart' as rm;
import '../models/mind_map_model.dart';
import '../models/task_model.dart';
import '../core/firestore_service.dart';
import '../core/local_storage_service.dart';
import '../core/deepseek_service.dart';
import '../core/mind_map_adapter.dart';
// Provider для mind-карты проекта
final mindMapProvider = StateNotifierProvider.family<MindMapNotifier, AsyncValue<MindMapData>, String>((ref, projectId) {
final mindMapProvider = StateNotifierProvider.family<MindMapNotifier,
AsyncValue<MindMapData>, String>((ref, projectId) {
return MindMapNotifier(projectId);
});
@@ -31,9 +33,16 @@ class MindMapData {
// Получить родительский узел
MindMapNode? getParent(String nodeId) {
final node = nodes.firstWhere((n) => n.id == nodeId, orElse: () => throw Exception('Node not found'));
final node = nodes.firstWhere((n) => n.id == nodeId,
orElse: () => throw Exception('Node not found'));
if (node.parentId == null) return null;
return nodes.firstWhere((n) => n.id == node.parentId, orElse: () => throw Exception('Parent not found'));
return nodes.firstWhere((n) => n.id == node.parentId,
orElse: () => throw Exception('Parent not found'));
}
// Конвертировать в reactive_mind_map формат
rm.MindMapData toReactiveMindMapData() {
return MindMapAdapter.nodesToMindMapData(nodes);
}
}
@@ -49,8 +58,9 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
try {
// Загружаем из локального хранилища
final localNodes = localStorageService.getMindMapNodes();
final projectNodes = localNodes.where((n) => n.projectId == projectId).toList();
final projectNodes =
localNodes.where((n) => n.projectId == projectId).toList();
state = AsyncValue.data(_createMindMapData(projectNodes));
// TODO: Загрузить из Firestore и синхронизировать
@@ -90,7 +100,7 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
String icon = 'folder',
}) async {
try {
final parentNode = parentId != null
final parentNode = parentId != null
? state.value?.nodes.firstWhere((n) => n.id == parentId)
: null;
@@ -123,11 +133,11 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
final currentData = state.value;
if (currentData != null) {
final updatedNodes = [...currentData.nodes, node];
if (parentId != null) {
currentData.childrenMap.putIfAbsent(parentId, () => []).add(node.id);
}
state = AsyncValue.data(MindMapData(
nodes: updatedNodes,
childrenMap: currentData.childrenMap,
@@ -143,7 +153,8 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
}
// Обновление узла
Future<void> updateNode(String nodeId, {
Future<void> updateNode(
String nodeId, {
String? title,
String? color,
String? icon,
@@ -203,7 +214,9 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
_collectChildNodes(nodeId, currentData, nodesToDelete);
// Сохраняем для удаления
final nodes = currentData.nodes.where((n) => !nodesToDelete.contains(n.id)).toList();
final nodes = currentData.nodes
.where((n) => !nodesToDelete.contains(n.id))
.toList();
// Сохраняем локально (удаляем)
for (final id in nodesToDelete) {
@@ -234,7 +247,8 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
}
// Рекурсивный сбор дочерних узлов
void _collectChildNodes(String parentId, MindMapData data, Set<String> result) {
void _collectChildNodes(
String parentId, MindMapData data, Set<String> result) {
final childIds = data.childrenMap[parentId] ?? [];
for (final childId in childIds) {
result.add(childId);
@@ -260,7 +274,7 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
final nodeData = nodesData[i];
final level = nodeData['level'] as int;
final parentId = nodeData['parentId'] as int?;
String? actualParentId;
if (parentId != null && parentId < nodes.length) {
actualParentId = nodes[parentId].id;
@@ -272,16 +286,16 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
x: (level * 200).toDouble(), // Автоматическое позиционирование
y: (i * 50).toDouble(),
);
nodes.add(node);
if (level == 0) {
rootNode = node;
}
}
return nodes;
} catch (e, st) {
} catch (e) {
rethrow;
}
}
@@ -300,16 +314,18 @@ class MindMapNotifier extends StateNotifier<AsyncValue<MindMapData>> {
return {
'projectId': projectId,
'rootNodeId': data.rootNodeId,
'nodes': data.nodes.map((n) => {
'id': n.id,
'parentId': n.parentId,
'level': n.level,
'title': n.title,
'x': n.x,
'y': n.y,
'color': n.color,
'icon': n.icon,
}).toList(),
'nodes': data.nodes
.map((n) => {
'id': n.id,
'parentId': n.parentId,
'level': n.level,
'title': n.title,
'x': n.x,
'y': n.y,
'color': n.color,
'icon': n.icon,
})
.toList(),
};
}
+1 -2
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../models/task_model.dart';
import '../models/project_model.dart';
import '../core/firestore_service.dart';
import '../core/local_storage_service.dart';
import '../core/deepseek_service.dart';
@@ -253,7 +252,7 @@ class TasksNotifier extends StateNotifier<AsyncValue<List<TaskModel>>> {
}
return subtasks;
} catch (e, st) {
} catch (e) {
rethrow;
}
}