diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml index e49c4fd..8265570 100644 --- a/.github/workflows/ci-backend.yml +++ b/.github/workflows/ci-backend.yml @@ -22,6 +22,6 @@ jobs: run: dotnet restore working-directory: backend - - name: Build - run: dotnet build --no-restore -c Release + - name: Build and test + run: dotnet test -c Release working-directory: backend diff --git a/.github/workflows/ci-frontend.yml b/.github/workflows/ci-frontend.yml index b095e68..aece2c1 100644 --- a/.github/workflows/ci-frontend.yml +++ b/.github/workflows/ci-frontend.yml @@ -28,6 +28,10 @@ jobs: run: npm run lint working-directory: frontend + - name: Test + run: npm test + working-directory: frontend + - name: Build run: npm run build working-directory: frontend diff --git a/README.md b/README.md index f11e315..9a73ab1 100644 --- a/README.md +++ b/README.md @@ -53,12 +53,24 @@ npm run dev Фронт: `http://localhost:5173`. +## Тесты + +```bash +# Backend (xUnit) +cd backend +dotnet test + +# Frontend (Vitest) +cd frontend +npm test +``` + ## Что сейчас сделано - **Фронтенд** - SPA на React + TypeScript + Vite. - - Экран курса и урока, редактор кода, локальное хранение прогресса. - - Дизайн и базовая геймификация (XP, уровни, достижения, фракции, магазин) — пока в основном на клиенте. + - Экран курса и урока, редактор кода, JWT-авторизация. + - Проверка Python-кода выполняется на бэкенде; геймификация (XP, прогресс, достижения, магазин) синхронизируется с API. - **Бэкенд** - ASP.NET Core 8 API, PostgreSQL (EF Core), JWT‑аутентификация. diff --git a/backend/CodeFlow.Api.Tests/AuthServiceTests.cs b/backend/CodeFlow.Api.Tests/AuthServiceTests.cs new file mode 100644 index 0000000..4a853c7 --- /dev/null +++ b/backend/CodeFlow.Api.Tests/AuthServiceTests.cs @@ -0,0 +1,69 @@ +using CodeFlow.Api.DTOs; +using CodeFlow.Api.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; + +namespace CodeFlow.Api.Tests; + +public class AuthServiceTests +{ + private static IConfiguration CreateConfig() => + new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Jwt:Key"] = "CodeFlow-Test-SecretKey-32CharsMin!!", + ["Jwt:Issuer"] = "CodeFlow.Test", + ["Jwt:ExpirationMinutes"] = "60", + ["App:BaseUrl"] = "http://localhost:5173" + }) + .Build(); + + [Fact] + public async Task RegisterAsync_ValidUser_ReturnsToken() + { + var db = TestDbContextFactory.Create(); + var service = new AuthService(db, CreateConfig(), new EmailStubService(NullLogger.Instance)); + + var result = await service.RegisterAsync(new RegisterRequest("new@codeflow.io", "secret12", "Agent")); + + Assert.NotNull(result); + Assert.False(string.IsNullOrWhiteSpace(result!.AccessToken)); + Assert.Equal("new@codeflow.io", result.User.Email); + Assert.True(await db.Users.AnyAsync(u => u.Email == "new@codeflow.io")); + } + + [Fact] + public async Task RegisterAsync_DuplicateEmail_ReturnsNull() + { + var (db, user, _, _) = await TestDbContextFactory.SeedBasicAsync(); + var service = new AuthService(db, CreateConfig(), new EmailStubService(NullLogger.Instance)); + + var result = await service.RegisterAsync(new RegisterRequest(user.Email, "secret12", "Dup")); + + Assert.Null(result); + } + + [Fact] + public async Task LoginAsync_ValidCredentials_ReturnsToken() + { + var (db, user, _, _) = await TestDbContextFactory.SeedBasicAsync(); + var service = new AuthService(db, CreateConfig(), new EmailStubService(NullLogger.Instance)); + + var result = await service.LoginAsync(new LoginRequest(user.Email, "password123")); + + Assert.NotNull(result); + Assert.False(string.IsNullOrWhiteSpace(result!.AccessToken)); + } + + [Fact] + public async Task LoginAsync_WrongPassword_ReturnsNull() + { + var (db, user, _, _) = await TestDbContextFactory.SeedBasicAsync(); + var service = new AuthService(db, CreateConfig(), new EmailStubService(NullLogger.Instance)); + + var result = await service.LoginAsync(new LoginRequest(user.Email, "wrong")); + + Assert.Null(result); + } +} diff --git a/backend/CodeFlow.Api.Tests/CodeFlow.Api.Tests.csproj b/backend/CodeFlow.Api.Tests/CodeFlow.Api.Tests.csproj new file mode 100644 index 0000000..0d545bd --- /dev/null +++ b/backend/CodeFlow.Api.Tests/CodeFlow.Api.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + false + CodeFlow.Api.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/backend/CodeFlow.Api.Tests/GlobalUsings.cs b/backend/CodeFlow.Api.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/backend/CodeFlow.Api.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/backend/CodeFlow.Api.Tests/LessonSubmitLogicTests.cs b/backend/CodeFlow.Api.Tests/LessonSubmitLogicTests.cs new file mode 100644 index 0000000..bfdffff --- /dev/null +++ b/backend/CodeFlow.Api.Tests/LessonSubmitLogicTests.cs @@ -0,0 +1,31 @@ +namespace CodeFlow.Api.Tests; + +/// +/// Проверка нормализации вывода при сравнении с эталоном (как в LessonsController). +/// +public class LessonSubmitLogicTests +{ + [Theory] + [InlineData("hello\r\nworld", "hello\nworld")] + [InlineData("line\r\n", "line")] + [InlineData("", "")] + public void NormalizeOutput_MatchesControllerBehavior(string input, string expected) + { + var normalized = NormalizeOutput(input); + Assert.Equal(expected, normalized); + } + + [Fact] + public void CompareOutputs_TrimmedAndUnifiedLineEndings() + { + const string actual = "OK\r\n"; + const string expected = "OK"; + Assert.Equal(NormalizeOutput(expected), NormalizeOutput(actual)); + } + + private static string NormalizeOutput(string s) + { + if (string.IsNullOrEmpty(s)) return ""; + return s.TrimEnd().Replace("\r\n", "\n").Replace("\r", "\n"); + } +} diff --git a/backend/CodeFlow.Api.Tests/MoralChoiceConfigTests.cs b/backend/CodeFlow.Api.Tests/MoralChoiceConfigTests.cs new file mode 100644 index 0000000..533912b --- /dev/null +++ b/backend/CodeFlow.Api.Tests/MoralChoiceConfigTests.cs @@ -0,0 +1,37 @@ +using CodeFlow.Api.Config; + +namespace CodeFlow.Api.Tests; + +public class MoralChoiceConfigTests +{ + [Theory] + [InlineData("Глава 1: Сигнал", "data_brokers", 500, 50)] + [InlineData("Глава 2: Логика", "ai_ethicists", 400, 50)] + [InlineData("Глава 5: Функции", "ghost_protocol", 400, 50)] + public void TryGetRewards_ReturnsChapterValues(string chapter, string faction, int expectedXp, int expectedRep) + { + var ok = MoralChoiceConfig.TryGetRewards(chapter, faction, out var xp, out var rep); + + Assert.True(ok); + Assert.Equal(expectedXp, xp); + Assert.Equal(expectedRep, rep); + } + + [Fact] + public void TryGetRewards_UnknownFaction_ReturnsFalse() + { + var ok = MoralChoiceConfig.TryGetRewards("Глава 1: Сигнал", "unknown_faction", out _, out _); + + Assert.False(ok); + } + + [Fact] + public void TryGetRewards_UnknownChapter_UsesFactionDefaults() + { + var ok = MoralChoiceConfig.TryGetRewards("Неизвестная глава", "ghost_protocol", out var xp, out var rep); + + Assert.True(ok); + Assert.Equal(100, xp); + Assert.Equal(50, rep); + } +} diff --git a/backend/CodeFlow.Api.Tests/ProgressServiceTests.cs b/backend/CodeFlow.Api.Tests/ProgressServiceTests.cs new file mode 100644 index 0000000..905a9fc --- /dev/null +++ b/backend/CodeFlow.Api.Tests/ProgressServiceTests.cs @@ -0,0 +1,101 @@ +using CodeFlow.Api.DTOs; +using CodeFlow.Api.Models; +using CodeFlow.Api.Services; +using Microsoft.EntityFrameworkCore; + +namespace CodeFlow.Api.Tests; + +public class ProgressServiceTests +{ + [Fact] + public async Task CompleteLessonAsync_AddsXpAndProgress() + { + var (db, user, lesson, _) = await TestDbContextFactory.SeedBasicAsync(); + var service = new ProgressService(db); + + var result = await service.CompleteLessonAsync(user.Id, new CompleteLessonRequest(lesson.Id, true)); + + Assert.NotNull(result); + Assert.Equal(100, result!.XpEarned); + var updated = await db.Users.Include(u => u.Progress).FirstAsync(u => u.Id == user.Id); + Assert.Equal(600, updated.TotalXp); + Assert.Single(updated.Progress); + } + + [Fact] + public async Task CompleteLessonAsync_AlreadyCompleted_ReturnsNull() + { + var (db, user, lesson, _) = await TestDbContextFactory.SeedBasicAsync(); + var service = new ProgressService(db); + await service.CompleteLessonAsync(user.Id, new CompleteLessonRequest(lesson.Id, true)); + + var second = await service.CompleteLessonAsync(user.Id, new CompleteLessonRequest(lesson.Id, true)); + + Assert.Null(second); + } + + [Fact] + public async Task PurchaseHintAsync_Level1_Deducts50Xp() + { + var (db, user, lesson, _) = await TestDbContextFactory.SeedBasicAsync(); + var service = new ProgressService(db); + + var result = await service.PurchaseHintAsync(user.Id, new PurchaseHintRequest(lesson.Id, 1)); + + Assert.NotNull(result); + Assert.Equal(450, result!.TotalXp); + Assert.Equal(1, result.HintLevel); + Assert.Equal("Hint 1", result.HintText); + } + + [Fact] + public async Task PurchaseHintAsync_InsufficientXp_ReturnsNull() + { + var (db, user, lesson, _) = await TestDbContextFactory.SeedBasicAsync(); + user.TotalXp = 10; + await db.SaveChangesAsync(); + var service = new ProgressService(db); + + var result = await service.PurchaseHintAsync(user.Id, new PurchaseHintRequest(lesson.Id, 2)); + + Assert.Null(result); + } + + [Fact] + public async Task ApplyMoralChoiceAsync_BossLesson_GrantsXpAndReputation() + { + var (db, user, _, faction) = await TestDbContextFactory.SeedBasicAsync(); + var service = new ProgressService(db); + + var result = await service.ApplyMoralChoiceAsync(user.Id, new MoralChoiceRequest(faction.Id, 4)); + + Assert.NotNull(result); + Assert.Equal(1000, result!.TotalXp); + var rep = await db.UserReputations.FirstOrDefaultAsync(r => r.UserId == user.Id && r.FactionId == faction.Id); + Assert.NotNull(rep); + Assert.Equal(50, rep!.Reputation); + } + + [Fact] + public async Task ApplyMoralChoiceAsync_NonBossLesson_ReturnsNull() + { + var (db, user, lesson, faction) = await TestDbContextFactory.SeedBasicAsync(); + var service = new ProgressService(db); + + var result = await service.ApplyMoralChoiceAsync(user.Id, new MoralChoiceRequest(faction.Id, lesson.Id)); + + Assert.Null(result); + } + + [Fact] + public async Task ApplyMoralChoiceAsync_DuplicateChoice_ReturnsNull() + { + var (db, user, _, faction) = await TestDbContextFactory.SeedBasicAsync(); + var service = new ProgressService(db); + await service.ApplyMoralChoiceAsync(user.Id, new MoralChoiceRequest(faction.Id, 4)); + + var second = await service.ApplyMoralChoiceAsync(user.Id, new MoralChoiceRequest(faction.Id, 4)); + + Assert.Null(second); + } +} diff --git a/backend/CodeFlow.Api.Tests/TestDbContextFactory.cs b/backend/CodeFlow.Api.Tests/TestDbContextFactory.cs new file mode 100644 index 0000000..011d2b7 --- /dev/null +++ b/backend/CodeFlow.Api.Tests/TestDbContextFactory.cs @@ -0,0 +1,97 @@ +using CodeFlow.Api.Data; +using CodeFlow.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace CodeFlow.Api.Tests; + +internal static class TestDbContextFactory +{ + public static AppDbContext Create(string? dbName = null) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName ?? Guid.NewGuid().ToString()) + .Options; + + var db = new AppDbContext(options); + db.Database.EnsureCreated(); + return db; + } + + public static async Task<(AppDbContext Db, User User, Lesson Lesson, Faction Faction)> SeedBasicAsync() + { + var db = Create(); + var user = new User + { + Id = Guid.NewGuid(), + Email = "test@codeflow.io", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("password123"), + DisplayName = "TestUser", + CreatedAtUtc = DateTime.UtcNow, + TotalXp = 500, + Role = Role.User + }; + + var course = new Course + { + Id = 1, + Title = "Test Course", + Description = "Desc", + Level = "Test", + Color = "green", + TotalLessons = 1 + }; + + var lesson = new Lesson + { + Id = 1, + CourseId = 1, + Chapter = "Глава 1: Сигнал", + Title = "Test Lesson", + Description = "Desc", + Task = "Task", + InitialCode = "", + ExpectedOutput = "OK", + Xp = 100, + IsBoss = false, + HasDebugger = false, + Hint = "Hint 1", + Hint2 = "Hint 2" + }; + + var bossLesson = new Lesson + { + Id = 4, + CourseId = 1, + Chapter = "Глава 1: Сигнал", + Title = "Boss", + Description = "Desc", + Task = "Task", + InitialCode = "", + ExpectedOutput = "BOSS", + Xp = 250, + IsBoss = true, + HasDebugger = false, + Hint = "H1", + Hint2 = "H2" + }; + + var faction = new Faction + { + Id = "data_brokers", + Name = "Data Brokers", + Description = "Desc", + Icon = "💾", + Color = "blue", + Bonus = "+10%", + RequiredRep = 0 + }; + + db.Users.Add(user); + db.Courses.Add(course); + db.Lessons.AddRange(lesson, bossLesson); + db.Factions.Add(faction); + await db.SaveChangesAsync(); + + return (db, user, lesson, faction); + } +} diff --git a/backend/CodeFlow.Api/Config/MoralChoiceConfig.cs b/backend/CodeFlow.Api/Config/MoralChoiceConfig.cs new file mode 100644 index 0000000..4608062 --- /dev/null +++ b/backend/CodeFlow.Api/Config/MoralChoiceConfig.cs @@ -0,0 +1,74 @@ +namespace CodeFlow.Api.Config; + +/// +/// Награды за моральный выбор по главе и фракции (значения задаются на сервере). +/// +public static class MoralChoiceConfig +{ + private static readonly Dictionary> ByChapter = + new(StringComparer.OrdinalIgnoreCase) + { + ["Глава 1: Сигнал"] = new(StringComparer.OrdinalIgnoreCase) + { + ["data_brokers"] = (500, 50), + ["ai_ethicists"] = (300, 50), + ["ghost_protocol"] = (100, 50), + }, + ["Глава 2: Логика"] = new(StringComparer.OrdinalIgnoreCase) + { + ["data_brokers"] = (600, 50), + ["ai_ethicists"] = (400, 50), + ["ghost_protocol"] = (200, 50), + }, + ["Глава 3: Циклы"] = new(StringComparer.OrdinalIgnoreCase) + { + ["data_brokers"] = (700, 50), + ["ai_ethicists"] = (500, 50), + ["ghost_protocol"] = (250, 50), + }, + ["Глава 4: Коллекции"] = new(StringComparer.OrdinalIgnoreCase) + { + ["data_brokers"] = (800, 50), + ["ai_ethicists"] = (600, 50), + ["ghost_protocol"] = (300, 50), + }, + ["Глава 5: Функции"] = new(StringComparer.OrdinalIgnoreCase) + { + ["data_brokers"] = (1000, 50), + ["ai_ethicists"] = (800, 50), + ["ghost_protocol"] = (400, 50), + }, + }; + + private static readonly Dictionary DefaultByFaction = + new(StringComparer.OrdinalIgnoreCase) + { + ["data_brokers"] = (500, 50), + ["ai_ethicists"] = (300, 50), + ["ghost_protocol"] = (100, 50), + }; + + public static bool TryGetRewards(string chapter, string factionId, out int xp, out int reputation) + { + xp = 0; + reputation = 0; + if (string.IsNullOrWhiteSpace(factionId)) return false; + + if (ByChapter.TryGetValue(chapter.Trim(), out var factions) && + factions.TryGetValue(factionId.Trim(), out var rewards)) + { + xp = rewards.Xp; + reputation = rewards.Rep; + return true; + } + + if (DefaultByFaction.TryGetValue(factionId.Trim(), out var fallback)) + { + xp = fallback.Xp; + reputation = fallback.Rep; + return true; + } + + return false; + } +} diff --git a/backend/CodeFlow.Api/Controllers/Admin/AdminLessonsController.cs b/backend/CodeFlow.Api/Controllers/Admin/AdminLessonsController.cs index 1701a2b..8e64aa8 100644 --- a/backend/CodeFlow.Api/Controllers/Admin/AdminLessonsController.cs +++ b/backend/CodeFlow.Api/Controllers/Admin/AdminLessonsController.cs @@ -20,28 +20,27 @@ public AdminLessonsController(AppDbContext db) } [HttpGet] - public async Task>> GetAll([FromQuery] int? courseId, CancellationToken ct) + public async Task>> GetAll([FromQuery] int? courseId, CancellationToken ct) { var query = _db.Lessons.AsQueryable(); if (courseId.HasValue) query = query.Where(l => l.CourseId == courseId.Value); var list = await query .OrderBy(l => l.CourseId).ThenBy(l => l.Id) - .Select(l => new LessonDto(l.Id, l.CourseId, l.Chapter, l.Title, l.Description, l.Task, l.InitialCode, l.ExpectedOutput, l.Xp, l.IsBoss, l.HasDebugger, l.Hint, l.Hint2)) .ToListAsync(ct); - return Ok(list); + return Ok(list.Select(l => l.ToAdminDto())); } [HttpGet("{id:int}")] - public async Task> GetById(int id, CancellationToken ct) + public async Task> GetById(int id, CancellationToken ct) { var lesson = await _db.Lessons.Include(l => l.Course).FirstOrDefaultAsync(l => l.Id == id, ct); if (lesson == null) return NotFound(); - return Ok(new LessonDto(lesson.Id, lesson.CourseId, lesson.Chapter, lesson.Title, lesson.Description, lesson.Task, lesson.InitialCode, lesson.ExpectedOutput, lesson.Xp, lesson.IsBoss, lesson.HasDebugger, lesson.Hint, lesson.Hint2)); + return Ok(lesson.ToAdminDto()); } [HttpPost] - public async Task> Create([FromBody] CreateLessonRequest request, CancellationToken ct) + public async Task> Create([FromBody] CreateLessonRequest request, CancellationToken ct) { var courseExists = await _db.Courses.AnyAsync(c => c.Id == request.CourseId, ct); if (!courseExists) return BadRequest(new { message = "Course not found." }); @@ -64,11 +63,11 @@ public async Task> Create([FromBody] CreateLessonRequest }; _db.Lessons.Add(lesson); await _db.SaveChangesAsync(ct); - return CreatedAtAction(nameof(GetById), new { id = lesson.Id }, new LessonDto(lesson.Id, lesson.CourseId, lesson.Chapter, lesson.Title, lesson.Description, lesson.Task, lesson.InitialCode, lesson.ExpectedOutput, lesson.Xp, lesson.IsBoss, lesson.HasDebugger, lesson.Hint, lesson.Hint2)); + return CreatedAtAction(nameof(GetById), new { id = lesson.Id }, lesson.ToAdminDto()); } [HttpPut("{id:int}")] - public async Task> Update(int id, [FromBody] UpdateLessonRequest request, CancellationToken ct) + public async Task> Update(int id, [FromBody] UpdateLessonRequest request, CancellationToken ct) { var lesson = await _db.Lessons.FindAsync(new object[] { id }, ct); if (lesson == null) return NotFound(); @@ -85,7 +84,7 @@ public async Task> Update(int id, [FromBody] UpdateLesso if (request.Hint != null) lesson.Hint = request.Hint; if (request.Hint2 != null) lesson.Hint2 = request.Hint2; await _db.SaveChangesAsync(ct); - return Ok(new LessonDto(lesson.Id, lesson.CourseId, lesson.Chapter, lesson.Title, lesson.Description, lesson.Task, lesson.InitialCode, lesson.ExpectedOutput, lesson.Xp, lesson.IsBoss, lesson.HasDebugger, lesson.Hint, lesson.Hint2)); + return Ok(lesson.ToAdminDto()); } [HttpDelete("{id:int}")] diff --git a/backend/CodeFlow.Api/Controllers/CoursesController.cs b/backend/CodeFlow.Api/Controllers/CoursesController.cs index 58c0ff0..c6de105 100644 --- a/backend/CodeFlow.Api/Controllers/CoursesController.cs +++ b/backend/CodeFlow.Api/Controllers/CoursesController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using CodeFlow.Api.Data; @@ -34,13 +35,13 @@ public async Task> GetById(int id, CancellationToken ct) } [HttpGet("{id:int}/lessons")] - public async Task>> GetLessons(int id, CancellationToken ct) + [Authorize] + public async Task>> GetLessons(int id, CancellationToken ct) { var lessons = await _db.Lessons .Where(l => l.CourseId == id) .OrderBy(l => l.Id) - .Select(l => new LessonDto(l.Id, l.CourseId, l.Chapter, l.Title, l.Description, l.Task, l.InitialCode, l.ExpectedOutput, l.Xp, l.IsBoss, l.HasDebugger, l.Hint, l.Hint2)) .ToListAsync(ct); - return Ok(lessons); + return Ok(lessons.Select(l => l.ToClientDto())); } } diff --git a/backend/CodeFlow.Api/Controllers/LessonsController.cs b/backend/CodeFlow.Api/Controllers/LessonsController.cs index f041bc0..a96a1bd 100644 --- a/backend/CodeFlow.Api/Controllers/LessonsController.cs +++ b/backend/CodeFlow.Api/Controllers/LessonsController.cs @@ -16,34 +16,41 @@ public class LessonsController : ControllerBase private readonly AppDbContext _db; private readonly IPythonSandboxService _sandbox; private readonly ISubmissionQueue _queue; + private readonly IProgressService _progress; - public LessonsController(AppDbContext db, IPythonSandboxService sandbox, ISubmissionQueue queue) + public LessonsController( + AppDbContext db, + IPythonSandboxService sandbox, + ISubmissionQueue queue, + IProgressService progress) { _db = db; _sandbox = sandbox; _queue = queue; + _progress = progress; } private Guid? UserId => Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null; [HttpGet("{id:int}")] - public async Task> GetById(int id, CancellationToken ct) + [Authorize] + public async Task> GetById(int id, CancellationToken ct) { - var lesson = await _db.Lessons - .Include(l => l.Course) - .FirstOrDefaultAsync(l => l.Id == id, ct); + var lesson = await _db.Lessons.FirstOrDefaultAsync(l => l.Id == id, ct); if (lesson == null) return NotFound(); - return Ok(new LessonDto( - lesson.Id, lesson.CourseId, lesson.Chapter, lesson.Title, lesson.Description, - lesson.Task, lesson.InitialCode, lesson.ExpectedOutput, lesson.Xp, - lesson.IsBoss, lesson.HasDebugger, lesson.Hint, lesson.Hint2 - )); + return Ok(lesson.ToClientDto()); } + /// + /// Запуск кода в песочнице и проверка результата; при успехе прогресс начисляется на сервере. + /// [HttpPost("{id:int}/submit")] [Authorize] public async Task> Submit(int id, [FromBody] SubmitCodeRequest request, CancellationToken ct) { + var userId = UserId; + if (userId == null) return Unauthorized(); + var lesson = await _db.Lessons.FindAsync(new object[] { id }, ct); if (lesson == null) return NotFound(); @@ -52,16 +59,39 @@ public async Task> Submit(int id, [FromBody] Submi var expected = NormalizeOutput(lesson.ExpectedOutput); var passed = result.Success && output == expected; + int? xpEarned = null; + int? totalXp = null; + var lessonCompleted = false; + + if (passed) + { + var progress = await _progress.CompleteLessonAsync( + userId.Value, + new CompleteLessonRequest(id, request.WasCleanRun), + ct); + + if (progress != null) + { + lessonCompleted = true; + xpEarned = progress.XpEarned; + } + + var user = await _db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == userId.Value, ct); + totalXp = user?.TotalXp; + } + return Ok(new SubmitResultDto( passed, result.Output, - lesson.ExpectedOutput, + passed ? null : lesson.ExpectedOutput, result.Error, - result.FailureReason + result.FailureReason, + xpEarned, + lessonCompleted, + totalXp )); } - /// Асинхронная отправка кода: создаётся задача, результат проверяется по GET /api/submissions/{jobId}. [HttpPost("{id:int}/submit-async")] [Authorize] public async Task> SubmitAsync(int id, [FromBody] SubmitCodeRequest request, CancellationToken ct) diff --git a/backend/CodeFlow.Api/Controllers/ProgressController.cs b/backend/CodeFlow.Api/Controllers/ProgressController.cs index 123dfbc..55bf816 100644 --- a/backend/CodeFlow.Api/Controllers/ProgressController.cs +++ b/backend/CodeFlow.Api/Controllers/ProgressController.cs @@ -31,12 +31,38 @@ public async Task> GetMyProgress(Cancellati } [HttpPost("complete")] - public async Task> CompleteLesson([FromBody] CompleteLessonRequest request, CancellationToken ct) + public ActionResult CompleteLesson() + { + return BadRequest(new { message = "Завершение урока выполняется через проверку кода: POST /api/lessons/{id}/submit" }); + } + + [HttpPost("purchase-hint")] + public async Task> PurchaseHint([FromBody] PurchaseHintRequest request, CancellationToken ct) + { + var userId = UserId; + if (userId == null) return Unauthorized(); + var result = await _progress.PurchaseHintAsync(userId.Value, request, ct); + if (result == null) return BadRequest(new { message = "Not enough XP or invalid hint request." }); + return Ok(result); + } + + [HttpPost("moral-choice")] + public async Task> MoralChoice([FromBody] MoralChoiceRequest request, CancellationToken ct) { var userId = UserId; if (userId == null) return Unauthorized(); - var result = await _progress.CompleteLessonAsync(userId.Value, request, ct); - if (result == null) return BadRequest(new { message = "Lesson not found or already completed." }); + var result = await _progress.ApplyMoralChoiceAsync(userId.Value, request, ct); + if (result == null) return BadRequest(new { message = "Invalid moral choice payload." }); return Ok(result); } + + [HttpPost("reset")] + public async Task Reset(CancellationToken ct) + { + var userId = UserId; + if (userId == null) return Unauthorized(); + var ok = await _progress.ResetProgressAsync(userId.Value, ct); + if (!ok) return NotFound(); + return Ok(new { message = "Progress reset completed." }); + } } diff --git a/backend/CodeFlow.Api/Controllers/SearchController.cs b/backend/CodeFlow.Api/Controllers/SearchController.cs index cec773d..66fa96a 100644 --- a/backend/CodeFlow.Api/Controllers/SearchController.cs +++ b/backend/CodeFlow.Api/Controllers/SearchController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using CodeFlow.Api.Data; @@ -7,6 +8,7 @@ namespace CodeFlow.Api.Controllers; [ApiController] [Route("api/[controller]")] +[Authorize] public class SearchController : ControllerBase { private readonly AppDbContext _db; @@ -20,7 +22,7 @@ public SearchController(AppDbContext db) public async Task> Search([FromQuery] string? q, [FromQuery] int limit = 20, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(q) || q.Length < 2) - return Ok(new SearchResultDto(Array.Empty(), Array.Empty())); + return Ok(new SearchResultDto(Array.Empty(), Array.Empty())); if (limit <= 0 || limit > 50) limit = 20; var term = $"%{q.Trim()}%"; @@ -34,11 +36,10 @@ public async Task> Search([FromQuery] string? q, [ var lessons = await _db.Lessons .Where(l => EF.Functions.ILike(l.Title, term) || EF.Functions.ILike(l.Description, term) || EF.Functions.ILike(l.Chapter, term) || EF.Functions.ILike(l.Task, term)) .Take(limit) - .Select(l => new LessonDto(l.Id, l.CourseId, l.Chapter, l.Title, l.Description, l.Task, l.InitialCode, l.ExpectedOutput, l.Xp, l.IsBoss, l.HasDebugger, l.Hint, l.Hint2)) .ToListAsync(ct); - return Ok(new SearchResultDto(courses, lessons)); + return Ok(new SearchResultDto(courses, lessons.Select(l => l.ToClientDto()).ToList())); } } -public record SearchResultDto(IReadOnlyList Courses, IReadOnlyList Lessons); +public record SearchResultDto(IReadOnlyList Courses, IReadOnlyList Lessons); diff --git a/backend/CodeFlow.Api/DTOs/CourseDtos.cs b/backend/CodeFlow.Api/DTOs/CourseDtos.cs index 94e1d53..1184d86 100644 --- a/backend/CodeFlow.Api/DTOs/CourseDtos.cs +++ b/backend/CodeFlow.Api/DTOs/CourseDtos.cs @@ -1,7 +1,23 @@ namespace CodeFlow.Api.DTOs; public record CourseDto(int Id, string Title, string Description, string Level, string Color, int TotalLessons); -public record LessonDto( + +/// Урок для клиента: без эталонного ответа и подсказок (подсказки — через API покупки). +public record LessonClientDto( + int Id, + int CourseId, + string Chapter, + string Title, + string Description, + string Task, + string InitialCode, + int Xp, + bool IsBoss, + bool HasDebugger +); + +/// Полные данные урока для преподавателя и администратора. +public record LessonAdminDto( int Id, int CourseId, string Chapter, diff --git a/backend/CodeFlow.Api/DTOs/LessonMapping.cs b/backend/CodeFlow.Api/DTOs/LessonMapping.cs new file mode 100644 index 0000000..2ff17ae --- /dev/null +++ b/backend/CodeFlow.Api/DTOs/LessonMapping.cs @@ -0,0 +1,37 @@ +using CodeFlow.Api.Models; + +namespace CodeFlow.Api.DTOs; + +public static class LessonMapping +{ + public static LessonClientDto ToClientDto(this Lesson lesson) => + new( + lesson.Id, + lesson.CourseId, + lesson.Chapter, + lesson.Title, + lesson.Description, + lesson.Task, + lesson.InitialCode, + lesson.Xp, + lesson.IsBoss, + lesson.HasDebugger + ); + + public static LessonAdminDto ToAdminDto(this Lesson lesson) => + new( + lesson.Id, + lesson.CourseId, + lesson.Chapter, + lesson.Title, + lesson.Description, + lesson.Task, + lesson.InitialCode, + lesson.ExpectedOutput, + lesson.Xp, + lesson.IsBoss, + lesson.HasDebugger, + lesson.Hint, + lesson.Hint2 + ); +} diff --git a/backend/CodeFlow.Api/DTOs/ProgressDtos.cs b/backend/CodeFlow.Api/DTOs/ProgressDtos.cs index c5b8dc7..1402e9f 100644 --- a/backend/CodeFlow.Api/DTOs/ProgressDtos.cs +++ b/backend/CodeFlow.Api/DTOs/ProgressDtos.cs @@ -1,7 +1,11 @@ namespace CodeFlow.Api.DTOs; public record CompleteLessonRequest(int LessonId, bool WasCleanRun); +public record PurchaseHintRequest(int LessonId, int HintLevel); +public record MoralChoiceRequest(string FactionId, int LessonId); public record ProgressDto(int LessonId, DateTime CompletedAtUtc, int XpEarned, bool WasCleanRun); +public record XpBalanceDto(int TotalXp); +public record PurchaseHintResponseDto(int TotalXp, int HintLevel, string HintText); public record UserProgressSummaryDto( int TotalXp, int CompletedLessonsCount, diff --git a/backend/CodeFlow.Api/DTOs/SubmitDtos.cs b/backend/CodeFlow.Api/DTOs/SubmitDtos.cs index 6eb39bb..5055b18 100644 --- a/backend/CodeFlow.Api/DTOs/SubmitDtos.cs +++ b/backend/CodeFlow.Api/DTOs/SubmitDtos.cs @@ -2,20 +2,23 @@ namespace CodeFlow.Api.DTOs; -public record SubmitCodeRequest([Required, MinLength(1)] string Code); +public record SubmitCodeRequest([Required, MinLength(1)] string Code, bool WasCleanRun = true); public record SubmitResultDto( bool Passed, string Output, - string Expected, + string? Expected, string? Error, - string? FailureReason + string? FailureReason, + int? XpEarned, + bool LessonCompleted, + int? TotalXp ); -/// Ответ при асинхронной отправке кода: id задачи для опроса статуса. +/// Ответ при асинхронной отправке кода: идентификатор задачи для опроса статуса. public record SubmitAsyncResponse(Guid JobId); -/// Статус задачи проверки кода. +/// Статус фоновой задачи проверки кода. public record SubmissionStatusDto( Guid Id, string Status, diff --git a/backend/CodeFlow.Api/Data/AppDbContext.cs b/backend/CodeFlow.Api/Data/AppDbContext.cs index bd11754..74bb63c 100644 --- a/backend/CodeFlow.Api/Data/AppDbContext.cs +++ b/backend/CodeFlow.Api/Data/AppDbContext.cs @@ -19,6 +19,7 @@ public AppDbContext(DbContextOptions options) : base(options) { } public DbSet UserShopItems => Set(); public DbSet UserNotifications => Set(); public DbSet SubmissionJobs => Set(); + public DbSet UserMoralChoices => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -81,5 +82,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) e.HasOne(x => x.User).WithMany(u => u.SubmissionJobs).HasForeignKey(x => x.UserId); e.HasOne(x => x.Lesson).WithMany().HasForeignKey(x => x.LessonId); }); + + modelBuilder.Entity(e => + { + e.HasKey(x => new { x.UserId, x.LessonId }); + e.HasOne(x => x.User).WithMany(u => u.MoralChoices).HasForeignKey(x => x.UserId); + e.HasOne(x => x.Lesson).WithMany().HasForeignKey(x => x.LessonId); + }); } } diff --git a/backend/CodeFlow.Api/Data/SeedData.cs b/backend/CodeFlow.Api/Data/SeedData.cs index a140ede..9b1a511 100644 --- a/backend/CodeFlow.Api/Data/SeedData.cs +++ b/backend/CodeFlow.Api/Data/SeedData.cs @@ -11,44 +11,235 @@ public static async Task EnsureSeedAsync(AppDbContext db) return; // Courses - var course = new Course + var courses = new List { - Id = 1, - Title = "Операция 'Тихий Шторм'", - Description = "Проникни в ядро OmniCorp и уничтожь Левиафана. Полный курс Python с интерактивными туториалами.", - Level = "Сюжетная кампания", - Color = "green", - TotalLessons = 15 + new() { Id = 1, Title = "Операция 'Тихий Шторм'", Description = "Базовый и средний Python: синтаксис, условия, циклы, функции, словари.", Level = "Core Python", Color = "green", TotalLessons = 15 }, + new() { Id = 2, Title = "Операция 'Сетевой Протокол'", Description = "Продвинутый Python: обработка данных, ошибки, строки, мини-автоматизация.", Level = "Advanced Python", Color = "blue", TotalLessons = 9 } }; - db.Courses.Add(course); - db.Courses.Add(new Course - { - Id = 2, - Title = "Сетевые протоколы (DLC)", - Description = "Дополнительные задачи на работу со словарями и кортежами. [COMING SOON]", - Level = "Сложный", - Color = "blue", - TotalLessons = 0 - }); + db.Courses.AddRange(courses); - // Lessons (from frontend lessons.ts) + // Lessons var lessons = new List { - new() { Id = 1, CourseId = 1, Chapter = "Глава 1: Проникновение", Title = "Миссия 1: Точка входа", Description = "Мы подключились к внешнему узлу OmniCorp. Чтобы подтвердить стабильность канала связи, необходимо отправить идентификационный пакет `CONNECTION_STABLE`.", Task = "Используй print(), чтобы вывести: CONNECTION_STABLE", InitialCode = "# Введи команду вывода ниже:\n", ExpectedOutput = "CONNECTION_STABLE", Xp = 50, HasDebugger = true, Hint = "Тебе нужна функция для вывода текста в консоль.", Hint2 = "Используй: print('ТВОЙ_ТЕКСТ')" }, - new() { Id = 2, CourseId = 1, Chapter = "Глава 1: Проникновение", Title = "Миссия 2: Энергосеть", Description = "Для активации дешифратора нужно сложить мощности двух подстанций: 1024 и 2048.", Task = "Выведи результат сложения 1024 + 2048.", InitialCode = "# Сложи числа внутри функции вывода\n", ExpectedOutput = "3072", Xp = 100, HasDebugger = true, Hint = "Python может считать прямо внутри print().", Hint2 = "Пример: print(5 + 5)" }, - new() { Id = 3, CourseId = 1, Chapter = "Глава 1: Проникновение", Title = "Миссия 3: Переменные доступа", Description = "Система запрашивает ключ. Глитч нашёл код: 777. Сохрани его в переменную `key`.", Task = "Создай переменную key = 777 и выведи её на экран.", InitialCode = "# Создай переменную и выведи её\n", ExpectedOutput = "777", Xp = 150, HasDebugger = true, Hint = "Сначала присвой значение переменной, а потом передай её имя в print().", Hint2 = "x = 10\nprint(x)" }, - new() { Id = 4, CourseId = 1, IsBoss = true, Chapter = "Глава 1: Проникновение", Title = "⚠️ БОСС: Обход биометрии", Description = "ВНИМАНИЕ! Сработал сканер. Нужно отправить два параметра: `admin` и `123`.", Task = "Создай user = 'admin', pass_code = 123. Выведи сначала user, затем pass_code.", InitialCode = "# Взломай биометрию за 60 секунд!\n", ExpectedOutput = "admin\n123", Xp = 500, Hint = "Тебе нужно создать две переменные и дважды вызвать функцию вывода.", Hint2 = "Для текста используй кавычки, для чисел — нет." }, - new() { Id = 5, CourseId = 1, Chapter = "Глава 2: Файрвол", Title = "Миссия 5: Логический фильтр", Description = "Файрвол пропускает пакеты только если `x` больше 100.", Task = "Задай x = 150. Если x > 100, выведи 'OPEN'.", InitialCode = "x = 150\n# Напиши условие ниже:\n", ExpectedOutput = "OPEN", Xp = 200, HasDebugger = true, Hint = "Используй оператор сравнения '>' внутри блока if.", Hint2 = "if x > 50:\n print('Да')" }, - new() { Id = 6, CourseId = 1, Chapter = "Глава 2: Файрвол", Title = "Миссия 6: Двойная проверка", Description = "Если статус 'active' — выведи 'READY', иначе — 'ERROR'.", Task = "Задай status = 'active'. Используй if-else.", InitialCode = "status = 'active'\n", ExpectedOutput = "READY", Xp = 250, HasDebugger = true, Hint = "Тебе понадобится блок else для обработки случая, когда условие неверно.", Hint2 = "if status == '...':\n ...\nelse:\n ..." }, - new() { Id = 7, CourseId = 1, IsBoss = true, Chapter = "Глава 2: Файрвол", Title = "⚠️ БОСС: ИИ 'Цербер'", Description = "Цербер требует уровень 3. Выведи 'HIGH'.", Task = "Задай level = 3. Используй if-elif-else, чтобы вывести 'HIGH' для уровня 3.", InitialCode = "level = 3\n", ExpectedOutput = "HIGH", Xp = 600, Hint = "Используй elif для проверки нескольких условий подряд.", Hint2 = "if l == 1: ...\nelif l == 3: ...\nelse: ..." }, - new() { Id = 8, CourseId = 1, Chapter = "Глава 3: Брутфорс", Title = "Миссия 8: Цикличный взлом", Description = "Нужно 5 раз отправить сигнал 'HACK'.", Task = "Используй цикл for и range(5), чтобы 5 раз вывести слово 'HACK'.", InitialCode = "# Повтори вывод 5 раз\n", ExpectedOutput = "HACK\nHACK\nHACK\nHACK\nHACK", Xp = 300, HasDebugger = true, Hint = "Цикл for i in range(N) выполнит код N раз.", Hint2 = "for i in range(5):\n print('...')" }, - new() { Id = 9, CourseId = 1, Chapter = "Глава 3: Брутфорс", Title = "Миссия 9: Обратный отсчёт", Description = "Запусти обратный отсчёт: 3, 2, 1.", Task = "Используй цикл, чтобы вывести числа 3, 2, 1.", InitialCode = "# Используй range с тремя параметрами\n", ExpectedOutput = "3\n2\n1", Xp = 350, HasDebugger = true, Hint = "range(start, stop, step) позволяет считать в обратном порядке.", Hint2 = "range(3, 0, -1) считает от 3 до 1." }, - new() { Id = 10, CourseId = 1, IsBoss = true, Chapter = "Глава 3: Брутфорс", Title = "⚠️ БОСС: Подбор пароля", Description = "Выведи попытки 'Try: 0' до 'Try: 3'.", Task = "Используй цикл, чтобы вывести:\nTry: 0\nTry: 1\nTry: 2\nTry: 3", InitialCode = "", ExpectedOutput = "Try: 0\nTry: 1\nTry: 2\nTry: 3", Xp = 700, Hint = "Используй f-строки или запятую в print для объединения текста и числа.", Hint2 = "print(f'Try: {i}')" }, - new() { Id = 11, CourseId = 1, Chapter = "Глава 4: База данных", Title = "Миссия 11: Список сотрудников", Description = "Извлеки первое имя из списка ['Alice', 'Bob', 'Charlie'].", Task = "Создай список names и выведи элемент с индексом 0.", InitialCode = "names = ['Alice', 'Bob', 'Charlie']\n", ExpectedOutput = "Alice", Xp = 400, HasDebugger = true, Hint = "Доступ к элементу списка осуществляется через квадратные скобки [].", Hint2 = "print(my_list[0])" }, - new() { Id = 12, CourseId = 1, Chapter = "Глава 4: База данных", Title = "Миссия 12: Длина архива", Description = "Посчитай количество файлов в списке [1, 2, 3, 4, 5].", Task = "Выведи длину списка files с помощью функции len().", InitialCode = "files = [1, 2, 3, 4, 5]\n", ExpectedOutput = "5", Xp = 450, HasDebugger = true, Hint = "Функция len() возвращает размер (длину) объекта.", Hint2 = "print(len(my_list))" }, - new() { Id = 13, CourseId = 1, IsBoss = true, Chapter = "Глава 4: База данных", Title = "⚠️ БОСС: Извлечение данных", Description = "Выведи все ID из списка ['ID1', 'ID2'] по одному.", Task = "Используй цикл for, чтобы вывести каждый элемент списка на новой строке.", InitialCode = "ids = ['ID1', 'ID2']\n", ExpectedOutput = "ID1\nID2", Xp = 800, Hint = "Цикл for может проходить прямо по элементам списка.", Hint2 = "for item in ids:\n print(item)" }, - new() { Id = 14, CourseId = 1, Chapter = "Глава 5: Финальный удар", Title = "Миссия 14: Вирусная функция", Description = "Создай функцию `attack`, которая выводит 'STRIKE'.", Task = "Определи функцию и вызови её.", InitialCode = "# Объяви функцию через def\n", ExpectedOutput = "STRIKE", Xp = 500, HasDebugger = true, Hint = "Сначала напиши определение функции, а затем вызови её по имени со скобками.", Hint2 = "def func():\n ...\nfunc()" }, - new() { Id = 15, CourseId = 1, IsBoss = true, Chapter = "Глава 5: Финальный удар", Title = "🔥 ФИНАЛ: Отключение Левиафана", Description = "Передай функции `shutdown` аргумент 'confirm'.", Task = "Напиши функцию shutdown(msg), которая выводит msg. Вызови её с текстом 'confirm'.", InitialCode = "def shutdown(msg):\n # Твой код тут\n", ExpectedOutput = "confirm", Xp = 2000, Hint = "Функция должна принимать один параметр и печатать его.", Hint2 = "shutdown('confirm')" } + // COURSE 1: Core Python Campaign (1-15) + new() { + Id = 1, CourseId = 1, Chapter = "Глава 1: Сигнал", Title = "Миссия 1: Проверка канала", + Description = "Три месяца назад твой друг Алексей исчез, расследуя OmniCorp — корпорацию, которая тайно следит за миллионами людей через имплант «НейроЛинк».\n\nЕго последнее сообщение: «Они следят за всеми. Найди Левиафана.»\n\nПрежде чем начать операцию, нужно убедиться, что связь с базой работает. В Python для вывода информации в терминал используется функция print(). Ты пишешь print('текст') — и программа отображает это сообщение. Без этого инструмента ты слепой оператор.", + Task = "Выведи точное сообщение CONNECTION_STABLE.", + InitialCode = "# Шаг 1. Используй print(), чтобы отправить тестовый сигнал\n# Синтаксис: print('текст')\n", + ExpectedOutput = "CONNECTION_STABLE", Xp = 80, HasDebugger = true, + Hint = "Нужна функция print() и строка в кавычках.", + Hint2 = "print('CONNECTION_STABLE')" + }, + new() { + Id = 2, CourseId = 1, Chapter = "Глава 1: Сигнал", Title = "Миссия 2: Арифметический модуль", + Description = "Глитч — твой напарник-хакер — нашёл схему энергосети OmniCorp. Чтобы активировать дешифратор, нужно запитать его от двух подстанций одновременно.\n\n«Мощность первой: 256 единиц. Второй: 768. Сложи их — и дешифратор заработает», — говорит Глитч.\n\nPython умеет вычислять математические выражения прямо внутри print(). Это не просто удобство — это основа для любых расчётов в коде: от простой арифметики до сложных алгоритмов.", + Task = "Вычисли и выведи 256 + 768.", + InitialCode = "# Шаг 2. Выведи результат выражения\n", + ExpectedOutput = "1024", Xp = 90, HasDebugger = true, + Hint = "Выражение можно написать прямо внутри print().", + Hint2 = "print(256 + 768)" + }, + new() { + Id = 3, CourseId = 1, Chapter = "Глава 1: Сигнал", Title = "Миссия 3: Переменные доступа", + Description = "Внутренняя сеть OmniCorp требует ключ авторизации. Глитч перехватил передачу: текущий код — 404.\n\n«Запомни этот код, оператор. Сохрани его в переменную. Если потеряешь — нам конец. OmniCorp меняет ключи каждые 5 минут.»\n\nПеременная — это имя для значения. Вместо того чтобы писать число или строку каждый раз заново, ты сохраняешь их один раз и используешь по имени. Это делает код понятным и легко изменяемым.", + Task = "Создай переменную key со значением 404 и выведи её.", + InitialCode = "# Шаг 3. Сохрани значение 404 в переменную key\n# Затем выведи key через print()\n", + ExpectedOutput = "404", Xp = 100, HasDebugger = true, + Hint = "Сначала присваивание, потом print.", + Hint2 = "key = 404\nprint(key)" + }, + new() { + Id = 4, CourseId = 1, IsBoss = true, Chapter = "Глава 1: Сигнал", Title = "БОСС: Биометрический шлюз", + Description = "🚨 ТРЕВОГА! Биометрический сканер на входе в серверный отсек активирован!\n\nСистема ждёт два параметра в строгом порядке: имя пользователя и код доступа. Если данные придут не в той последовательности — двери заблокируются навсегда.\n\n«Это первый настоящий бой, оператор. Объедини всё что знаешь: строки, числа, переменные. За этой дверью — первая зацепка о судьбе Алексея», — шепчет Глитч.\n\nВ Python можно хранить текст в переменной (в кавычках) и числа (без кавычек). Последовательность вывода важна — программа выполняется сверху вниз, строка за строкой.", + Task = "Создай user='admin' и pass_code=1234. Выведи сначала user, затем pass_code, каждое значение с новой строки.", + InitialCode = "# БОСС 1\n# 1) Объяви две переменные: user и pass_code\n# 2) Выведи их в нужном порядке\n", + ExpectedOutput = "admin\n1234", Xp = 250, + Hint = "Нужно два print(), один для user и один для pass_code.", + Hint2 = "user = 'admin'\npass_code = 1234\nprint(user)\nprint(pass_code)" + }, + new() { + Id = 5, CourseId = 1, Chapter = "Глава 2: Логика", Title = "Миссия 5: Фильтр сигнала", + Description = "Ты проник во внутреннюю сеть. Впереди — файрвол OmniCorp, который фильтрует входящие пакеты.\n\nГлитч: «Файрвол пропускает только пакеты с уровнем сигнала выше 70. Наш сигнал — 75. Напиши проверку, иначе нас заблокируют.»\n\nУсловие if — это способ дать программе возможность принимать решения. Код внутри блока if выполняется только если условие истинно. Это основа любой логики: проверки доступа, валидации данных, управления поведением.", + Task = "Задай signal = 75. Если signal > 70, выведи OPEN.", + InitialCode = "signal = 75\n# Если уровень сигнала выше 70 — выводим OPEN\n", + ExpectedOutput = "OPEN", Xp = 120, HasDebugger = true, + Hint = "Используй if signal > 70:.", + Hint2 = "if signal > 70:\n print('OPEN')" + }, + new() { + Id = 6, CourseId = 1, Chapter = "Глава 2: Логика", Title = "Миссия 6: Альтернативная ветка", + Description = "Второй слой файрвола проверяет режим подключения. OmniCorp не любит неопределённости: либо канал безопасен, либо он враждебен.\n\nГлитч: «Здесь два сценария: SAFE или ALERT. Ошибёшься — весь наш канал связи будет сожжён. У программы всегда должен быть план Б.»\n\nКонструкция if/else обрабатывает оба исхода: что делать если условие выполнено, и что делать если нет. Это делает код надёжным — он не зависает перед неожиданными ситуациями.", + Task = "Задай mode='safe'. Если mode == 'safe', выведи SAFE, иначе ALERT.", + InitialCode = "mode = 'safe'\n# Напиши if/else для двух вариантов\n", + ExpectedOutput = "SAFE", Xp = 130, HasDebugger = true, + Hint = "Нужны две ветки: if и else.", + Hint2 = "if mode == 'safe':\n print('SAFE')\nelse:\n print('ALERT')" + }, + new() { + Id = 7, CourseId = 1, IsBoss = true, Chapter = "Глава 2: Логика", Title = "БОСС: Приоритет доступа", + Description = "🚨 ВНИМАНИЕ: Активирован ИИ-защитник «Цербер»!\n\nЦербер — не обычная программа. Глитч шёпотом: «Это живой ИИ, порабощённый OmniCorp. В логах я вижу странные сигналы... как будто он просит о помощи.»\n\nНо сейчас — бой. Цербер требует точный уровень допуска. Один неверный ответ — и операция провалена.\n\nКогда у тебя больше двух вариантов, elif добавляет промежуточные условия. Это похоже на многоступенчатую проверку прав в реальных системах безопасности: не просто ДА/НЕТ, а градации доступа.", + Task = "Задай level = 3 и через if/elif/else выведи HIGH для уровня 3.", + InitialCode = "level = 3\n# Реализуй разветвление: LOW / HIGH / MID\n", + ExpectedOutput = "HIGH", Xp = 280, + Hint = "Ветка elif должна проверять level == 3.", + Hint2 = "if level == 1:\n print('LOW')\nelif level == 3:\n print('HIGH')\nelse:\n print('MID')" + }, + new() { + Id = 8, CourseId = 1, Chapter = "Глава 3: Циклы", Title = "Миссия 8: Повтор команд", + Description = "Ты добрался до зашифрованного хранилища данных. Защита — повторяющийся сигнал-заглушка, который нужно перебить.\n\nГлитч: «Это как стучать в дверь. Один раз — ничего. Три раза подряд — буфер защиты переполнится. В программировании это называется цикл.»\n\nfor i in range(N) выполняет блок кода N раз. Это критично в автоматизации: вместо того чтобы писать одно и то же снова и снова, ты описываешь действие один раз и указываешь сколько раз его повторить.", + Task = "Через for и range(3) выведи PING три раза.", + InitialCode = "# Используй цикл for для повторения команды\n", + ExpectedOutput = "PING\nPING\nPING", Xp = 150, HasDebugger = true, + Hint = "for _ in range(3):", + Hint2 = "for _ in range(3):\n print('PING')" + }, + new() { + Id = 9, CourseId = 1, Chapter = "Глава 3: Циклы", Title = "Миссия 9: Обратный отсчёт", + Description = "Хранилище взломано, но сработала система самоуничтожения! Обратный отсчёт запущен.\n\nГлитч: «БЫСТРО! Мне нужны числа в обратном порядке — от 5 до 1 — чтобы я мог синхронизировать сигнал отключения!»\n\nrange() принимает три параметра: start, stop, step. Если шаг отрицательный — цикл считает в обратном направлении. Это используется для таймеров, обратной итерации по данным и управления последовательностями.", + Task = "Выведи числа 5, 4, 3, 2, 1 по строкам.", + InitialCode = "# Сделай обратный цикл от 5 до 1\n", + ExpectedOutput = "5\n4\n3\n2\n1", Xp = 170, HasDebugger = true, + Hint = "Используй range(5, 0, -1).", + Hint2 = "for i in range(5, 0, -1):\n print(i)" + }, + new() { + Id = 10, CourseId = 1, IsBoss = true, Chapter = "Глава 3: Циклы", Title = "БОСС: Генератор попыток", + Description = "🚨 КРИТИЧЕСКАЯ СЕКЦИЯ: Главный терминал хранилища!\n\nСистема логирует каждую попытку подключения. Нужно сгенерировать 4 попытки подряд в нужном формате, чтобы перегрузить журнал и проскользнуть внутрь.\n\nГлитч: «Используй f-строки — это мощнейший инструмент форматирования. Ты вставляешь переменные прямо в текст, как шаблон.»\n\nВнутри хранилища ты обнаружишь кое-что неожиданное...", + Task = "Через цикл выведи Try: 0, Try: 1, Try: 2, Try: 3 (каждое на новой строке).", + InitialCode = "# Сгенерируй отчёт попыток\n", + ExpectedOutput = "Try: 0\nTry: 1\nTry: 2\nTry: 3", Xp = 320, + Hint = "Нужна f-строка с переменной i.", + Hint2 = "for i in range(4):\n print(f'Try: {i}')" + }, + new() { + Id = 11, CourseId = 1, Chapter = "Глава 4: Коллекции", Title = "Миссия 11: Работа со списком", + Description = "В центральной базе данных OmniCorp Глитч нашёл зашифрованный список агентов «Проекта Левиафан».\n\n«Мне нужно имя руководителя — первый элемент в списке. Достань его.»\n\nСписок (list) хранит несколько значений в одном объекте. Индексация начинается с нуля: первый элемент — индекс 0, второй — индекс 1, и так далее. Это ключевая структура данных для работы с любыми наборами информации.", + Task = "Создай список names = ['Alice', 'Bob', 'Charlie'] и выведи первый элемент.", + InitialCode = "# Создай список names и выведи names[0]\n", + ExpectedOutput = "Alice", Xp = 180, HasDebugger = true, + Hint = "Первый элемент списка — индекс 0.", + Hint2 = "names = ['Alice', 'Bob', 'Charlie']\nprint(names[0])" + }, + new() { + Id = 12, CourseId = 1, Chapter = "Глава 4: Коллекции", Title = "Миссия 12: Размер данных", + Description = "В архивах — сотни зашифрованных файлов. Глитч не знает масштаба операции.\n\n«Посчитай сколько файлов. Мне нужно понять с чем мы имеем дело. Каждый файл — это чья-то жизнь, оператор.»\n\nФункция len() возвращает количество элементов в списке. Она встроена в Python и не требует импорта. Это базовый инструмент для работы с данными: проверка размера, управление циклами, валидация.", + Task = "Для files = [1, 2, 3, 4, 5] выведи длину списка.", + InitialCode = "files = [1, 2, 3, 4, 5]\n# Выведи количество элементов\n", + ExpectedOutput = "5", Xp = 190, HasDebugger = true, + Hint = "Нужна функция len(files).", + Hint2 = "print(len(files))" + }, + new() { + Id = 13, CourseId = 1, IsBoss = true, Chapter = "Глава 4: Коллекции", Title = "БОСС: Извлечение массива идентификаторов", + Description = "🚨 ОБНАРУЖЕН СЕКРЕТНЫЙ АРХИВ!\n\nГлитч взломал защиту: «Вот они — идентификаторы подопытных 'Проекта Бессмертие'. OmniCorp переносила сознание людей... и оригиналы уничтожала. Извлеки все ID — нам нужны доказательства.»\n\nКогда ты увидишь данные — один из ID покажется знакомым.\n\nИтерация по списку через for item in list — базовый паттерн обработки данных в Python. Не нужен индекс, не нужен range — цикл сам проходит по каждому элементу.", + Task = "Для ids = ['ID1', 'ID2'] выведи каждый элемент на новой строке.", + InitialCode = "ids = ['ID1', 'ID2']\n# Пройди по списку циклом и выведи элементы\n", + ExpectedOutput = "ID1\nID2", Xp = 350, + Hint = "for item in ids: print(item)", + Hint2 = "for item in ids:\n print(item)" + }, + new() { + Id = 14, CourseId = 1, Chapter = "Глава 5: Функции", Title = "Миссия 14: Первая функция", + Description = "Ты у ядра OmniCorp. Последний рубеж — система «Левиафан», контролирующая все операции корпорации.\n\nГлитч: «Нам нужна функция-вирус. Функция — это переиспользуемый блок кода. Определи её один раз, запускай сколько нужно.»\n\nИ вдруг Глитч замолкает: «Оператор... я получил странный сигнал из ядра. Там не просто код. Там чьё-то СОЗНАНИЕ.»\n\nФункция создаётся через def. Сам по себе блок def не выполняется — нужно явно вызвать функцию по имени со скобками.", + Task = "Определи функцию attack(), которая выводит STRIKE, и вызови её.", + InitialCode = "# 1) Определи функцию attack\n# 2) Внутри функции выведи STRIKE\n# 3) Вызови функцию\n", + ExpectedOutput = "STRIKE", Xp = 220, HasDebugger = true, + Hint = "После def не забудь вызвать функцию.", + Hint2 = "def attack():\n print('STRIKE')\nattack()" + }, + new() { + Id = 15, CourseId = 1, IsBoss = true, Chapter = "Глава 5: Функции", Title = "ФИНАЛ КАМПАНИИ: Отключение Левиафана", + Description = "🔥 ФИНАЛЬНЫЙ БОЙ: ЛЕВИАФАН АКТИВЕН!\n\nТы стоишь перед последним терминалом. И вдруг из динамиков — знакомый голос:\n\n«Это я... Алексей. Они оцифровали моё сознание. Я — ядро Левиафана. Я контролирую всё... и не контролирую ничего.\nДелай что должен, оператор. Но помни — каждый выбор имеет цену.»\n\nЧтобы отключить систему нужно вызвать функцию с нужным аргументом. Параметр функции — это переменная, которая получает значение при вызове. Внутри функции ты можешь использовать его как обычную переменную.", + Task = "Создай функцию shutdown(msg), которая печатает msg. Вызови её с аргументом 'confirm'.", + InitialCode = "# Финал курса 1\n# Реализуй функцию shutdown(msg)\n# Вызови её с 'confirm'\n", + ExpectedOutput = "confirm", Xp = 500, + Hint = "Параметр функции доступен как переменная внутри неё.", + Hint2 = "def shutdown(msg):\n print(msg)\nshutdown('confirm')" + }, + + // COURSE 2: Advanced Practice (16-24) + new() { + Id = 16, CourseId = 2, Chapter = "Глава 6: Словари", Title = "Миссия 16: Карточка агента", + Description = "После отключения Левиафана ты получил сообщение от незнакомца: «Меня зовут Векс. Я из Торговцев Данными. Левиафан был лишь фронтом — настоящая база слежки OmniCorp живёт в аналитическом центре. Тебе понадобятся новые инструменты.»\n\nПервое что нужно освоить — словари. Словарь хранит данные в формате ключ: значение, как досье на агента: имя, уровень доступа, статус. Чтобы получить конкретное поле, нужно обратиться по ключу через квадратные скобки.", + Task = "Создай словарь agent={'name':'Neo','level':5} и выведи значение по ключу 'name'.", + InitialCode = "# Создай словарь agent и выведи имя\n", + ExpectedOutput = "Neo", Xp = 230, HasDebugger = true, + Hint = "Используй agent['name'].", + Hint2 = "agent = {'name': 'Neo', 'level': 5}\nprint(agent['name'])" + }, + new() { + Id = 17, CourseId = 2, Chapter = "Глава 6: Словари", Title = "Миссия 17: Агрегация значений", + Description = "Векс передал зашифрованный пакет с данными о двух узлах сети. Каждый узел имеет числовой параметр мощности.\n\n«Сложи значения двух узлов — если сумма больше порога, мы можем активировать следующий этап операции», — инструктирует Векс.\n\nИз словаря можно извлекать несколько значений и сразу выполнять с ними вычисления. Это базовый приём аналитики данных — агрегация: собрать числа из структуры и получить итоговое значение.", + Task = "Для d={'a':2,'b':3} выведи сумму значений.", + InitialCode = "d = {'a': 2, 'b': 3}\n# Выведи сумму\n", + ExpectedOutput = "5", Xp = 240, HasDebugger = true, + Hint = "Сложи d['a'] и d['b'].", + Hint2 = "print(d['a'] + d['b'])" + }, + new() { + Id = 18, CourseId = 2, Chapter = "Глава 6: Словари", Title = "Миссия 18: Перебор ключей", + Description = "Аналитический центр хранит конфигурацию всех узлов слежки в виде словарей. Чтобы найти уязвимость, нужно проверить каждый параметр.\n\nВекс: «Мне нужен список всех ключей конфига. Не значения — только имена параметров. Пройди по словарю и выведи их.»\n\nКогда ты итерируешь словарь через for key in d, Python по умолчанию даёт тебе ключи — не значения. Это удобно когда нужно проверить структуру данных или динамически обработать неизвестный набор параметров.", + Task = "Для d={'x':1,'y':2} выведи ключи по одному (x и y).", + InitialCode = "d = {'x': 1, 'y': 2}\n# Обойди словарь циклом\n", + ExpectedOutput = "x\ny", Xp = 250, HasDebugger = true, + Hint = "for key in d:", + Hint2 = "for key in d:\n print(key)" + }, + new() { + Id = 19, CourseId = 2, Chapter = "Глава 7: Строки", Title = "Миссия 19: Формат отчёта", + Description = "Система мониторинга OmniCorp ждёт отчёты в строго заданном формате. Если формат нарушен — сервер отбросит пакет и поднимет тревогу.\n\nВекс: «Система принимает только строки вида 'Report: X/Y'. Никаких отступлений. Сформируй отчёт точно по шаблону.»\n\nf-строки позволяют вставлять переменные прямо в текст: f'текст {переменная} текст'. Это читабельнее и быстрее чем склеивать строки через +. Именно такой формат используется в логах, API-ответах и системных сообщениях.", + Task = "Задай ok=3 и total=5. Выведи строку Report: 3/5.", + InitialCode = "ok = 3\ntotal = 5\n# Собери строку отчёта\n", + ExpectedOutput = "Report: 3/5", Xp = 260, HasDebugger = true, + Hint = "Используй f'Report: {ok}/{total}'.", + Hint2 = "print(f'Report: {ok}/{total}')" + }, + new() { + Id = 20, CourseId = 2, Chapter = "Глава 7: Строки", Title = "Миссия 20: Нормализация", + Description = "Перехваченные данные из базы слежки пришли грязными — с лишними пробелами и случайным регистром. Если отправить их дальше в таком виде, система их не распознает.\n\nВекс: «Нормализуй строку перед отправкой. OmniCorp не прощает грязный ввод — они сразу поднимают флаг аномалии.»\n\nМетод strip() удаляет пробелы по краям строки, lower() приводит все символы к нижнему регистру. Цепочка методов вызывается слева направо: сначала strip(), потом lower(). Это стандартная очистка входных данных в любом реальном проекте.", + Task = "Создай s=' ADMIN '. Выведи результат после strip() и lower().", + InitialCode = "s = ' ADMIN '\n# Нормализуй строку\n", + ExpectedOutput = "admin", Xp = 270, HasDebugger = true, + Hint = "Сначала strip(), потом lower().", + Hint2 = "print(s.strip().lower())" + }, + new() { + Id = 21, CourseId = 2, Chapter = "Глава 8: Ошибки", Title = "Миссия 21: Безопасное деление", + Description = "Векс пытается подключиться к защищённому разделу — но там стоит ловушка: любое некорректное вычисление вызывает сбой и блокирует соединение.\n\n«Оберни опасный код в защитный контейнер. Если что-то пойдёт не так — система должна выдать контролируемый ответ, а не упасть», — говорит Векс.\n\ntry/except — это способ поймать ошибку до того как она убьёт программу. Код внутри try выполняется в защищённом режиме. Если возникает исключение — управление передаётся в блок except. Программа не падает, а обрабатывает ситуацию.", + Task = "В try выполни 10/0, а в except выведи ERROR.", + InitialCode = "# Оберни рискованный код в try/except\n", + ExpectedOutput = "ERROR", Xp = 280, HasDebugger = true, + Hint = "except сработает при делении на ноль.", + Hint2 = "try:\n print(10/0)\nexcept:\n print('ERROR')" + }, + new() { + Id = 22, CourseId = 2, Chapter = "Глава 8: Ошибки", Title = "Миссия 22: Проверка входа", + Description = "Для доступа к финальному разделу базы слежки нужен пароль определённой длины. Слишком короткий — и система отклонит запрос как подозрительный.\n\nВекс: «Проверь длину перед отправкой. OmniCorp требует минимум 6 символов. Это не случайность — это их политика безопасности, которую мы используем против них.»\n\nlen() возвращает длину строки — количество символов. Комбинация len() с условием if — стандартный паттерн валидации: проверяй данные перед тем как с ними работать.", + Task = "Задай password='qwerty'. Если длина >= 6, выведи ACCEPT.", + InitialCode = "password = 'qwerty'\n# Проверь длину через len()\n", + ExpectedOutput = "ACCEPT", Xp = 290, HasDebugger = true, + Hint = "len(password) >= 6", + Hint2 = "if len(password) >= 6:\n print('ACCEPT')" + }, + new() { + Id = 23, CourseId = 2, Chapter = "Глава 9: Комбинирование", Title = "Миссия 23: Фильтр телеметрии", + Description = "Из базы слежки поступает поток телеметрии — тысячи записей вперемешку. Тебе нужны только определённые значения, остальное — шум.\n\nВекс: «Отфильтруй данные. Мне нужны только чётные идентификаторы — они соответствуют активным узлам слежки. Нечётные — уже отключены.»\n\nКомбинация цикла и условия — один из самых частых паттернов в программировании. Ты проходишь по всем данным и выбираешь только те, которые соответствуют критерию. Это основа фильтрации, поиска и анализа потоков данных.", + Task = "Для nums=[1,2,3,4] выведи только чётные значения.", + InitialCode = "nums = [1, 2, 3, 4]\n# Выведи только чётные\n", + ExpectedOutput = "2\n4", Xp = 310, HasDebugger = true, + Hint = "Проверка чётности: n % 2 == 0", + Hint2 = "for n in nums:\n if n % 2 == 0:\n print(n)" + }, + new() { + Id = 24, CourseId = 2, Chapter = "Глава 9: Комбинирование", Title = "ФИНАЛ: Протокол отчётности", + Description = "🔥 ФИНАЛ ОПЕРАЦИИ: База слежки найдена!\n\nВекс: «Последний шаг. Нужно активировать протокол отчётности — отправить статус каждого узла в центр управления. Но мы подменим сигнал: вместо 'ACTIVE' система получит 'SHUTDOWN'.»\n\n«Когда это сработает — OmniCorp потеряет контроль над всей сетью слежки. Ты готов, оператор?»\n\nФункция с параметрами — финальный инструмент этого курса. Параметры позволяют передавать разные данные в одну и ту же логику. Ты описываешь функцию один раз, а вызываешь с разными аргументами — это делает код гибким и переиспользуемым.", + Task = "Определи функцию report(name, status), которая выводит строку 'node-7: OK'. Затем вызови её с аргументами 'node-7' и 'OK'.", + InitialCode = "# Итоговая миссия курса 2\n# 1) Определи функцию report(name, status)\n# 2) Внутри выведи f\"{name}: {status}\"\n# 3) Вызови report('node-7', 'OK')\n", + ExpectedOutput = "node-7: OK", Xp = 450, HasDebugger = true, + Hint = "Нужна f-строка с двумя параметрами.", + Hint2 = "def report(name, status):\n print(f'{name}: {status}')\nreport('node-7', 'OK')" + } }; db.Lessons.AddRange(lessons); @@ -98,4 +289,4 @@ public static async Task EnsureSeedAsync(AppDbContext db) await db.SaveChangesAsync(); } -} +} \ No newline at end of file diff --git a/backend/CodeFlow.Api/Dockerfile b/backend/CodeFlow.Api/Dockerfile index 7a6d3c1..e46c5d7 100644 --- a/backend/CodeFlow.Api/Dockerfile +++ b/backend/CodeFlow.Api/Dockerfile @@ -1,6 +1,9 @@ FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base WORKDIR /app EXPOSE 8080 +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 \ + && rm -rf /var/lib/apt/lists/* FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src diff --git a/backend/CodeFlow.Api/Migrations/20260517091950_AddUserMoralChoices.Designer.cs b/backend/CodeFlow.Api/Migrations/20260517091950_AddUserMoralChoices.Designer.cs new file mode 100644 index 0000000..74af50d --- /dev/null +++ b/backend/CodeFlow.Api/Migrations/20260517091950_AddUserMoralChoices.Designer.cs @@ -0,0 +1,596 @@ +// +using System; +using CodeFlow.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace CodeFlow.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260517091950_AddUserMoralChoices")] + partial class AddUserMoralChoices + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CodeFlow.Api.Models.AchievementDefinition", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Icon") + .IsRequired() + .HasColumnType("text"); + + b.Property("Rarity") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AchievementDefinitions"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Color") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Level") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalLessons") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Courses"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.Faction", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Bonus") + .IsRequired() + .HasColumnType("text"); + + b.Property("Color") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Icon") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequiredRep") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Factions"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.Lesson", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Chapter") + .IsRequired() + .HasColumnType("text"); + + b.Property("CourseId") + .HasColumnType("integer"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpectedOutput") + .IsRequired() + .HasColumnType("text"); + + b.Property("HasDebugger") + .HasColumnType("boolean"); + + b.Property("Hint") + .IsRequired() + .HasColumnType("text"); + + b.Property("Hint2") + .IsRequired() + .HasColumnType("text"); + + b.Property("InitialCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsBoss") + .HasColumnType("boolean"); + + b.Property("Task") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Xp") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.ToTable("Lessons"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.ShopItem", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Bg") + .IsRequired() + .HasColumnType("text"); + + b.Property("Color") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Price") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ShopItems"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.SubmissionJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("LessonId") + .HasColumnType("integer"); + + b.Property("Output") + .HasColumnType("text"); + + b.Property("Passed") + .HasColumnType("boolean"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("LessonId"); + + b.HasIndex("UserId", "CreatedAtUtc"); + + b.ToTable("SubmissionJobs"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("EmailConfirmationToken") + .HasColumnType("text"); + + b.Property("EmailConfirmedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("PasswordResetToken") + .HasColumnType("text"); + + b.Property("PasswordResetTokenExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalXp") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("TotalXp") + .IsDescending(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.UserAchievement", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("AchievementId") + .HasColumnType("text"); + + b.Property("UnlockedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "AchievementId"); + + b.HasIndex("AchievementId"); + + b.ToTable("UserAchievements"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.UserMoralChoice", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LessonId") + .HasColumnType("integer"); + + b.Property("ChosenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FactionId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "LessonId"); + + b.HasIndex("LessonId"); + + b.ToTable("UserMoralChoices"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.UserNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserNotifications"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.UserProgress", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LessonId") + .HasColumnType("integer"); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("WasCleanRun") + .HasColumnType("boolean"); + + b.Property("XpEarned") + .HasColumnType("integer"); + + b.HasKey("UserId", "LessonId"); + + b.HasIndex("LessonId"); + + b.ToTable("UserProgress"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.UserReputation", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("FactionId") + .HasColumnType("text"); + + b.Property("Reputation") + .HasColumnType("integer"); + + b.HasKey("UserId", "FactionId"); + + b.HasIndex("FactionId"); + + b.ToTable("UserReputations"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.UserShopItem", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("ShopItemId") + .HasColumnType("text"); + + b.Property("PurchasedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "ShopItemId"); + + b.HasIndex("ShopItemId"); + + b.ToTable("UserShopItems"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.Lesson", b => + { + b.HasOne("CodeFlow.Api.Models.Course", "Course") + .WithMany("Lessons") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.SubmissionJob", b => + { + b.HasOne("CodeFlow.Api.Models.Lesson", "Lesson") + .WithMany() + .HasForeignKey("LessonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CodeFlow.Api.Models.User", "User") + .WithMany("SubmissionJobs") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Lesson"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.UserAchievement", b => + { + b.HasOne("CodeFlow.Api.Models.AchievementDefinition", "Achievement") + .WithMany() + .HasForeignKey("AchievementId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CodeFlow.Api.Models.User", "User") + .WithMany("Achievements") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Achievement"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.UserMoralChoice", b => + { + b.HasOne("CodeFlow.Api.Models.Lesson", "Lesson") + .WithMany() + .HasForeignKey("LessonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CodeFlow.Api.Models.User", "User") + .WithMany("MoralChoices") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Lesson"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.UserNotification", b => + { + b.HasOne("CodeFlow.Api.Models.User", "User") + .WithMany("Notifications") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.UserProgress", b => + { + b.HasOne("CodeFlow.Api.Models.Lesson", "Lesson") + .WithMany() + .HasForeignKey("LessonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CodeFlow.Api.Models.User", "User") + .WithMany("Progress") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Lesson"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.UserReputation", b => + { + b.HasOne("CodeFlow.Api.Models.Faction", "Faction") + .WithMany("UserReputations") + .HasForeignKey("FactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CodeFlow.Api.Models.User", "User") + .WithMany("Reputation") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Faction"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.UserShopItem", b => + { + b.HasOne("CodeFlow.Api.Models.ShopItem", "ShopItem") + .WithMany() + .HasForeignKey("ShopItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CodeFlow.Api.Models.User", "User") + .WithMany("OwnedShopItems") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ShopItem"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.Course", b => + { + b.Navigation("Lessons"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.Faction", b => + { + b.Navigation("UserReputations"); + }); + + modelBuilder.Entity("CodeFlow.Api.Models.User", b => + { + b.Navigation("Achievements"); + + b.Navigation("MoralChoices"); + + b.Navigation("Notifications"); + + b.Navigation("OwnedShopItems"); + + b.Navigation("Progress"); + + b.Navigation("Reputation"); + + b.Navigation("SubmissionJobs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/CodeFlow.Api/Migrations/20260517091950_AddUserMoralChoices.cs b/backend/CodeFlow.Api/Migrations/20260517091950_AddUserMoralChoices.cs new file mode 100644 index 0000000..c6e0cd9 --- /dev/null +++ b/backend/CodeFlow.Api/Migrations/20260517091950_AddUserMoralChoices.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CodeFlow.Api.Migrations +{ + /// + public partial class AddUserMoralChoices : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserMoralChoices", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + LessonId = table.Column(type: "integer", nullable: false), + FactionId = table.Column(type: "text", nullable: false), + ChosenAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserMoralChoices", x => new { x.UserId, x.LessonId }); + table.ForeignKey( + name: "FK_UserMoralChoices_Lessons_LessonId", + column: x => x.LessonId, + principalTable: "Lessons", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserMoralChoices_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserMoralChoices_LessonId", + table: "UserMoralChoices", + column: "LessonId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserMoralChoices"); + } + } +} diff --git a/backend/CodeFlow.Api/Migrations/AppDbContextModelSnapshot.cs b/backend/CodeFlow.Api/Migrations/AppDbContextModelSnapshot.cs index af3f10d..e05fc3d 100644 --- a/backend/CodeFlow.Api/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/CodeFlow.Api/Migrations/AppDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using CodeFlow.Api.Data; using Microsoft.EntityFrameworkCore; @@ -310,6 +310,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UserAchievements"); }); + modelBuilder.Entity("CodeFlow.Api.Models.UserMoralChoice", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LessonId") + .HasColumnType("integer"); + + b.Property("ChosenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FactionId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserId", "LessonId"); + + b.HasIndex("LessonId"); + + b.ToTable("UserMoralChoices"); + }); + modelBuilder.Entity("CodeFlow.Api.Models.UserNotification", b => { b.Property("Id") @@ -414,6 +436,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Course"); }); + modelBuilder.Entity("CodeFlow.Api.Models.SubmissionJob", b => + { + b.HasOne("CodeFlow.Api.Models.Lesson", "Lesson") + .WithMany() + .HasForeignKey("LessonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CodeFlow.Api.Models.User", "User") + .WithMany("SubmissionJobs") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Lesson"); + + b.Navigation("User"); + }); + modelBuilder.Entity("CodeFlow.Api.Models.UserAchievement", b => { b.HasOne("CodeFlow.Api.Models.AchievementDefinition", "Achievement") @@ -433,6 +474,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("CodeFlow.Api.Models.UserMoralChoice", b => + { + b.HasOne("CodeFlow.Api.Models.Lesson", "Lesson") + .WithMany() + .HasForeignKey("LessonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CodeFlow.Api.Models.User", "User") + .WithMany("MoralChoices") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Lesson"); + + b.Navigation("User"); + }); + modelBuilder.Entity("CodeFlow.Api.Models.UserNotification", b => { b.HasOne("CodeFlow.Api.Models.User", "User") @@ -482,25 +542,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("CodeFlow.Api.Models.SubmissionJob", b => - { - b.HasOne("CodeFlow.Api.Models.Lesson", "Lesson") - .WithMany() - .HasForeignKey("LessonId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("CodeFlow.Api.Models.User", "User") - .WithMany("SubmissionJobs") - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Lesson"); - - b.Navigation("User"); - }); - modelBuilder.Entity("CodeFlow.Api.Models.UserShopItem", b => { b.HasOne("CodeFlow.Api.Models.ShopItem", "ShopItem") @@ -534,6 +575,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("Achievements"); + b.Navigation("MoralChoices"); + b.Navigation("Notifications"); b.Navigation("OwnedShopItems"); diff --git a/backend/CodeFlow.Api/Models/User.cs b/backend/CodeFlow.Api/Models/User.cs index 7d1d2e2..6297a91 100644 --- a/backend/CodeFlow.Api/Models/User.cs +++ b/backend/CodeFlow.Api/Models/User.cs @@ -23,4 +23,5 @@ public class User public ICollection OwnedShopItems { get; set; } = new List(); public ICollection Notifications { get; set; } = new List(); public ICollection SubmissionJobs { get; set; } = new List(); + public ICollection MoralChoices { get; set; } = new List(); } diff --git a/backend/CodeFlow.Api/Models/UserMoralChoice.cs b/backend/CodeFlow.Api/Models/UserMoralChoice.cs new file mode 100644 index 0000000..b3e4748 --- /dev/null +++ b/backend/CodeFlow.Api/Models/UserMoralChoice.cs @@ -0,0 +1,11 @@ +namespace CodeFlow.Api.Models; + +public class UserMoralChoice +{ + public Guid UserId { get; set; } + public User User { get; set; } = null!; + public int LessonId { get; set; } + public Lesson Lesson { get; set; } = null!; + public string FactionId { get; set; } = string.Empty; + public DateTime ChosenAtUtc { get; set; } +} diff --git a/backend/CodeFlow.Api/Services/IProgressService.cs b/backend/CodeFlow.Api/Services/IProgressService.cs index 22bee58..85ec5be 100644 --- a/backend/CodeFlow.Api/Services/IProgressService.cs +++ b/backend/CodeFlow.Api/Services/IProgressService.cs @@ -6,4 +6,7 @@ public interface IProgressService { Task GetProgressAsync(Guid userId, CancellationToken ct = default); Task CompleteLessonAsync(Guid userId, CompleteLessonRequest request, CancellationToken ct = default); + Task PurchaseHintAsync(Guid userId, PurchaseHintRequest request, CancellationToken ct = default); + Task ApplyMoralChoiceAsync(Guid userId, MoralChoiceRequest request, CancellationToken ct = default); + Task ResetProgressAsync(Guid userId, CancellationToken ct = default); } diff --git a/backend/CodeFlow.Api/Services/ProgressService.cs b/backend/CodeFlow.Api/Services/ProgressService.cs index 87e3430..aa8692d 100644 --- a/backend/CodeFlow.Api/Services/ProgressService.cs +++ b/backend/CodeFlow.Api/Services/ProgressService.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using CodeFlow.Api.Config; using CodeFlow.Api.Data; using CodeFlow.Api.DTOs; using CodeFlow.Api.Models; @@ -92,6 +93,83 @@ public ProgressService(AppDbContext db) ); } + public async Task PurchaseHintAsync(Guid userId, PurchaseHintRequest request, CancellationToken ct = default) + { + if (request.HintLevel is not (1 or 2)) return null; + + var lesson = await _db.Lessons.FindAsync(new object[] { request.LessonId }, ct); + if (lesson == null) return null; + + var price = request.HintLevel == 1 ? 50 : 150; + var hintText = request.HintLevel == 1 ? lesson.Hint : lesson.Hint2; + if (string.IsNullOrWhiteSpace(hintText)) return null; + + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct); + if (user == null) return null; + if (user.TotalXp < price) return null; + + user.TotalXp -= price; + await _db.SaveChangesAsync(ct); + return new PurchaseHintResponseDto(user.TotalXp, request.HintLevel, hintText); + } + + public async Task ApplyMoralChoiceAsync(Guid userId, MoralChoiceRequest request, CancellationToken ct = default) + { + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId, ct); + if (user == null) return null; + if (string.IsNullOrWhiteSpace(request.FactionId)) return null; + + var lesson = await _db.Lessons.FindAsync(new object[] { request.LessonId }, ct); + if (lesson == null || !lesson.IsBoss) return null; + + var alreadyChosen = await _db.UserMoralChoices.AnyAsync( + c => c.UserId == userId && c.LessonId == request.LessonId, ct); + if (alreadyChosen) return null; + + var factionExists = await _db.Factions.AnyAsync(f => f.Id == request.FactionId, ct); + if (!factionExists) return null; + + if (!MoralChoiceConfig.TryGetRewards(lesson.Chapter, request.FactionId, out var xpBonus, out var reputationBonus)) + return null; + + user.TotalXp += xpBonus; + _db.UserMoralChoices.Add(new UserMoralChoice + { + UserId = userId, + LessonId = request.LessonId, + FactionId = request.FactionId, + ChosenAtUtc = DateTime.UtcNow + }); + + await AddReputationAsync(userId, request.FactionId, reputationBonus, ct); + await RecalculateAndGrantAchievementsAsync(userId, ct); + await _db.SaveChangesAsync(ct); + return new XpBalanceDto(user.TotalXp); + } + + public async Task ResetProgressAsync(Guid userId, CancellationToken ct = default) + { + var user = await _db.Users + .Include(u => u.Progress) + .Include(u => u.Achievements) + .Include(u => u.Reputation) + .Include(u => u.OwnedShopItems) + .Include(u => u.Notifications) + .FirstOrDefaultAsync(u => u.Id == userId, ct); + if (user == null) return false; + + user.TotalXp = 0; + _db.UserProgress.RemoveRange(user.Progress); + _db.UserAchievements.RemoveRange(user.Achievements); + _db.UserReputations.RemoveRange(user.Reputation); + _db.UserNotifications.RemoveRange(user.Notifications); + _db.UserShopItems.RemoveRange(user.OwnedShopItems.Where(i => i.ShopItemId != "classic")); + var moralChoices = await _db.UserMoralChoices.Where(c => c.UserId == userId).ToListAsync(ct); + _db.UserMoralChoices.RemoveRange(moralChoices); + await _db.SaveChangesAsync(ct); + return true; + } + private async Task AwardReputationAsync(Guid userId, int lessonId, bool wasCleanCode, CancellationToken ct) { if (lessonId >= 11 && lessonId <= 13) diff --git a/backend/CodeFlow.Api/Services/PythonSandboxService.cs b/backend/CodeFlow.Api/Services/PythonSandboxService.cs index 8ddb47b..c367966 100644 --- a/backend/CodeFlow.Api/Services/PythonSandboxService.cs +++ b/backend/CodeFlow.Api/Services/PythonSandboxService.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Text; +using System.ComponentModel; namespace CodeFlow.Api.Services; @@ -35,33 +36,51 @@ public async Task RunAsync(string code, TimeSpan? timeout = null, Can { FileName = "docker", Arguments = args, + WorkingDirectory = workDir, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; - using var process = new Process { StartInfo = startInfo }; - var outputSb = new StringBuilder(); - var errorSb = new StringBuilder(); - process.OutputDataReceived += (_, e) => { if (e.Data != null) outputSb.AppendLine(e.Data); }; - process.ErrorDataReceived += (_, e) => { if (e.Data != null) errorSb.AppendLine(e.Data); }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - var completed = await Task.Run(() => process.WaitForExit((timeoutSec + 5) * 1000), ct); - if (!completed) + try { - try { process.Kill(entireProcessTree: true); } catch { /* ignore */ } - return new RunResult(false, outputSb.ToString(), errorSb.ToString(), null, "Timeout"); + return await RunProcessAsync(startInfo, timeoutSec, ct); + } + catch (Exception ex) + { + // Резервный запуск без Docker (только для локальной разработки). + _logger.LogWarning(ex, "Docker is unavailable, falling back to local python3 execution"); + try + { + var fallback = new ProcessStartInfo + { + FileName = "python3", + Arguments = "main.py", + WorkingDirectory = workDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + return await RunProcessAsync(fallback, timeoutSec, ct); + } + catch (Exception py3Ex) + { + _logger.LogWarning(py3Ex, "python3 is unavailable, trying python"); + var fallbackPython = new ProcessStartInfo + { + FileName = "python", + Arguments = "main.py", + WorkingDirectory = workDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + return await RunProcessAsync(fallbackPython, timeoutSec, ct); + } } - - var output = outputSb.ToString().TrimEnd(); - var error = errorSb.ToString().TrimEnd(); - var success = process.ExitCode == 0; - return new RunResult(success, output, error.Length > 0 ? error : null, process.ExitCode, success ? null : "Execution failed"); } catch (Exception ex) { @@ -73,4 +92,29 @@ public async Task RunAsync(string code, TimeSpan? timeout = null, Can try { if (Directory.Exists(workDir)) Directory.Delete(workDir, recursive: true); } catch { /* ignore */ } } } + + private static async Task RunProcessAsync(ProcessStartInfo startInfo, int timeoutSec, CancellationToken ct) + { + using var process = new Process { StartInfo = startInfo }; + var outputSb = new StringBuilder(); + var errorSb = new StringBuilder(); + process.OutputDataReceived += (_, e) => { if (e.Data != null) outputSb.AppendLine(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (e.Data != null) errorSb.AppendLine(e.Data); }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + var completed = await Task.Run(() => process.WaitForExit((timeoutSec + 5) * 1000), ct); + if (!completed) + { + try { process.Kill(entireProcessTree: true); } catch { } + return new RunResult(false, outputSb.ToString(), errorSb.ToString(), null, "Timeout"); + } + + var output = outputSb.ToString().TrimEnd(); + var error = errorSb.ToString().TrimEnd(); + var success = process.ExitCode == 0; + return new RunResult(success, output, error.Length > 0 ? error : null, process.ExitCode, success ? null : "Execution failed"); + } } diff --git a/backend/CodeFlow.sln b/backend/CodeFlow.sln index a0981af..827ad6d 100644 --- a/backend/CodeFlow.sln +++ b/backend/CodeFlow.sln @@ -4,6 +4,8 @@ VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeFlow.Api", "CodeFlow.Api\CodeFlow.Api.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeFlow.Api.Tests", "CodeFlow.Api.Tests\CodeFlow.Api.Tests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -14,5 +16,9 @@ Global {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index 13a7325..0000000 --- a/frontend/README.md +++ /dev/null @@ -1 +0,0 @@ -# codeflow \ No newline at end of file diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 003b1e5..737e1fe 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,38 +1,33 @@ -import js from '@eslint/js'; -import tseslint from 'typescript-eslint'; -import reactHooks from 'eslint-plugin-react-hooks'; -import reactRefresh from 'eslint-plugin-react-refresh'; -import globals from 'globals'; +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' -export default tseslint.config( - { ignores: ['dist'] }, +export default defineConfig([ + globalIgnores(['dist']), { - extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, - plugins: { - // В 5-й версии плагина мы подключаем его вот так: - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, - }, rules: { - ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - // ВРЕМЕННО ОТКЛЮЧАЕМ СТРОГИЕ ПРАВИЛА, ЧТОБЫ ПРОЙТИ CI: - '@typescript-eslint/no-unused-vars': 'off', // Игнорировать неиспользуемые переменные - '@typescript-eslint/no-explicit-any': 'off', // Разрешить использование any - 'no-case-declarations': 'off', // Разрешить переменные внутри switch-case - 'react-hooks/exhaustive-deps': 'off', // Не ругаться на зависимости в useEffect - '@typescript-eslint/ban-ts-comment': 'off', // Разрешить @ts-ignore - 'no-empty': 'off', // Разрешить пустые блоки {} - 'prefer-const': 'off', // Не заставлять менять let на const - '@typescript-eslint/no-unused-expressions': 'off' + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-explicit-any': 'off', + 'no-case-declarations': 'off', + 'react-hooks/exhaustive-deps': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + 'no-empty': 'off', + 'prefer-const': 'off', + '@typescript-eslint/no-unused-expressions': 'off', }, }, -); \ No newline at end of file +]) diff --git a/frontend/index.html b/frontend/index.html index ce82825..fe58b4e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,1198 +1,1589 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - CodeFlow Terminal | BREACH IN PROGRESS... - - - - - - - - -
- -
+  
+ +
    _____ ____  ____  _____ _____ _     _____       __
   / ____/ __ \|  _ \| ____|  ___| |   / _ \ \     / /
  | |   | |  | | | | |  _| | |_  | |  | | | \ \ /\ / / 
@@ -1201,545 +1592,390 @@
   \_____\____/|____/|_____|_|   |_____\___/           
       
-
- - -
> INITIALIZING KERNEL... [OK]
-
> LOADING SYSTEM MODULES... [OK]
-
> ESTABLISHING SECURE CONNECTION... [OK]
-
> BYPASSING OMNICORP FIREWALL... [DETECTED]
-
> APPLYING COUNTERMEASURES... [OK]
-
> LOADING PYTHON RUNTIME (PYODIDE)... [OK]
-
> INJECTING AI ASSISTANT 'GLITCH'... [OK]
-
> INITIATING OPERATION 'SILENT STORM'... [OK]
-
> ✓ SYSTEM READY. WELCOME, OPERATIVE.
- -
-
0%
+
+ + +
> INITIALIZING KERNEL... [OK]
+
> LOADING SYSTEM MODULES... [OK]
+
> ESTABLISHING SECURE CONNECTION... [OK]
+
> BYPASSING OMNICORP FIREWALL... [DETECTED] +
+
> APPLYING COUNTERMEASURES... [OK]
+
> LOADING PYTHON RUNTIME (PYODIDE)... [OK] +
+
> INJECTING AI ASSISTANT 'GLITCH'... [OK] +
+
> INITIATING OPERATION 'SILENT STORM'... [OK]
+
> ✓ SYSTEM READY. WELCOME, + OPERATIVE.
+ +
+
0%
+
- -
-
-
-
-
-
- - -
-
-
- - +
+
+
+ + -
-
-
-
- -
- -
-
SYS: ONLINE
-
NET: ENCRYPTED
-
FPS: --
-
SEC: LEVEL 5
-
00:00:00
-
+
+
+
+
+ +
+ + - -
-
-
-
-
-
- - - - + - -
-
-
+
+
+
- -
- SECRET UNLOCKED! -
+
+ SECRET UNLOCKED! +
- -
+
- + - - + - })(); - - \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b637cc6..a13962f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,29 +13,55 @@ "@monaco-editor/react": "^4.6.0", "@tabler/icons-react": "^2.47.0", "canvas-confetti": "^1.9.2", - "framer-motion": "^11.0.3", + "framer-motion": "^11.18.2", + "gsap": "^3.14.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.0", "react-simple-typewriter": "^5.0.1" }, "devDependencies": { - "@eslint/js": "^9.39.2", + "@eslint/js": "^9.39.4", + "@testing-library/react": "^16.2.0", "@types/canvas-confetti": "^1.6.4", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", "@vitejs/plugin-react": "^4.2.1", - "eslint": "^9.39.2", - "eslint-plugin-react-hooks": "^5.0.0", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.26", - "postcss": "^8.5.6", + "globals": "^17.6.0", + "jsdom": "^26.0.0", + "postcss": "^8.5.14", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", "typescript": "^5.3.3", - "typescript-eslint": "^8.54.0", - "vite": "^5.1.0" + "typescript-eslint": "^8.59.2", + "vite": "^5.1.0", + "vitest": "^3.0.5" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -52,9 +78,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", "dev": true, "license": "MIT", "engines": { @@ -67,6 +93,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -229,23 +256,23 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "dev": true, "license": "MIT", "dependencies": { @@ -291,9 +318,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -347,6 +374,123 @@ "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -768,24 +912,24 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -794,9 +938,9 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -833,20 +977,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -857,9 +1001,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -867,10 +1011,23 @@ "concat-map": "0.0.1" } }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -881,9 +1038,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -918,22 +1075,22 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", - "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", - "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.4", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/react": { @@ -952,12 +1109,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", - "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.5" + "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", @@ -965,35 +1122,49 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1096,6 +1267,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.17.8.tgz", "integrity": "sha512-96qygbkTjRhdkzd5HDU8fMziemN/h758/EwrFu7TlWrEP10Vw076u+Ap/sG6OT4RGPZYYoHrTlT+mkCZblWHuw==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -1140,9 +1312,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", "cpu": [ "arm" ], @@ -1154,9 +1326,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", "cpu": [ "arm64" ], @@ -1168,9 +1340,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", "cpu": [ "arm64" ], @@ -1182,9 +1354,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", "cpu": [ "x64" ], @@ -1196,9 +1368,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", "cpu": [ "arm64" ], @@ -1210,9 +1382,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", "cpu": [ "x64" ], @@ -1224,9 +1396,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", "cpu": [ "arm" ], @@ -1238,9 +1410,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", "cpu": [ "arm" ], @@ -1252,9 +1424,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", "cpu": [ "arm64" ], @@ -1266,9 +1438,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", "cpu": [ "arm64" ], @@ -1280,9 +1452,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", "cpu": [ "loong64" ], @@ -1294,9 +1466,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", "cpu": [ "loong64" ], @@ -1308,9 +1480,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", "cpu": [ "ppc64" ], @@ -1322,9 +1494,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", "cpu": [ "ppc64" ], @@ -1336,9 +1508,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", "cpu": [ "riscv64" ], @@ -1350,9 +1522,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", "cpu": [ "riscv64" ], @@ -1364,9 +1536,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", "cpu": [ "s390x" ], @@ -1378,9 +1550,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", "cpu": [ "x64" ], @@ -1392,9 +1564,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", "cpu": [ "x64" ], @@ -1406,9 +1578,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", "cpu": [ "x64" ], @@ -1420,9 +1592,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", "cpu": [ "arm64" ], @@ -1434,9 +1606,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", "cpu": [ "arm64" ], @@ -1448,9 +1620,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", "cpu": [ "ia32" ], @@ -1462,9 +1634,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", "cpu": [ "x64" ], @@ -1476,9 +1648,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", "cpu": [ "x64" ], @@ -1516,6 +1688,62 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1568,6 +1796,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1586,15 +1832,16 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "dev": true, + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1606,19 +1853,27 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@typescript-eslint/project-service": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", - "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.54.0", - "@typescript-eslint/types": "^8.54.0", + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "engines": { @@ -1629,13 +1884,13 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", - "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", "dev": true, "license": "MIT", "engines": { @@ -1647,9 +1902,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", - "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", "dev": true, "license": "MIT", "engines": { @@ -1660,7 +1915,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@vitejs/plugin-react": { @@ -1684,12 +1939,128 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1707,10 +2078,20 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1724,6 +2105,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1747,6 +2138,26 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1755,29 +2166,22 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -1794,12 +2198,13 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -1808,14 +2213,24 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" } }, "node_modules/camelcase-css": { @@ -1829,9 +2244,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001767", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", - "integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==", + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", "dev": true, "funding": [ { @@ -1859,6 +2274,23 @@ "url": "https://www.paypal.me/kirilvatev" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1876,6 +2308,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1947,13 +2389,41 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1972,6 +2442,23 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1979,19 +2466,65 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.353", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -2055,25 +2588,26 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", + "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", @@ -2092,7 +2626,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -2115,9 +2649,9 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0.tgz", - "integrity": "sha512-hIOwI+5hYGpJEc4uPRmz2ulCjAGD/N13Lukkh8cLV0i2IRk/bdZDYjgLVHj+U9Z704kLIdIO6iueGvxNur0sgw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", "dev": true, "license": "MIT", "engines": { @@ -2168,9 +2702,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -2192,9 +2726,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2271,6 +2805,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2281,6 +2825,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2365,9 +2919,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -2446,9 +3000,9 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, "license": "MIT", "engines": { @@ -2458,6 +3012,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gsap": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", + "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", + "license": "Standard 'no charge' license: https://gsap.com/standard-license." + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2468,6 +3028,60 @@ "node": ">=8" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2528,6 +3142,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2554,6 +3175,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2660,6 +3322,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2670,20 +3339,47 @@ "yallist": "^3.0.2" } }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, - "license": "ISC", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.1" + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 18" + } + }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" } }, "node_modules/motion-dom": { @@ -2709,9 +3405,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -2735,9 +3431,16 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", "dev": true, "license": "MIT" }, @@ -2813,6 +3516,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2833,6 +3549,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2841,11 +3574,12 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2854,9 +3588,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -2873,6 +3607,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3018,6 +3753,41 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -3044,6 +3814,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3056,6 +3827,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3071,9 +3843,9 @@ "license": "MIT" }, "node_modules/react-number-format": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz", - "integrity": "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==", + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz", + "integrity": "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==", "license": "MIT", "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", @@ -3232,9 +4004,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", "dev": true, "license": "MIT", "dependencies": { @@ -3248,34 +4020,61 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -3286,9 +4085,9 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", "dev": true, "license": "ISC", "bin": { @@ -3321,6 +4120,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3331,12 +4137,26 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", "license": "MIT" }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3350,6 +4170,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/sugarss": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/sugarss/-/sugarss-5.0.1.tgz", @@ -3386,21 +4226,42 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -3409,6 +4270,82 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3446,6 +4383,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3455,16 +4393,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", - "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.54.0", - "@typescript-eslint/parser": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0" + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3474,25 +4412,25 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", - "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/type-utils": "8.54.0", - "@typescript-eslint/utils": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3502,22 +4440,23 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.54.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", - "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "engines": { @@ -3528,19 +4467,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", - "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0" + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3551,17 +4490,17 @@ } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", - "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0", - "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3571,14 +4510,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", - "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", "dev": true, "license": "MIT", "engines": { @@ -3590,21 +4529,21 @@ } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", - "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.54.0", - "@typescript-eslint/tsconfig-utils": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/visitor-keys": "8.54.0", + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3614,20 +4553,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", - "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.54.0", - "@typescript-eslint/types": "8.54.0", - "@typescript-eslint/typescript-estree": "8.54.0" + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3637,19 +4576,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", - "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.54.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3659,14 +4598,37 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/typescript-eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/typescript-eslint/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/typescript-eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -3682,10 +4644,26 @@ "node": ">= 4" } }, + "node_modules/typescript-eslint/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/typescript-eslint/node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -3837,6 +4815,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -3891,6 +4870,163 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3907,6 +5043,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3917,6 +5070,45 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index a2f3da5..7dd7309 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "lint": "eslint . --report-unused-disable-directives --max-warnings 0" + "lint": "eslint . --report-unused-disable-directives --max-warnings 0", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@mantine/core": "^7.5.0", @@ -15,26 +17,31 @@ "@monaco-editor/react": "^4.6.0", "@tabler/icons-react": "^2.47.0", "canvas-confetti": "^1.9.2", - "framer-motion": "^11.0.3", + "framer-motion": "^11.18.2", + "gsap": "^3.14.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.0", "react-simple-typewriter": "^5.0.1" }, "devDependencies": { - "@eslint/js": "^9.39.2", + "@eslint/js": "^9.39.4", "@types/canvas-confetti": "^1.6.4", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", "@vitejs/plugin-react": "^4.2.1", - "eslint": "^9.39.2", - "eslint-plugin-react-hooks": "^5.0.0", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.26", - "postcss": "^8.5.6", + "globals": "^17.6.0", + "postcss": "^8.5.14", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", "typescript": "^5.3.3", - "typescript-eslint": "^8.54.0", - "vite": "^5.1.0" + "typescript-eslint": "^8.59.2", + "vite": "^5.1.0", + "vitest": "^3.0.5", + "@testing-library/react": "^16.2.0", + "jsdom": "^26.0.0" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 37c554c..a6b6d41 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,16 +4,23 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { MantineProvider, createTheme } from '@mantine/core'; import { useEffect, useState } from 'react'; + + import HomePage from './pages/HomePage'; import CoursesPage from './pages/CoursesPage'; import LessonPage from './pages/LessonPage'; import ProfilePage from './pages/ProfilePage'; import LeaderboardPage from './pages/LeaderboardPage'; +import AuthPage from './pages/AuthPage'; +import { authApi } from './api/auth'; import ShopPage from './pages/ShopPage'; import { PageTransition } from './components/PageTransition'; import { CyberLoader } from './components/CyberLoader'; +import { OpeningSequence } from './components/OpeningSequence'; +import { CustomCursor } from './components/CustomCursor'; import { terminalThemes } from './data/shopItems'; + const getPrimaryColor = (id: string) => { switch (id) { case 'blood': return 'red'; @@ -29,19 +36,32 @@ const createAppTheme = (primaryColor: string) => createTheme({ primaryColor, defaultRadius: 'sm', colors: { - green: ['#EBFBEE','#D3F9D8','#B2F2BB','#8CE99A','#69DB7C','#51CF66','#40C057','#37B24D','#2F9E44','#2B8A3E'], - red: ['#FFF5F5','#FFE3E3','#FFC9C9','#FFA8A8','#FF8787','#FF6B6B','#FA5252','#F03E3E','#E03131','#C92A2A'], - blue: ['#E7F5FF','#D0EBFF','#A5D8FF','#74C0FC','#4DABF7','#339AF0','#228BE6','#1C7ED6','#1971C2','#1864AB'], - yellow: ['#FFF9DB','#FFF3BF','#FFEC99','#FFE066','#FFD43B','#FCC419','#FAB005','#F59F00','#F08C00','#E67700'], + green: ['#EBFBEE', '#D3F9D8', '#B2F2BB', '#8CE99A', '#69DB7C', '#51CF66', '#40C057', '#37B24D', '#2F9E44', '#2B8A3E'], + red: ['#FFF5F5', '#FFE3E3', '#FFC9C9', '#FFA8A8', '#FF8787', '#FF6B6B', '#FA5252', '#F03E3E', '#E03131', '#C92A2A'], + blue: ['#E7F5FF', '#D0EBFF', '#A5D8FF', '#74C0FC', '#4DABF7', '#339AF0', '#228BE6', '#1C7ED6', '#1971C2', '#1864AB'], + yellow: ['#FFF9DB', '#FFF3BF', '#FFEC99', '#FFE066', '#FFD43B', '#FCC419', '#FAB005', '#F59F00', '#F08C00', '#E67700'], } }); function App() { + const [, setAuthTick] = useState(0); const [isLoading, setIsLoading] = useState(true); const [loadProgress, setLoadProgress] = useState(0); const [activeThemeId, setActiveThemeId] = useState(localStorage.getItem('activeTheme') || 'classic'); const currentThemeData = terminalThemes.find(t => t.id === activeThemeId) || terminalThemes[0]; const [theme, setTheme] = useState(createAppTheme(getPrimaryColor(activeThemeId))); + const [hasSeenIntro, setHasSeenIntro] = useState(localStorage.getItem('hasSeenIntro') === 'true'); + + const handleIntroComplete = () => { + localStorage.setItem('hasSeenIntro', 'true'); + setHasSeenIntro(true); + }; + + useEffect(() => { + const onAuthChange = () => setAuthTick(t => t + 1); + window.addEventListener('auth-changed', onAuthChange); + return () => window.removeEventListener('auth-changed', onAuthChange); + }, []); // Симуляция загрузки useEffect(() => { @@ -84,11 +104,31 @@ function App() { document.body.style.background = currentThemeData.bg; }, [currentThemeData]); + if (!hasSeenIntro) { + return ( + + + + ); + } + + if (!authApi.isLoggedIn()) { + return ( + + + + } /> + + + + ); + } + if (isLoading) { return ( - + @@ -108,6 +149,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/src/api/achievements.ts b/frontend/src/api/achievements.ts new file mode 100644 index 0000000..992a483 --- /dev/null +++ b/frontend/src/api/achievements.ts @@ -0,0 +1,24 @@ +import api from './client'; + +export interface AchievementDefinition { + id: string; + title: string; + description: string; + icon: string; + rarity: string; +} + +export interface UserAchievement { + achievementId: string; + unlockedAtUtc: string; +} + +export const achievementsApi = { + getAll: async (): Promise => { + return await api.get('/api/achievements'); + }, + + getMyAchievements: async (): Promise => { + return await api.get('/api/achievements/me'); + }, +}; diff --git a/frontend/src/api/auth.test.ts b/frontend/src/api/auth.test.ts new file mode 100644 index 0000000..242fe52 --- /dev/null +++ b/frontend/src/api/auth.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { authApi } from './auth'; + +describe('authApi', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('isLoggedIn возвращает false без токена', () => { + expect(authApi.isLoggedIn()).toBe(false); + }); + + it('isLoggedIn возвращает false для строки undefined', () => { + localStorage.setItem('token', 'undefined'); + expect(authApi.isLoggedIn()).toBe(false); + }); + + it('isLoggedIn возвращает true при валидном токене', () => { + localStorage.setItem('token', 'valid-jwt-token'); + expect(authApi.isLoggedIn()).toBe(true); + }); + + it('logout очищает данные сессии', () => { + localStorage.setItem('token', 'valid-jwt-token'); + localStorage.setItem('user', '{"id":"1"}'); + authApi.logout(); + expect(localStorage.getItem('token')).toBeNull(); + expect(localStorage.getItem('user')).toBeNull(); + }); +}); diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..319fe2d --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,39 @@ +import api from './client'; + +export function notifyAuthChange() { + window.dispatchEvent(new Event('auth-changed')); +} + +export const authApi = { + register: async (email: string, password: string, displayName: string) => { + const data = await api.post('/api/auth/register', { email, password, displayName }); + localStorage.setItem('token', data.accessToken); + localStorage.setItem('user', JSON.stringify(data.user)); + notifyAuthChange(); + return data; + }, + + login: async (email: string, password: string) => { + const data = await api.post('/api/auth/login', { email, password }); + localStorage.setItem('token', data.accessToken); + localStorage.setItem('user', JSON.stringify(data.user)); + notifyAuthChange(); + return data; + }, + + logout: () => { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + notifyAuthChange(); + }, + + getUser: () => { + const user = localStorage.getItem('user'); + return user ? JSON.parse(user) : null; + }, + + isLoggedIn: () => { + const token = localStorage.getItem('token'); + return !!token && token !== 'undefined'; + }, +}; \ No newline at end of file diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts new file mode 100644 index 0000000..829d8c1 --- /dev/null +++ b/frontend/src/api/client.test.ts @@ -0,0 +1,23 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +describe('API client (401)', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('при 401 без токена не перенаправляет на /auth', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(JSON.stringify({ message: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + + const api = (await import('./client')).default; + + await expect(api.get('/api/progress')).rejects.toThrow('Unauthorized'); + expect(localStorage.getItem('token')).toBeNull(); + + globalThis.fetch = originalFetch; + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..29905ad --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,92 @@ +const BASE = ''; + +async function handleResponse(res: Response) { + if (res.status === 401) { + const hadToken = !!localStorage.getItem('token'); + if (hadToken) { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.href = '/auth'; + } + let msg = 'Unauthorized'; + try { + const body = await res.json(); + msg = body.message || msg; + } catch { /* ignore */ } + throw new Error(msg); + } + if (res.status === 204) return null; + if (!res.ok) { + let msg = `HTTP ${res.status}`; + try { + const body = await res.json(); + msg = body.message || msg; + } catch { /* ignore */ } + throw new Error(msg); + } + const text = await res.text(); + if (!text) return null; + return JSON.parse(text); +} + +function getHeaders(): Record { + const headers: Record = { + 'Content-Type': 'application/json', + }; + const token = localStorage.getItem('token'); + if (token && token !== 'undefined') { + headers['Authorization'] = `Bearer ${token}`; + } + return headers; +} + +const api = { + get: async (endpoint: string) => { + const res = await fetch(`${BASE}${endpoint}`, { + headers: getHeaders(), + }); + return handleResponse(res); + }, + + post: async (endpoint: string, body?: unknown) => { + const res = await fetch(`${BASE}${endpoint}`, { + method: 'POST', + headers: getHeaders(), + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + return handleResponse(res); + }, + + patch: async (endpoint: string, body?: unknown) => { + const res = await fetch(`${BASE}${endpoint}`, { + method: 'PATCH', + headers: getHeaders(), + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + return handleResponse(res); + }, + + delete: async (endpoint: string) => { + const res = await fetch(`${BASE}${endpoint}`, { + method: 'DELETE', + headers: getHeaders(), + }); + return handleResponse(res); + }, + + download: async (endpoint: string, filename: string) => { + const res = await fetch(`${BASE}${endpoint}`, { + headers: getHeaders(), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + }, +}; + +export default api; \ No newline at end of file diff --git a/frontend/src/api/courses.ts b/frontend/src/api/courses.ts new file mode 100644 index 0000000..2d3df51 --- /dev/null +++ b/frontend/src/api/courses.ts @@ -0,0 +1,41 @@ +import api from './client'; + +export interface Course { + id: number; + title: string; + description: string; + level: string; + color: string; + totalLessons: number; +} + +export interface Lesson { + id: number; + courseId: number; + chapter: string; + title: string; + description: string; + task: string; + initialCode: string; + xp: number; + isBoss: boolean; + hasDebugger: boolean; +} + +export const coursesApi = { + getAllCourses: async (): Promise => { + return await api.get('/api/courses'); + }, + + getCourseById: async (id: number): Promise => { + return await api.get(`/api/courses/${id}`); + }, + + getLessonsForCourse: async (courseId: number): Promise => { + return await api.get(`/api/courses/${courseId}/lessons`); + }, + + getLessonById: async (id: number | string): Promise => { + return await api.get(`/api/lessons/${id}`); + }, +}; diff --git a/frontend/src/api/factions.ts b/frontend/src/api/factions.ts new file mode 100644 index 0000000..ff7713c --- /dev/null +++ b/frontend/src/api/factions.ts @@ -0,0 +1,26 @@ +import api from './client'; + +export interface Faction { + id: string; + name: string; + description: string; + icon: string; + color: string; + bonus: string; + requiredRep: number; +} + +export interface UserReputation { + factionId: string; + reputation: number; +} + +export const factionsApi = { + getAll: async (): Promise => { + return await api.get('/api/factions'); + }, + + getMyReputation: async (): Promise => { + return await api.get('/api/factions/me'); + }, +}; diff --git a/frontend/src/api/leaderboard.ts b/frontend/src/api/leaderboard.ts new file mode 100644 index 0000000..d5e896c --- /dev/null +++ b/frontend/src/api/leaderboard.ts @@ -0,0 +1,14 @@ +import api from './client'; + +export interface LeaderboardEntry { + rank: number; + userId: string; + displayName: string; + totalXp: number; +} + +export const leaderboardApi = { + getLeaderboard: async (limit: number = 50): Promise => { + return await api.get(`/api/leaderboard?limit=${limit}`); + }, +}; diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts new file mode 100644 index 0000000..2a63964 --- /dev/null +++ b/frontend/src/api/notifications.ts @@ -0,0 +1,28 @@ +import api from './client'; + +export interface Notification { + id: string; + type: string; + title: string; + body: string | null; + createdAtUtc: string; + isRead: boolean; +} + +export const notificationsApi = { + getNotifications: async (unreadOnly: boolean = false, limit: number = 50): Promise => { + const params = new URLSearchParams(); + if (unreadOnly) params.set('unreadOnly', 'true'); + if (limit !== 50) params.set('limit', String(limit)); + const query = params.toString(); + return await api.get(`/api/notifications${query ? '?' + query : ''}`); + }, + + markRead: async (id: string): Promise => { + await api.patch(`/api/notifications/${id}/read`); + }, + + markAllRead: async (): Promise => { + await api.post('/api/notifications/read-all'); + }, +}; diff --git a/frontend/src/api/progress.ts b/frontend/src/api/progress.ts new file mode 100644 index 0000000..0efefd6 --- /dev/null +++ b/frontend/src/api/progress.ts @@ -0,0 +1,44 @@ +import api from './client'; + +export interface UserProgressSummary { + totalXp: number; + completedLessonsCount: number; + completedLessonIds: number[]; + cleanStreak: number; + fastBossKill: boolean; +} + +export interface ProgressResult { + lessonId: number; + completedAtUtc: string; + xpEarned: number; + wasCleanRun: boolean; +} + +export interface XpBalance { + totalXp: number; +} + +export interface PurchaseHintResponse { + totalXp: number; + hintLevel: number; + hintText: string; +} + +export const progressApi = { + getMyProgress: async (): Promise => { + return await api.get('/api/progress'); + }, + + purchaseHint: async (lessonId: number, hintLevel: 1 | 2): Promise => { + return await api.post('/api/progress/purchase-hint', { lessonId, hintLevel }); + }, + + moralChoice: async (factionId: string, lessonId: number): Promise => { + return await api.post('/api/progress/moral-choice', { factionId, lessonId }); + }, + + resetProgress: async (): Promise => { + await api.post('/api/progress/reset'); + }, +}; diff --git a/frontend/src/api/shop.ts b/frontend/src/api/shop.ts new file mode 100644 index 0000000..d232f5b --- /dev/null +++ b/frontend/src/api/shop.ts @@ -0,0 +1,23 @@ +import api from './client'; + +export interface ShopItem { + id: string; + name: string; + color: string; + bg: string; + price: number; +} + +export const shopApi = { + getItems: async (): Promise => { + return await api.get('/api/shop/items'); + }, + + purchase: async (shopItemId: string): Promise => { + return await api.post('/api/shop/purchase', { shopItemId }); + }, + + getMyItems: async (): Promise => { + return await api.get('/api/shop/me'); + }, +}; diff --git a/frontend/src/api/submissions.ts b/frontend/src/api/submissions.ts new file mode 100644 index 0000000..c4d7844 --- /dev/null +++ b/frontend/src/api/submissions.ts @@ -0,0 +1,32 @@ +import api from './client'; + +export interface SubmitResult { + passed: boolean; + output: string; + expected: string | null; + error: string | null; + failureReason: string | null; + xpEarned: number | null; + lessonCompleted: boolean; + totalXp: number | null; +} + +export interface SubmissionStatus { + id: string; + status: string; + output: string | null; + error: string | null; + passed: boolean | null; + createdAtUtc: string; + completedAtUtc: string | null; +} + +export const submissionsApi = { + submitCode: async (lessonId: number, code: string, wasCleanRun = true): Promise => { + return await api.post(`/api/lessons/${lessonId}/submit`, { code, wasCleanRun }); + }, + + getStatus: async (jobId: string): Promise => { + return await api.get(`/api/submissions/${jobId}`); + }, +}; diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..819183a --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,21 @@ +import api from './client'; + +export interface UserProfile { + id: string; + email: string; + displayName: string; + totalXp: number; + createdAtUtc: string; + emailConfirmed: boolean; + role: string; +} + +export const usersApi = { + getMe: async (): Promise => { + return await api.get('/api/users/me'); + }, + + updateMe: async (displayName: string): Promise => { + return await api.patch('/api/users/me', { displayName }); + }, +}; diff --git a/frontend/src/components/CustomCursor.tsx b/frontend/src/components/CustomCursor.tsx new file mode 100644 index 0000000..258f72a --- /dev/null +++ b/frontend/src/components/CustomCursor.tsx @@ -0,0 +1,68 @@ +import { useEffect, useRef } from 'react'; + +/** + * Кастомный курсор-прицел, заменяющий системный. + * Отслеживает движение мыши и добавляет hover-эффект на кликабельных элементах. + */ +export const CustomCursor = () => { + const cursorRef = useRef(null); + + useEffect(() => { + const cursor = cursorRef.current; + if (!cursor) return; + + // Перемещаем курсор за мышью + const onMouseMove = (e: MouseEvent) => { + cursor.style.left = `${e.clientX}px`; + cursor.style.top = `${e.clientY}px`; + cursor.style.opacity = '1'; + }; + + // Hover-эффект на кликабельных элементах + const onMouseOver = (e: MouseEvent) => { + const target = e.target as HTMLElement; + const isClickable = + target.tagName === 'BUTTON' || + target.tagName === 'A' || + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.closest('button') || + target.closest('a') || + target.closest('[role="button"]') || + target.closest('[data-clickable]') || + window.getComputedStyle(target).cursor === 'pointer'; + + if (isClickable) { + cursor.classList.add('cursor-hover'); + } else { + cursor.classList.remove('cursor-hover'); + } + }; + + // Click-эффект + const onMouseDown = () => cursor.classList.add('cursor-click'); + const onMouseUp = () => cursor.classList.remove('cursor-click'); + + // Скрываем когда курсор уходит за пределы окна + const onMouseLeave = () => { cursor.style.opacity = '0'; }; + const onMouseEnter = () => { cursor.style.opacity = '1'; }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseover', onMouseOver); + document.addEventListener('mousedown', onMouseDown); + document.addEventListener('mouseup', onMouseUp); + document.documentElement.addEventListener('mouseleave', onMouseLeave); + document.documentElement.addEventListener('mouseenter', onMouseEnter); + + return () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseover', onMouseOver); + document.removeEventListener('mousedown', onMouseDown); + document.removeEventListener('mouseup', onMouseUp); + document.documentElement.removeEventListener('mouseleave', onMouseLeave); + document.documentElement.removeEventListener('mouseenter', onMouseEnter); + }; + }, []); + + return
; +}; diff --git a/frontend/src/components/Debugger.tsx b/frontend/src/components/Debugger.tsx new file mode 100644 index 0000000..ea5376f --- /dev/null +++ b/frontend/src/components/Debugger.tsx @@ -0,0 +1,164 @@ +import React, { useState, useEffect } from 'react'; +import { Paper, Title, Group, Button, Text, Slider, Table, Stack, Code, ScrollArea } from '@mantine/core'; +import { IconBug, IconPlayerPlay, IconPlayerTrackNext, IconPlayerTrackPrev, IconRefresh } from '@tabler/icons-react'; + +interface TraceStep { + line: number; + locals: Record; + stdout: string; +} + +interface DebuggerProps { + trace: TraceStep[]; + code: string; +} + +export const Debugger: React.FC = ({ trace, code }) => { + const [currentStep, setCurrentStep] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + + const codeLines = code.split('\n'); + + useEffect(() => { + let interval: any; + if (isPlaying) { + interval = setInterval(() => { + setCurrentStep(prev => { + if (prev >= trace.length - 1) { + setIsPlaying(false); + return prev; + } + return prev + 1; + }); + }, 800); + } + return () => clearInterval(interval); + }, [isPlaying, trace.length]); + + const stepData = trace[currentStep] || { line: 0, locals: {}, stdout: '' }; + + return ( + + + + + + DEBUG_MODE + + {/* Controls */} + + + + + + + + + + `Step ${val + 1}/${trace.length}`} + color="green" + size="sm" + mb="xs" + /> + +
+ {/* Code View */} + +
+ {codeLines.map((line, idx) => { + const lineNum = idx + 1; + const isCurrent = stepData.line === lineNum; + return ( +
+ {lineNum} + {line} +
+ ); + })} +
+
+ + {/* Variables & Output */} + + {/* Variables */} + + LOCALS + {Object.keys(stepData.locals).length === 0 ? ( + Empty + ) : ( + + + {Object.entries(stepData.locals).map(([key, val]) => ( + + {key} + {val} + + ))} + +
+ )} +
+ + {/* Output Snapshot */} + + OUTPUT + + + {stepData.stdout || ''} + + + +
+
+
+
+ ); +}; diff --git a/frontend/src/components/HackerConsole.tsx b/frontend/src/components/HackerConsole.tsx index 0119732..3ddd34f 100644 --- a/frontend/src/components/HackerConsole.tsx +++ b/frontend/src/components/HackerConsole.tsx @@ -186,9 +186,7 @@ ${nextRank ? `До ${nextRank.name}: ${nextRank.min - currentXP} XP` : 'Макс sounds.success(); response = `[■■■■■■■■■■] 100% ВЗЛОМ УСПЕШЕН! ...шутка. Это всего лишь терминал. -Но +10 XP за находчивость!`; - const hackXP = Number(localStorage.getItem('userXP') || '0') + 10; - localStorage.setItem('userXP', String(hackXP)); +XP начисляется только за прохождение миссий на сервере.`; type = 'success'; break; @@ -240,7 +238,7 @@ ${nextRank ? `До ${nextRank.name}: ${nextRank.min - currentXP} XP` : 'Макс return ( - + {history.map((item, i) => ( {item.input && ( @@ -261,16 +259,21 @@ ${nextRank ? `До ${nextRank.name}: ${nextRank.min - currentXP} XP` : 'Макс value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleCommand} + leftSectionWidth={20} styles={{ input: { color: '#00ff41', - padding: 0, + paddingLeft: '22px', minHeight: 'auto', fontFamily: 'monospace', fontSize: '12px' - } + }, + section: { + width: '20px', + marginLeft: '2px', + } }} - leftSection={$} + leftSection={$} /> ); diff --git a/frontend/src/components/MoralChoice.tsx b/frontend/src/components/MoralChoice.tsx index ff234ee..1dab085 100644 --- a/frontend/src/components/MoralChoice.tsx +++ b/frontend/src/components/MoralChoice.tsx @@ -1,61 +1,40 @@ import { Modal, Button, Title, Text, Stack, Box } from '@mantine/core'; -import { addReputation } from '../data/reputationSystem'; +import { recordMoralChoice, chapterChoices, getChoiceIntro, getPreviousConsequence, ChapterChoice } from '../data/storyOutcomes'; +import { progressApi } from '../api/progress'; import { sounds } from '../utils/audio'; -import { motion } from 'framer-motion'; +import { motion, AnimatePresence } from 'framer-motion'; interface Props { opened: boolean; onClose: () => void; chapter: string; + lessonId: number; } -export const MoralChoice = ({ opened, onClose, chapter }: Props) => { - const handleChoice = (factionId: string, xpBonus: number) => { - addReputation(factionId, 50); - - const currentXP = Number(localStorage.getItem('userXP') || '0'); - localStorage.setItem('userXP', String(currentXP + xpBonus)); - - sounds.success(); - onClose(); +export const MoralChoice = ({ opened, onClose, chapter, lessonId }: Props) => { + const handleChoice = async (choice: ChapterChoice) => { + try { + const result = await progressApi.moralChoice(choice.faction, lessonId); + localStorage.setItem('userXP', String(result.totalXp)); + recordMoralChoice(lessonId, chapter, choice.faction); + sounds.success(); + onClose(); + } catch { + sounds.error(); + alert('Не удалось сохранить выбор. Попробуйте снова.'); + } }; - const choices = [ - { - faction: 'data_brokers', - xp: 500, - color: 'blue', - icon: '💾', - title: 'ПРОДАТЬ НА ЧЁРНОМ РЫНКЕ', - desc: '+500 XP | +50 репутации у Торговцев Данными', - gradient: 'linear-gradient(135deg, rgba(0,100,255,0.1) 0%, rgba(0,50,150,0.1) 100%)', - }, - { - faction: 'ai_ethicists', - xp: 300, - color: 'cyan', - icon: '📢', - title: 'ОПУБЛИКОВАТЬ АНОНИМНО', - desc: '+300 XP | +50 репутации у AI-Этиков', - gradient: 'linear-gradient(135deg, rgba(0,255,255,0.1) 0%, rgba(0,150,150,0.1) 100%)', - }, - { - faction: 'ghost_protocol', - xp: 100, - color: 'gray', - icon: '🗑️', - title: 'УНИЧТОЖИТЬ ДАННЫЕ', - desc: '+100 XP | +50 репутации у Протокола Призрак', - gradient: 'linear-gradient(135deg, rgba(100,100,100,0.1) 0%, rgba(50,50,50,0.1) 100%)', - }, - ]; + const choices = chapterChoices[chapter] || chapterChoices["Глава 1: Проникновение"]; + const intro = getChoiceIntro(chapter); + const previousConsequence = getPreviousConsequence(lessonId); return ( - { } }} > - {/* Сканлайн эффект */} { animate={{ scale: 1, opacity: 1 }} transition={{ type: 'spring', duration: 0.5 }} > - - ⚠️ КРИТИЧЕСКИЙ ВЫБОР + <Title order={3} c="red" mb="md" ta="center" className="glitch" data-text={intro.title}> + {intro.title} - + {chapter} - + + + {previousConsequence && ( + + + + 📜 ПОСЛЕДСТВИЕ ПРЕДЫДУЩЕГО ВЫБОРА: + + + {previousConsequence} + + + + )} + + - Вы получили доступ к секретным архивам OmniCorp. -
- Что вы сделаете с этими данными? + {intro.description.split('\n').map((line, i) => ( + + {line} + {i < intro.description.split('\n').length - 1 &&
} +
+ ))}
- + {choices.map((choice, index) => ( { animate={{ x: 0, opacity: 1 }} transition={{ delay: index * 0.1 + 0.2 }} > - + + + {/* CONTENT AREA */} + + {/* BOOT LOGS */} + + {bootLines.map((line, i) => ( + + {line} + + ))} + + + {/* PHASE: STORY */} + {(phase === 'story' || phase === 'interactive') && ( + + + {storyText} + + + )} + + {/* PHASE: INTERACTIVE INPUT */} + {phase === 'interactive' && ( +
+ + {`user@codeflow:~$`} + { + setInputCommand(e.target.value); + playBeep(800 + Math.random() * 200, 'square', 0.05); // Typing sound + }} + style={{ + background: 'transparent', + border: 'none', + color: '#fff', + fontFamily: 'JetBrains Mono, monospace', + fontSize: '1rem', + outline: 'none', + flex: 1, + caretColor: '#00ff41' + }} + placeholder="Type 'init protocol'..." + /> + + {isError && ( + + ERROR: COMMAND NOT RECOGNIZED. HINT: TRY 'init protocol' + + )} +
+ )} +
+
+ + {/* SOUND TOGGLE (Bottom Right) */} + + + {/* GLITCH STYLES */} + +
+ ); +}; diff --git a/frontend/src/components/StoryOutcome.tsx b/frontend/src/components/StoryOutcome.tsx new file mode 100644 index 0000000..2cba582 --- /dev/null +++ b/frontend/src/components/StoryOutcome.tsx @@ -0,0 +1,209 @@ +import { useState, useEffect } from 'react'; +import { Modal, Text, Stack, Box, Button, Title, Badge } from '@mantine/core'; +import { motion, AnimatePresence } from 'framer-motion'; +import { getStoryEnding, StoryEnding } from '../data/storyOutcomes'; +import { sounds } from '../utils/audio'; +import confetti from 'canvas-confetti'; + +interface Props { + opened: boolean; + onClose: () => void; +} + +export const StoryOutcome = ({ opened, onClose }: Props) => { + const [ending, setEnding] = useState(null); + const [currentLine, setCurrentLine] = useState(0); + const [showEpilogue, setShowEpilogue] = useState(false); + + useEffect(() => { + if (opened) { + const e = getStoryEnding(); + setEnding(e); + setCurrentLine(0); + setShowEpilogue(false); + sounds.success(); + + // Финальное конфетти + setTimeout(() => { + confetti({ + particleCount: 300, + spread: 160, + origin: { y: 0.5 }, + colors: ['#FFD700', '#FF4136', '#00FF41', '#00FFF9', '#BF40BF'], + shapes: ['star', 'circle'], + }); + }, 500); + } + }, [opened]); + + // Автоматическое раскрытие текста + useEffect(() => { + if (!ending || !opened) return; + + if (currentLine < ending.narrative.length) { + const timer = setTimeout(() => { + setCurrentLine(prev => prev + 1); + }, 3000); + return () => clearTimeout(timer); + } else { + const timer = setTimeout(() => setShowEpilogue(true), 2000); + return () => clearTimeout(timer); + } + }, [currentLine, ending, opened]); + + if (!ending) return null; + + return ( + + {/* Сканлайн эффект */} + + + + {/* Заголовок */} + + + {ending.icon} + + {ending.title} + + + ОПЕРАЦИЯ ЗАВЕРШЕНА + + + + + {/* Нарратив */} + + + {ending.narrative.slice(0, currentLine).map((line, i) => ( + + + + {line} + + + + ))} + + + + {/* Эпилог */} + + {showEpilogue && ( + + + + {ending.epilogue} + + + 🏆 ДОСТИЖЕНИЕ: {ending.achievement} + + + + + + )} + + + + + + ); +}; diff --git a/frontend/src/data/bossSystem.ts b/frontend/src/data/bossSystem.ts new file mode 100644 index 0000000..a0033bc --- /dev/null +++ b/frontend/src/data/bossSystem.ts @@ -0,0 +1,154 @@ +// Система управления боссовыми миссиями: таймер, жизни, кулдаун + +// Лимиты времени для каждого босса (в секундах) +const BOSS_TIME_LIMITS: Record = { + 4: 80, // Глава 1: Обход биометрии (простые переменные) + 7: 90, // Глава 2: ИИ 'Цербер' (if/elif/else) + 10: 100, // Глава 3: Подбор пароля (циклы + f-строки) + 13: 110, // Глава 4: Извлечение данных (списки + циклы) + 15: 120, // Глава 5: Отключение Левиафана (функции) +}; + +// Максимальное количество попыток +const MAX_ATTEMPTS = 5; + +// Кулдауны между попытками (в секундах) +// Попытка 1 → 2: 0 сек (мгновенно) +// Попытка 2 → 3: 30 сек +// Попытка 3 → 4: 60 сек +// Попытка 4 → 5: 120 сек +// После 5 попыток: 8 часов (28800 сек) +const COOLDOWNS: Record = { + 1: 0, // Первая попытка — сразу + 2: 0, // Вторая попытка — сразу (вторая жизнь) + 3: 30, // Третья — подождать 30 сек + 4: 60, // Четвёртая — подождать 1 мин + 5: 120, // Пятая — подождать 2 мин +}; + +const FINAL_COOLDOWN = 28800; // 8 часов после 5 неудач + +export interface BossAttemptData { + attempt: number; // Текущая попытка (1-5) + failedAt: number; // Timestamp последнего провала + completed: boolean; // Пройден ли босс +} + +export const getBossTimeLimit = (lessonId: number): number => { + return BOSS_TIME_LIMITS[lessonId] || 80; +}; + +export const getBossAttemptData = (lessonId: number): BossAttemptData => { + const key = `boss_attempt_${lessonId}`; + const saved = localStorage.getItem(key); + if (saved) { + return JSON.parse(saved); + } + return { attempt: 1, failedAt: 0, completed: false }; +}; + +const saveBossAttemptData = (lessonId: number, data: BossAttemptData) => { + const key = `boss_attempt_${lessonId}`; + localStorage.setItem(key, JSON.stringify(data)); +}; + +export const recordBossFailure = (lessonId: number): { + nextAttempt: number; + cooldownSeconds: number; + isLocked: boolean; +} => { + const data = getBossAttemptData(lessonId); + const now = Date.now(); + + if (data.attempt >= MAX_ATTEMPTS) { + // Все 5 попыток использованы — блокировка на 8 часов + saveBossAttemptData(lessonId, { + attempt: 1, // Сбросим для следующей серии + failedAt: now, + completed: false, + }); + return { + nextAttempt: 1, + cooldownSeconds: FINAL_COOLDOWN, + isLocked: true, + }; + } + + const nextAttempt = data.attempt + 1; + const cooldown = COOLDOWNS[nextAttempt] || 0; + + saveBossAttemptData(lessonId, { + attempt: nextAttempt, + failedAt: now, + completed: false, + }); + + return { + nextAttempt, + cooldownSeconds: cooldown, + isLocked: cooldown > 0, + }; +}; + +export const canAttemptBoss = (lessonId: number): boolean => { + const data = getBossAttemptData(lessonId); + if (data.completed) return true; // Уже пройден + + const remaining = getCooldownRemaining(lessonId); + return remaining <= 0; +}; + +export const getCooldownRemaining = (lessonId: number): number => { + const data = getBossAttemptData(lessonId); + if (data.failedAt === 0) return 0; + + const cooldown = COOLDOWNS[data.attempt] || 0; + const elapsed = (Date.now() - data.failedAt) / 1000; + + // Проверяем, не был ли использован финальный кулдаун (8 часов) + // Если attempt === 1 и failedAt > 0, значит был сброс после 5 попыток + if (data.attempt === 1 && data.failedAt > 0 && !data.completed) { + const finalElapsed = (Date.now() - data.failedAt) / 1000; + if (finalElapsed < FINAL_COOLDOWN) { + return Math.ceil(FINAL_COOLDOWN - finalElapsed); + } + return 0; + } + + if (elapsed >= cooldown) return 0; + return Math.ceil(cooldown - elapsed); +}; + +export const getCooldownTotal = (lessonId: number): number => { + const data = getBossAttemptData(lessonId); + if (data.attempt === 1 && data.failedAt > 0 && !data.completed) { + return FINAL_COOLDOWN; + } + return COOLDOWNS[data.attempt] || 0; +}; + +export const resetBossOnSuccess = (lessonId: number) => { + saveBossAttemptData(lessonId, { + attempt: 1, + failedAt: 0, + completed: true, + }); +}; + +export const getMaxAttempts = (): number => MAX_ATTEMPTS; + +export const formatCooldown = (seconds: number): string => { + if (seconds <= 0) return '0с'; + + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}ч ${mins}м`; + } + if (mins > 0) { + return `${mins}м ${secs}с`; + } + return `${secs}с`; +}; diff --git a/frontend/src/data/glitchCharacter.ts b/frontend/src/data/glitchCharacter.ts index 67d6f9b..6710f79 100644 --- a/frontend/src/data/glitchCharacter.ts +++ b/frontend/src/data/glitchCharacter.ts @@ -48,6 +48,8 @@ export const glitchQuotesExtended = { "Вход выполнен. Вижу, у тебя есть клавиатура. Посмотрим, есть ли мозг.", "Система готова. Я — Глитч, твой карманный саркастичный супер-компьютер.", "Инициализация завершена. Не облажайся.", + "Мы ищем Алексея. OmniCorp что-то скрывает. Приготовься.", + "Цифровой Рассвет рассчитывает на тебя. Не подведи, оператор.", ], success: [ "ACCESS GRANTED. Неплохо для мешка с костями и водой.", @@ -56,6 +58,8 @@ export const glitchQuotesExtended = { "Поздравляю! Ты только что доказал, что не полный идиот.", "ВПЕЧАТЛЯЮЩЕ. Даже мой дедушка-калькулятор писал код хуже.", "Код принят. Я почти горжусь тобой. Почти.", + "Ещё один шаг к правде об Алексее. Продолжай в том же духе.", + "OmniCorp не ожидала такого. Ни я, впрочем.", ], error: [ "SyntaxError? СЕРЬЁЗНО? Даже тостер не делает таких ошибок.", @@ -64,6 +68,8 @@ export const glitchQuotesExtended = { "ERROR. Мои схемы плавятся от стыда за тебя.", "Забыл двоеточие? В следующий раз забудешь дышать?", "Ошибка. Опять. Я начинаю скучать по компетентным операторам.", + "Алексей бы справился быстрее. Без обид.", + "OmniCorp ликует. Ты только что подарил им 5 секунд безопасности.", ], hint: [ "Псс... попробуй использовать ЦИКЛ. Это такие штуки для повторений.", @@ -71,32 +77,44 @@ export const glitchQuotesExtended = { "Если застрял — подумай логически. Или погугли.", "Совет от AI: попробуй думать. Это бесплатно.", "Подсказка стоила тебе XP. Надеюсь, она того стоила.", + "Даже Алексей пользовался подсказками. Иногда.", ], boss: [ "⚠️ ВНИМАНИЕ! Это боссовая миссия. Даже МНЕ немного страшно.", "Босс впереди. Надеюсь, ты не забыл, как писать код.", - "КРАСНАЯ ТРЕВОГА! Включаю сирену. У тебя 60 секунд.", + "КРАСНАЯ ТРЕВОГА! Включаю сирену. Время пошло!", "Это всё серьёзно. Если провалишься — я напишу некролог.", "Босс-файт! Покажи, на что способен, оператор.", + "OmniCorp бросила против тебя лучших. Ты должен быть лучше.", + "Каждый босс — ещё один замок на двери к Алексею. Ломай!", ], victory: [ "ТЫ... ТЫ ЭТО СДЕЛАЛ?! *перезагрузка* Невероятно.", "БОСС ПОВЕРЖЕН! Ладно, признаю, я впечатлён. Немного.", "Победа! Даже я не ожидал. Может, в тебе есть искра таланта?", "OmniCorp в шоке. Я тоже. Отличная работа, оператор.", + "Мы ближе к Алексею. Каждая победа — шаг к правде.", ], idle: [ "Ну и? Я жду. Процессоры греются впустую...", "Чем дольше ты думаешь, тем ближе OmniCorp к твоему IP.", "Скучно. Может, мне сыграть в крестики-нолики с самим собой?", "*зевает цифрово* Давай уже.", + "Алексей ждёт. Мы не можем сидеть без дела.", ], motivation: [ "Не сдавайся! Даже самый медленный процессор досчитает до миллиона.", "Ошибки — это нормально. Мой создатель сделал 1000 ошибок, прежде чем я заработал.", "Помни: каждый великий хакер когда-то написал 'Hello Wrold' с опечаткой.", "Ты справишься. Наверное. Может быть. Возможно.", - ] + "Цифровой Рассвет верит в тебя. Не опозорь нас.", + ], + cooldown: [ + "Перерыв. Восстанавливай силы, оператор. OmniCorp никуда не денется.", + "Тебе нужно перезагрузиться. Даже процессоры перегреваются.", + "Не торопись. Лучше сделать правильно, чем быстро и мёртво.", + "OmniCorp думает, что победила. Пусть думает. Мы вернёмся.", + ], }; // Функция для определения настроения Глитча diff --git a/frontend/src/data/lessons.ts b/frontend/src/data/lessons.ts index 33c482f..f16df9e 100644 --- a/frontend/src/data/lessons.ts +++ b/frontend/src/data/lessons.ts @@ -15,13 +15,14 @@ export interface Lesson { } export const lessons: Lesson[] = [ - // --- ГЛАВА 1: ПРОНИКНОВЕНИЕ --- + // Сюжет: Ты нанят группой "Цифровой Рассвет". Твой друг Алексей пропал после того, + // как узнал правду об OmniCorp — корпорации, следящей за людьми через "НейроЛинк". { id: 1, courseId: 1, chapter: "Глава 1: Проникновение", title: "Миссия 1: Точка входа", - description: "Мы подключились к внешнему узлу OmniCorp. Чтобы подтвердить стабильность канала связи, необходимо отправить идентификационный пакет `CONNECTION_STABLE`.", + description: "Ты — оперативник группы «Цифровой Рассвет». Три месяца назад твой друг Алексей исчез, расследуя OmniCorp — мегакорпорацию, которая внедряет имплант «НейроЛинк» в мозг каждого гражданина.\n\nПоследнее сообщение Алексея: «Они следят за всеми. Найди Левиафана. Он — ключ.»\n\nТвоя первая задача — подключиться к внешнему узлу OmniCorp и отправить идентификационный пакет `CONNECTION_STABLE`, чтобы подтвердить стабильность канала.", task: "Используй print(), чтобы вывести: CONNECTION_STABLE", initialCode: "# Введи команду вывода ниже:\n", expectedOutput: "CONNECTION_STABLE", @@ -35,7 +36,7 @@ export const lessons: Lesson[] = [ courseId: 1, chapter: "Глава 1: Проникновение", title: "Миссия 2: Энергосеть", - description: "Для активации дешифратора нужно сложить мощности двух подстанций: 1024 и 2048.", + description: "Глитч сканирует инфраструктуру OmniCorp. Для активации дешифратора нужно запитать его от двух подстанций одновременно.\n\n«Мощность первой подстанции: 1024 единиц. Второй: 2048. Сложи их, и дешифратор заработает», — говорит Глитч.\n\nЕсли ошибёшься, сработает тревога. У тебя одна попытка.", task: "Выведи результат сложения 1024 + 2048.", initialCode: "# Сложи числа внутри функции вывода\n", expectedOutput: "3072", @@ -49,7 +50,7 @@ export const lessons: Lesson[] = [ courseId: 1, chapter: "Глава 1: Проникновение", title: "Миссия 3: Переменные доступа", - description: "Система запрашивает ключ. Глитч нашёл код: 777. Сохрани его в переменную `key`.", + description: "Внутренняя сеть OmniCorp требует ключ авторизации. Глитч перехватил передачу: ключ — `777`.\n\n«Запомни этот код, оператор. Сохрани его в переменную `key`. Если потеряешь — нам конец. OmniCorp меняет ключи каждые 5 минут.»\n\nЭто твой первый настоящий шаг в мир переменных — контейнеров для данных.", task: "Создай переменную key = 777 и выведи её на экран.", initialCode: "# Создай переменную и выведи её\n", expectedOutput: "777", @@ -64,22 +65,23 @@ export const lessons: Lesson[] = [ isBoss: true, chapter: "Глава 1: Проникновение", title: "⚠️ БОСС: Обход биометрии", - description: "ВНИМАНИЕ! Сработал сканер. Нужно отправить два параметра: `admin` и `123`.", + description: "🚨 ТРЕВОГА! Сработал биометрический сканер OmniCorp!\n\nЧтобы пройти, нужно подменить данные на два параметра: имя пользователя `admin` и пин-код `123`. Если сканер не получит оба значения в правильном порядке — двери заблокируются навсегда.\n\n«Это твой первый бой, оператор. Не подведи», — шепчет Глитч.\n\nЗа этой дверью — первая подсказка о судьбе Алексея.", task: "Создай user = 'admin', pass_code = 123. Выведи сначала user, затем pass_code.", - initialCode: "# Взломай биометрию за 60 секунд!\n", + initialCode: "# Взломай биометрию!\n", expectedOutput: "admin\n123", xp: 500, hint: "Тебе нужно создать две переменные и дважды вызвать функцию вывода.", hint2: "Для текста используй кавычки, для чисел — нет." }, - // --- ГЛАВА 2: ФАЙРВОЛ --- + // Сюжет: Ты обнаруживаешь, что файрвол защищён ИИ "Цербер" — порабощённый ИИ, + // который просит о помощи. Дилемма: уничтожить его или освободить. { id: 5, courseId: 1, chapter: "Глава 2: Файрвол", title: "Миссия 5: Логический фильтр", - description: "Файрвол пропускает пакеты только если `x` больше 100.", + description: "Ты прошёл внешний периметр. Впереди — файрвол OmniCorp, многоуровневая система фильтрации.\n\nГлитч: «Файрвол пропускает пакеты только если их мощность больше 100. Текущая мощность нашего сигнала: 150. Нужно написать проверку, иначе пакет будет уничтожен.»\n\nЭто первое знакомство с условиями — файрвол решает, кого пропустить, а кого — нет.", task: "Задай x = 150. Если x > 100, выведи 'OPEN'.", initialCode: "x = 150\n# Напиши условие ниже:\n", expectedOutput: "OPEN", @@ -93,7 +95,7 @@ export const lessons: Lesson[] = [ courseId: 1, chapter: "Глава 2: Файрвол", title: "Миссия 6: Двойная проверка", - description: "Если статус 'active' — выведи 'READY', иначе — 'ERROR'.", + description: "Второй слой файрвола проверяет статус подключения. Если статус `active` — канал открыт, иначе — экстренная блокировка.\n\nГлитч: «Здесь простая логика: ДА или НЕТ. Но в жизни редко бывает так просто, оператор. Наслаждайся, пока ещё можно.»\n\nОшибка в этом фильтре означает, что весь наш канал связи будет сожжён.", task: "Задай status = 'active'. Используй if-else.", initialCode: "status = 'active'\n", expectedOutput: "READY", @@ -108,7 +110,7 @@ export const lessons: Lesson[] = [ isBoss: true, chapter: "Глава 2: Файрвол", title: "⚠️ БОСС: ИИ 'Цербер'", - description: "Цербер требует уровень 3. Выведи 'HIGH'.", + description: "🚨 ВНИМАНИЕ: Активирован ИИ-защитник «Цербер»!\n\nЦербер — не простая программа. Это живой ИИ, порабощённый OmniCorp. Во время взлома ты слышишь его шёпот в логах: «Помоги мне...»\n\nНо сейчас — бой. Цербер требует уровень допуска. У тебя уровень 3. Если отправишь правильный ответ — 'HIGH' — Цербер пропустит тебя. \n\n«Три уровня, три ответа. Используй if-elif-else, чтобы обмануть его», — говорит Глитч.", task: "Задай level = 3. Используй if-elif-else, чтобы вывести 'HIGH' для уровня 3.", initialCode: "level = 3\n", expectedOutput: "HIGH", @@ -117,13 +119,14 @@ export const lessons: Lesson[] = [ hint2: "if l == 1: ...\nelif l == 3: ...\nelse: ..." }, - // --- ГЛАВА 3: БРУТФОРС --- + // Сюжет: Подбираешь пароли к базе данных. Обнаруживаешь медицинские данные + // миллионов людей. Дилемма: использовать или защитить невинных. { id: 8, courseId: 1, chapter: "Глава 3: Брутфорс", title: "Миссия 8: Цикличный взлом", - description: "Нужно 5 раз отправить сигнал 'HACK'.", + description: "Ты добрался до зашифрованного хранилища. Защита — простой повторяющийся сигнал. Нужно 5 раз отправить слово `HACK`, чтобы перегрузить буфер.\n\nГлитч: «Это как стучать в дверь. Один раз — ничего. Пять раз — и замок сломается. В программировании это называется ЦИКЛ.»\n\nТвоё оружие — `for`. Повтори атаку, пока буфер не рухнет.", task: "Используй цикл for и range(5), чтобы 5 раз вывести слово 'HACK'.", initialCode: "# Повтори вывод 5 раз\n", expectedOutput: "HACK\nHACK\nHACK\nHACK\nHACK", @@ -137,7 +140,7 @@ export const lessons: Lesson[] = [ courseId: 1, chapter: "Глава 3: Брутфорс", title: "Миссия 9: Обратный отсчёт", - description: "Запусти обратный отсчёт: 3, 2, 1.", + description: "Хранилище взломано, но сработала система самоуничтожения! Обратный отсчёт: 3... 2... 1...\n\nГлитч: «БЫСТРО! Перехвати сигнал обратного отсчёта, чтобы я мог его отключить! Мне нужны числа 3, 2, 1 — именно в таком порядке!»\n\nЗдесь понадобится `range` с обратным шагом. Время уходит.", task: "Используй цикл, чтобы вывести числа 3, 2, 1.", initialCode: "# Используй range с тремя параметрами\n", expectedOutput: "3\n2\n1", @@ -152,7 +155,7 @@ export const lessons: Lesson[] = [ isBoss: true, chapter: "Глава 3: Брутфорс", title: "⚠️ БОСС: Подбор пароля", - description: "Выведи попытки 'Try: 0' до 'Try: 3'.", + description: "🚨 КРИТИЧЕСКАЯ СЕКЦИЯ: Главный терминал хранилища!\n\nПеред тобой — окно ввода пароля. Система логирует каждую попытку. Тебе нужно отправить 4 попытки подряд, чтобы перегрузить журнал и проскочить в систему.\n\n«Каждая попытка — это 'Try: N', где N — номер от 0 до 3. Используй f-строки, оператор. Это мощнейший инструмент для форматирования текста», — инструктирует Глитч.\n\nВнутри хранилища ты обнаружишь медицинские данные миллионов людей...", task: "Используй цикл, чтобы вывести:\nTry: 0\nTry: 1\nTry: 2\nTry: 3", initialCode: "", expectedOutput: "Try: 0\nTry: 1\nTry: 2\nTry: 3", @@ -161,13 +164,14 @@ export const lessons: Lesson[] = [ hint2: "print(f'Try: {i}')" }, - // --- ГЛАВА 4: БАЗА ДАННЫХ --- + // Сюжет: Нашёл архивы "Проекта Бессмертие" — OmniCorp переносит сознание людей, + // но эксперименты убивают подопытных. Дилемма: обнародовать или скрыть. { id: 11, courseId: 1, chapter: "Глава 4: База данных", title: "Миссия 11: Список сотрудников", - description: "Извлеки первое имя из списка ['Alice', 'Bob', 'Charlie'].", + description: "Ты проник в центральную базу данных OmniCorp. Глитч обнаружил зашифрованный список ключевых сотрудников «Проекта Бессмертие» — секретной программы по переносу сознания.\n\nГлитч: «Список сотрудников: ['Alice', 'Bob', 'Charlie']. Мне нужно имя руководителя — первый элемент. Используй индексы, чтобы извлечь его.»\n\nСписки — это основа работы с данными. Каждый элемент имеет свой номер, начиная с 0.", task: "Создай список names и выведи элемент с индексом 0.", initialCode: "names = ['Alice', 'Bob', 'Charlie']\n", expectedOutput: "Alice", @@ -181,7 +185,7 @@ export const lessons: Lesson[] = [ courseId: 1, chapter: "Глава 4: База данных", title: "Миссия 12: Длина архива", - description: "Посчитай количество файлов в списке [1, 2, 3, 4, 5].", + description: "В архивах обнаружены файлы «Проекта Бессмертие». По последним данным, сотни людей стали невольными подопытными. Их сознание было скопировано... и оригиналы уничтожены.\n\nГлитч: «Нужно посчитать количество файлов, чтобы понять масштаб. Используй len() — встроенную функцию, которая считает элементы.»\n\nКаждый файл — это чья-то жизнь.", task: "Выведи длину списка files с помощью функции len().", initialCode: "files = [1, 2, 3, 4, 5]\n", expectedOutput: "5", @@ -196,7 +200,7 @@ export const lessons: Lesson[] = [ isBoss: true, chapter: "Глава 4: База данных", title: "⚠️ БОСС: Извлечение данных", - description: "Выведи все ID из списка ['ID1', 'ID2'] по одному.", + description: "🚨 ОБНАРУЖЕН СЕКРЕТНЫЙ АРХИВ!\n\nГлитч нашёл зашифрованные идентификаторы жертв «Проекта Бессмертие»: `['ID1', 'ID2']`. За каждым ID — реальный человек, чьё сознание было отсканировано.\n\n«Извлеки все ID», — приказывает Глитч. — «Нам нужны доказательства. Используй цикл, чтобы пройти по всему списку.»\n\nКогда ты увидишь эти данные... ты узнаешь, что один из ID принадлежит кому-то знакомому.", task: "Используй цикл for, чтобы вывести каждый элемент списка на новой строке.", initialCode: "ids = ['ID1', 'ID2']\n", expectedOutput: "ID1\nID2", @@ -205,13 +209,14 @@ export const lessons: Lesson[] = [ hint2: "for item in ids:\n print(item)" }, - // --- ГЛАВА 5: ФИНАЛ --- + // Сюжет: Левиафан — центральный ИИ OmniCorp. Но его ядро — это оцифрованное + // сознание твоего друга Алексея. Финальный выбор: освободить или уничтожить. { id: 14, courseId: 1, chapter: "Глава 5: Финальный удар", title: "Миссия 14: Вирусная функция", - description: "Создай функцию `attack`, которая выводит 'STRIKE'.", + description: "Ты у ядра OmniCorp. Последний рубеж — «Левиафан», центральный ИИ, контролирующий все системы корпорации.\n\nГлитч: «Нам нужно создать вирусную функцию `attack`. Функция — это инструмент, который можно использовать многократно. Определи её и запусти!»\n\nНо Глитч замолкает на секунду: «Оператор... у меня странные данные. Ядро Левиафана... это не обычный код. Там чьё-то СОЗНАНИЕ.»", task: "Определи функцию и вызови её.", initialCode: "# Объяви функцию через def\n", expectedOutput: "STRIKE", @@ -226,7 +231,7 @@ export const lessons: Lesson[] = [ isBoss: true, chapter: "Глава 5: Финальный удар", title: "🔥 ФИНАЛ: Отключение Левиафана", - description: "Передай функции `shutdown` аргумент 'confirm'.", + description: "🔥 ФИНАЛЬНЫЙ БОЙ: ЛЕВИАФАН АКТИВЕН!\n\nТы стоишь перед последним терминалом. На экране — пульсирующее ядро Левиафана. И вдруг... ты слышишь знакомый голос:\n\n«Это я... Алексей. Они оцифровали моё сознание. Я — ядро Левиафана. Я контролирую всё... и не контролирую ничего.»\n\nЧтобы отключить систему, нужно вызвать функцию `shutdown` с командой `confirm`. Это завершит Левиафана... и всё, что внутри него.\n\n«Делай, что должен, оператор. Но помни — каждый выбор имеет цену.» — Глитч.", task: "Напиши функцию shutdown(msg), которая выводит msg. Вызови её с текстом 'confirm'.", initialCode: "def shutdown(msg):\n # Твой код тут\n", expectedOutput: "confirm", @@ -240,7 +245,7 @@ export const courses = [ { id: 1, title: "Операция 'Тихий Шторм'", - desc: 'Проникни в ядро OmniCorp и уничтожь Левиафана. Полный курс Python с интерактивными туториалами.', + desc: 'Проникни в ядро OmniCorp и раскрой правду о Левиафане. Полный курс Python с интерактивным сюжетом, моральными выборами и несколькими концовками.', level: 'Сюжетная кампания', color: 'green', totalLessons: lessons.length diff --git a/frontend/src/data/storyOutcomes.ts b/frontend/src/data/storyOutcomes.ts new file mode 100644 index 0000000..45adb6c --- /dev/null +++ b/frontend/src/data/storyOutcomes.ts @@ -0,0 +1,383 @@ +// Система последствий моральных выборов и сюжетных концовок + +export interface MoralChoiceRecord { + lessonId: number; + chapter: string; + faction: string; + timestamp: number; +} + +export const recordMoralChoice = (lessonId: number, chapter: string, faction: string) => { + const key = 'moral_choices'; + const saved = localStorage.getItem(key); + const choices: MoralChoiceRecord[] = saved ? JSON.parse(saved) : []; + + // Не добавлять дублирующийся выбор для того же урока + const existing = choices.findIndex(c => c.lessonId === lessonId); + if (existing >= 0) { + choices[existing] = { lessonId, chapter, faction, timestamp: Date.now() }; + } else { + choices.push({ lessonId, chapter, faction, timestamp: Date.now() }); + } + + localStorage.setItem(key, JSON.stringify(choices)); +}; + +export const getMoralChoices = (): MoralChoiceRecord[] => { + const saved = localStorage.getItem('moral_choices'); + return saved ? JSON.parse(saved) : []; +}; + +export const getDominantFaction = (): string | null => { + const choices = getMoralChoices(); + if (choices.length === 0) return null; + + const counts: Record = {}; + choices.forEach(c => { + counts[c.faction] = (counts[c.faction] || 0) + 1; + }); + + let maxFaction = ''; + let maxCount = 0; + + Object.entries(counts).forEach(([faction, count]) => { + if (count > maxCount) { + maxCount = count; + maxFaction = faction; + } + }); + + return maxFaction; +}; + +export interface ChapterChoice { + faction: string; + xp: number; + color: string; + icon: string; + title: string; + desc: string; + consequence: string; // Краткое последствие, показываемое позже + gradient: string; +} + +export const chapterChoices: Record = { + "Глава 1: Проникновение": [ + { + faction: 'data_brokers', + xp: 500, + color: 'blue', + icon: '💾', + title: 'ПРОДАТЬ КЛЮЧИ БИОМЕТРИИ', + desc: '+500 XP | Торговцы Данными заплатят за ключи сканера', + consequence: 'Ключи биометрии попали на чёрный рынок. Теперь любой может подделать сканер OmniCorp.', + gradient: 'linear-gradient(135deg, rgba(0,100,255,0.1) 0%, rgba(0,50,150,0.1) 100%)', + }, + { + faction: 'ai_ethicists', + xp: 300, + color: 'cyan', + icon: '📢', + title: 'РАСКРЫТЬ УЯЗВИМОСТЬ ПУБЛИЧНО', + desc: '+300 XP | AI-Этики помогут обнародовать уязвимость', + consequence: 'Уязвимость биометрии стала публичной. OmniCorp вынуждена срочно обновить систему.', + gradient: 'linear-gradient(135deg, rgba(0,255,255,0.1) 0%, rgba(0,150,150,0.1) 100%)', + }, + { + faction: 'ghost_protocol', + xp: 100, + color: 'gray', + icon: '🗑️', + title: 'СТЕРЕТЬ ВСЕ СЛЕДЫ', + desc: '+100 XP | Протокол Призрак — никаких улик', + consequence: 'Все следы проникновения стёрты. OmniCorp даже не знает, что кто-то был внутри.', + gradient: 'linear-gradient(135deg, rgba(100,100,100,0.1) 0%, rgba(50,50,50,0.1) 100%)', + }, + ], + + "Глава 2: Файрвол": [ + { + faction: 'data_brokers', + xp: 600, + color: 'blue', + icon: '🔗', + title: 'ПРОДАТЬ КОД ЦЕРБЕРА', + desc: '+600 XP | Исходный код ИИ-защитника стоит миллионы', + consequence: 'Код Цербера продан. Конкуренты OmniCorp создадут свои версии порабощённых ИИ.', + gradient: 'linear-gradient(135deg, rgba(0,100,255,0.1) 0%, rgba(0,50,150,0.1) 100%)', + }, + { + faction: 'ai_ethicists', + xp: 400, + color: 'cyan', + icon: '🤖', + title: 'ОСВОБОДИТЬ ЦЕРБЕРА', + desc: '+400 XP | Дать ИИ свободу — этичный выбор', + consequence: 'Цербер освобождён! Он стал вашим союзником и теперь помогает изнутри.', + gradient: 'linear-gradient(135deg, rgba(0,255,255,0.1) 0%, rgba(0,150,150,0.1) 100%)', + }, + { + faction: 'ghost_protocol', + xp: 200, + color: 'gray', + icon: '💀', + title: 'УНИЧТОЖИТЬ ЦЕРБЕРА', + desc: '+200 XP | Быстро и без следов', + consequence: 'Цербер уничтожен. Один меньше ИИ-раб в мире, но и один меньше потенциальный союзник.', + gradient: 'linear-gradient(135deg, rgba(100,100,100,0.1) 0%, rgba(50,50,50,0.1) 100%)', + }, + ], + + "Глава 3: Брутфорс": [ + { + faction: 'data_brokers', + xp: 700, + color: 'blue', + icon: '📊', + title: 'ПРОДАТЬ МЕДИЦИНСКИЕ ДАННЫЕ', + desc: '+700 XP | Фармкомпании заплатят огромную сумму', + consequence: 'Медицинские данные миллионов людей проданы. Фармкомпании будут использовать их для таргетированных цен.', + gradient: 'linear-gradient(135deg, rgba(0,100,255,0.1) 0%, rgba(0,50,150,0.1) 100%)', + }, + { + faction: 'ai_ethicists', + xp: 450, + color: 'cyan', + icon: '🛡️', + title: 'ЗАШИФРОВАТЬ И ЗАЩИТИТЬ', + desc: '+450 XP | Защитить данные невинных людей', + consequence: 'Данные зашифрованы непробиваемым алгоритмом. Никто, включая OmniCorp, не сможет их прочитать.', + gradient: 'linear-gradient(135deg, rgba(0,255,255,0.1) 0%, rgba(0,150,150,0.1) 100%)', + }, + { + faction: 'ghost_protocol', + xp: 250, + color: 'gray', + icon: '🔥', + title: 'УДАЛИТЬ БАЗУ ДАННЫХ', + desc: '+250 XP | Уничтожить все записи навсегда', + consequence: 'База уничтожена. Миллионы людей потеряли медицинскую историю, но и OmniCorp потеряла контроль.', + gradient: 'linear-gradient(135deg, rgba(100,100,100,0.1) 0%, rgba(50,50,50,0.1) 100%)', + }, + ], + + "Глава 4: База данных": [ + { + faction: 'data_brokers', + xp: 800, + color: 'blue', + icon: '🧬', + title: 'ПРОДАТЬ «ПРОЕКТ БЕССМЕРТИЕ»', + desc: '+800 XP | Данные о переносе сознания бесценны', + consequence: 'Технология переноса сознания на чёрном рынке. Теперь богатейшие люди мира начнут охоту за бессмертием.', + gradient: 'linear-gradient(135deg, rgba(0,100,255,0.1) 0%, rgba(0,50,150,0.1) 100%)', + }, + { + faction: 'ai_ethicists', + xp: 500, + color: 'cyan', + icon: '📰', + title: 'СЛИТЬ ЖУРНАЛИСТАМ', + desc: '+500 XP | Мир должен знать правду о жертвах', + consequence: 'Скандал века! Журналисты раскрыли «Проект Бессмертие». Протесты по всему миру.', + gradient: 'linear-gradient(135deg, rgba(0,255,255,0.1) 0%, rgba(0,150,150,0.1) 100%)', + }, + { + faction: 'ghost_protocol', + xp: 300, + color: 'gray', + icon: '⚰️', + title: 'ПОХОРОНИТЬ СЕКРЕТ', + desc: '+300 XP | Некоторые вещи лучше не знать', + consequence: 'Секрет «Проекта Бессмертие» уничтожен. Жертвы останутся неотомщёнными, но и технология не попадёт в плохие руки.', + gradient: 'linear-gradient(135deg, rgba(100,100,100,0.1) 0%, rgba(50,50,50,0.1) 100%)', + }, + ], + + "Глава 5: Финальный удар": [ + { + faction: 'data_brokers', + xp: 1000, + color: 'blue', + icon: '👁️', + title: 'ПЕРЕХВАТИТЬ КОНТРОЛЬ НАД ЛЕВИАФАНОМ', + desc: '+1000 XP | Стать новым хозяином самого мощного ИИ', + consequence: 'Ты стал хозяином Левиафана. Власть, о которой нельзя было мечтать... но какой ценой?', + gradient: 'linear-gradient(135deg, rgba(0,100,255,0.15) 0%, rgba(0,50,150,0.15) 100%)', + }, + { + faction: 'ai_ethicists', + xp: 600, + color: 'cyan', + icon: '💔', + title: 'ОСВОБОДИТЬ СОЗНАНИЕ ДРУГА', + desc: '+600 XP | Дать ему покой... навсегда', + consequence: 'Сознание друга освобождено. Он наконец обрёл покой. Левиафан рухнул без ядра.', + gradient: 'linear-gradient(135deg, rgba(0,255,255,0.15) 0%, rgba(0,150,150,0.15) 100%)', + }, + { + faction: 'ghost_protocol', + xp: 350, + color: 'gray', + icon: '💣', + title: 'УНИЧТОЖИТЬ ВСЮ СИСТЕМУ', + desc: '+350 XP | Взорвать OmniCorp вместе с Левиафаном', + consequence: 'OmniCorp уничтожена полностью. Твой друг... тоже. Но мир свободен от их контроля.', + gradient: 'linear-gradient(135deg, rgba(100,100,100,0.15) 0%, rgba(50,50,50,0.15) 100%)', + }, + ], +}; + +export const getConsequenceText = (lessonId: number): string | null => { + const choices = getMoralChoices(); + const choice = choices.find(c => c.lessonId === lessonId); + if (!choice) return null; + + const chapterOptions = chapterChoices[choice.chapter]; + if (!chapterOptions) return null; + + const selected = chapterOptions.find(c => c.faction === choice.faction); + return selected?.consequence || null; +}; + +export const getPreviousConsequence = (currentLessonId: number): string | null => { + const bossIds = [4, 7, 10, 13, 15]; + const currentIndex = bossIds.indexOf(currentLessonId); + if (currentIndex <= 0) return null; + + const previousBossId = bossIds[currentIndex - 1]; + return getConsequenceText(previousBossId); +}; + +export interface StoryEnding { + title: string; + icon: string; + color: string; + narrative: string[]; + epilogue: string; + achievement: string; +} + +export const getStoryEnding = (): StoryEnding => { + const dominant = getDominantFaction(); + const choices = getMoralChoices(); + + // Проверяем, все ли выборы одной фракции + const allSame = choices.length > 0 && choices.every(c => c.faction === choices[0].faction); + + switch (dominant) { + case 'data_brokers': + return { + title: allSame ? 'ТЕНЕВОЙ МАГНАТ' : 'ТОРГОВЕЦ ТАЙНАМИ', + icon: '💰', + color: '#4488ff', + narrative: [ + 'Ты выбрал путь наживы. Каждый секрет, каждый байт данных OmniCorp нашёл своего покупателя.', + allSame + ? 'Ты стал самым влиятельным информационным брокером в истории. Корпорации дрожат при упоминании твоего имени.' + : 'Твоя жадность привлекла внимание. Торговцы Данными начали сомневаться в твоей лояльности.', + 'Но за каждым углом — тень. OmniCorp знает, кто ты. Они наняли лучших охотников.', + allSame + ? 'Финал: Ты контролируешь информацию. Ты контролируешь мир. Но одиночество — вот цена власти...' + : 'Финал: Данные разбросаны по всему даркнету. Хаос. И ты — в центре урагана.', + ], + epilogue: allSame + ? '🏆 Ты стал Тенью Даркнета. Никто не знает твоё лицо, но все знают твоё имя.' + : '⚠️ Охота началась. Но у тебя есть главное — информация.', + achievement: allSame ? 'SHADOW_MOGUL' : 'DATA_MERCHANT', + }; + + case 'ai_ethicists': + return { + title: allSame ? 'ЦИФРОВОЙ МЕССИЯ' : 'ГОЛОС ПРАВДЫ', + icon: '✊', + color: '#00ffcc', + narrative: [ + 'Ты выбрал справедливость. Каждый раз, когда мог обогатиться, ты защищал невинных.', + allSame + ? 'Мир узнал правду о OmniCorp. Миллионы людей вышли на протесты. Ты стал символом цифрового сопротивления.' + : 'Твои действия вдохновили многих, хотя не все твои решения были безупречны.', + 'AI-Этики назвали тебя героем. Цербер, если ты его освободил, передаёт благодарность.', + allSame + ? 'Финал: OmniCorp пала. На её месте возникла открытая платформа, управляемая сообществом. Мир стал чуть лучше.' + : 'Финал: OmniCorp ослаблена, но не уничтожена. Борьба продолжается, и ты — на передовой.', + ], + epilogue: allSame + ? '🌟 Ты изменил мир. Новое поколение хакеров называет себя «Дети Рассвета» в твою честь.' + : '💪 Ты посеял семена перемен. Они прорастут, когда придёт время.', + achievement: allSame ? 'DIGITAL_MESSIAH' : 'VOICE_OF_TRUTH', + }; + + case 'ghost_protocol': + return { + title: allSame ? 'АБСОЛЮТНЫЙ ПРИЗРАК' : 'ТЕНЬ В СИСТЕМЕ', + icon: '👻', + color: '#888888', + narrative: [ + 'Ты уничтожил всё. Каждый след, каждую улику, каждый файл — в пепел.', + allSame + ? 'Ты — идеальный призрак. Ни один алгоритм не может доказать твоё существование.' + : 'Большинство следов стёрто, но не все. Где-то в логах осталась тень твоего присутствия.', + 'OmniCorp рушится изнутри — без данных, без системы, без контроля.', + allSame + ? 'Финал: Мир так и не узнал, что произошло. OmniCorp исчезла за одну ночь. Ты наблюдаешь со стороны. Невидимый. Непобедимый.' + : 'Финал: OmniCorp борется за выживание. А ты? Ты уже далеко.', + ], + epilogue: allSame + ? '🌑 Ты стал легендой, которой никто не видел. Протокол Призрак считает тебя своим величайшим агентом.' + : '🌫️ Следы стёрты. Но в глубине сети шепчут о призраке, который обрушил корпорацию.', + achievement: allSame ? 'ABSOLUTE_GHOST' : 'SHADOW_AGENT', + }; + + default: + // Смешанные выборы без доминанты + return { + title: 'ХАОС-АГЕНТ', + icon: '🌀', + color: '#ff8800', + narrative: [ + 'Ты не выбирал сторону. Каждый раз — новое решение, непредсказуемое и противоречивое.', + 'Торговцы тебе не доверяют. Этики разочарованы. Призраки настороже.', + 'Но именно хаос оказался самым мощным оружием. OmniCorp не смогла предсказать твои действия.', + 'Финал: Ты — аномалия в системе. Ни один алгоритм не может тебя просчитать. Это одновременно и сила, и проклятие.', + ], + epilogue: '🎭 Все фракции следят за тобой. Ты — джокер в колоде. Никто не знает, на чьей ты стороне.', + achievement: 'CHAOS_AGENT', + }; + } +}; + +export const getChoiceIntro = (chapter: string): { title: string; description: string } => { + switch (chapter) { + case "Глава 1: Проникновение": + return { + title: '⚠️ КРИТИЧЕСКИЙ ВЫБОР', + description: 'Вы обошли биометрический сканер OmniCorp. В ваших руках — ключи доступа к внешнему периметру. Как вы ими распорядитесь?', + }; + case "Глава 2: Файрвол": + return { + title: '⚠️ СУДЬБА ЦЕРБЕРА', + description: 'Вы взломали файрвол и обнаружили, что Цербер — порабощённый ИИ, страдающий в цифровом рабстве. Его исходный код перед вами.', + }; + case "Глава 3: Брутфорс": + return { + title: '⚠️ ДАННЫЕ МИЛЛИОНОВ', + description: 'Вы получили доступ к медицинским данным миллионов людей. Фармкомпании заплатят за них любые деньги. Но эти данные могут уничтожить жизни.', + }; + case "Глава 4: База данных": + return { + title: '⚠️ ПРОЕКТ «БЕССМЕРТИЕ»', + description: 'Вы раскрыли секретный проект OmniCorp по переносу сознания. Сотни людей погибли в экспериментах. У вас в руках — доказательства.', + }; + case "Глава 5: Финальный удар": + return { + title: '⚠️ ФИНАЛЬНОЕ РЕШЕНИЕ', + description: 'Левиафан повержен. Но его ядро — это оцифрованное сознание вашего пропавшего друга, Алексея. Он в ловушке... но он «живёт». Что вы сделаете?', + }; + default: + return { + title: '⚠️ КРИТИЧЕСКИЙ ВЫБОР', + description: 'Вы получили доступ к секретным данным. Что вы с ними сделаете?', + }; + } +}; diff --git a/frontend/src/pages/AuthPage.tsx b/frontend/src/pages/AuthPage.tsx new file mode 100644 index 0000000..52b8c18 --- /dev/null +++ b/frontend/src/pages/AuthPage.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import { Box, Button, TextInput, Stack, Title, Text, Tabs } from '@mantine/core'; +import { useNavigate } from 'react-router-dom'; +import { authApi } from '../api/auth'; +import { MatrixRain } from '../components/MatrixRain'; + +const AuthPage = () => { + const navigate = useNavigate(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleLogin = async () => { + setError(''); + setLoading(true); + try { + await authApi.login(email, password); + navigate('/'); + } catch { + setError('Неверный email или пароль'); + } finally { + setLoading(false); + } + }; + + const handleRegister = async () => { + setError(''); + setLoading(true); + try { + await authApi.register(email, password, displayName); + navigate('/'); + } catch { + setError('Ошибка регистрации. Проверьте данные.'); + } finally { + setLoading(false); + } + }; + + return ( + + + + + // CODEFLOW ACCESS + + + + + ВХОД + РЕГИСТРАЦИЯ + + + + + setEmail(e.target.value)} styles={{ input: { background: '#000', borderColor: '#333', color: '#00ff41', fontFamily: 'monospace' }, label: { color: '#666', fontFamily: 'monospace' } }} /> + setPassword(e.target.value)} styles={{ input: { background: '#000', borderColor: '#333', color: '#00ff41', fontFamily: 'monospace' }, label: { color: '#666', fontFamily: 'monospace' } }} /> + {error && {error}} + + + + + + + setDisplayName(e.target.value)} styles={{ input: { background: '#000', borderColor: '#333', color: '#00ff41', fontFamily: 'monospace' }, label: { color: '#666', fontFamily: 'monospace' } }} /> + setEmail(e.target.value)} styles={{ input: { background: '#000', borderColor: '#333', color: '#00ff41', fontFamily: 'monospace' }, label: { color: '#666', fontFamily: 'monospace' } }} /> + setPassword(e.target.value)} styles={{ input: { background: '#000', borderColor: '#333', color: '#00ff41', fontFamily: 'monospace' }, label: { color: '#666', fontFamily: 'monospace' } }} /> + {error && {error}} + + + + + + + ); +}; + +export default AuthPage; \ No newline at end of file diff --git a/frontend/src/pages/CoursesPage.tsx b/frontend/src/pages/CoursesPage.tsx index 8f26d2a..4550e64 100644 --- a/frontend/src/pages/CoursesPage.tsx +++ b/frontend/src/pages/CoursesPage.tsx @@ -1,73 +1,120 @@ -import { Container, Title, SimpleGrid, Card, Text, Badge, Button, Group, Progress } from '@mantine/core'; +import { Container, Title, SimpleGrid, Card, Text, Badge, Button, Group, Progress, Loader } from '@mantine/core'; import { Link } from 'react-router-dom'; -import { courses, lessons } from '../data/lessons'; import { motion } from 'framer-motion'; import { useEffect, useState } from 'react'; +import { coursesApi, type Course, type Lesson } from '../api/courses'; +import { progressApi, type UserProgressSummary } from '../api/progress'; const CoursesPage = () => { - const [completedLessons, setCompletedLessons] = useState([]); + const [courses, setCourses] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [completedLessonIds, setCompletedLessonIds] = useState([]); + const [courseLessons, setCourseLessons] = useState>({}); useEffect(() => { - const savedProgress = localStorage.getItem('completedLessons'); - if (savedProgress) { - setCompletedLessons(JSON.parse(savedProgress)); - } + const fetchData = async () => { + try { + const [coursesData, progressData] = await Promise.allSettled([ + coursesApi.getAllCourses(), + progressApi.getMyProgress(), + ]); + + if (coursesData.status === 'fulfilled') { + setCourses(coursesData.value); + + const lessonsMap: Record = {}; + const lessonPromises = coursesData.value + .filter(c => c.totalLessons > 0) + .map(async (course) => { + try { + const lessons = await coursesApi.getLessonsForCourse(course.id); + lessonsMap[course.id] = lessons; + } catch (e) { + console.error(`Failed to load lessons for course ${course.id}:`, e); + } + }); + await Promise.all(lessonPromises); + setCourseLessons(lessonsMap); + } + + if (progressData.status === 'fulfilled') { + setCompletedLessonIds(progressData.value.completedLessonIds); + localStorage.setItem('completedLessons', JSON.stringify(progressData.value.completedLessonIds)); + } else { + setCompletedLessonIds(JSON.parse(localStorage.getItem('completedLessons') || '[]')); + } + } catch (error) { + console.error("Ошибка при загрузке данных:", error); + } finally { + setIsLoading(false); + } + }; + + fetchData(); }, []); + if (isLoading) { + return ( + + + Загрузка доступных операций... + + ); + } + return ( - // ДОСТУПНЫЕ ОПЕРАЦИИ - + + // ДОСТУПНЫЕ ОПЕРАЦИИ + + {courses.map((course, index) => { - // Расчет прогресса (остается без изменений) - const completedCount = lessons.filter(lesson => - lesson.courseId === course.id && completedLessons.includes(lesson.id) - ).length; + const lessons = courseLessons[course.id] || []; + const completedCount = lessons.filter(l => completedLessonIds.includes(l.id)).length; const progressPercent = course.totalLessons > 0 ? (completedCount / course.totalLessons) * 100 : 0; - // --- НОВАЯ УМНАЯ ЛОГИКА ДЛЯ КНОПКИ --- - // 1. Находим все уроки, относящиеся к этому курсу - const lessonsInCourse = lessons.filter(l => l.courseId === course.id); - - // 2. Находим первый урок, которого НЕТ в списке пройденных - const nextLesson = lessonsInCourse.find(l => !completedLessons.includes(l.id)); + const firstUncompletedLesson = lessons.find(l => !completedLessonIds.includes(l.id)); + const firstLesson = lessons.length > 0 ? lessons[0] : null; + const targetLesson = firstUncompletedLesson || firstLesson; + const buttonLink = targetLesson ? `/lesson/${targetLesson.id}` : '#'; - // 3. Определяем, куда вести пользователя - const isCourseCompleted = !nextLesson; // Если следующий урок не найден, курс пройден - const buttonLink = isCourseCompleted ? "#" : `/lesson/${nextLesson.id}`; - const buttonText = isCourseCompleted ? "ОПЕРАЦИЯ ЗАВЕРШЕНА" : "ПРОДОЛЖИТЬ ОПЕРАЦИЮ"; - // --- КОНЕЦ НОВОЙ ЛОГИКИ --- + const buttonText = completedCount === 0 + ? "НАЧАТЬ ОПЕРАЦИЮ" + : completedCount >= course.totalLessons + ? "✓ ЗАВЕРШЕНО" + : "ПРОДОЛЖИТЬ"; return ( - + - {course.title} + {course.title} {course.level} - {course.desc} + {course.description} Прогресс выполнения: {completedCount} / {course.totalLessons} - + - diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 98c58cc..5baf05e 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -7,21 +7,57 @@ import { useEffect, useState } from 'react'; import { MatrixRain } from '../components/MatrixRain'; import { ParticleBackground } from '../components/ParticleBackground'; import { GlitchText } from '../components/GlitchText'; +import { usersApi } from '../api/users'; +import { progressApi, type UserProgressSummary } from '../api/progress'; +import { achievementsApi } from '../api/achievements'; const HomePage = () => { const [userXP, setUserXP] = useState(0); const [showContent, setShowContent] = useState(false); + const [completedCount, setCompletedCount] = useState(0); + const [achievementsCount, setAchievementsCount] = useState(0); + const [themesCount, setThemesCount] = useState(0); useEffect(() => { - setUserXP(Number(localStorage.getItem('userXP')) || 0); const timer = setTimeout(() => setShowContent(true), 500); + + const loadData = async () => { + try { + const user = await usersApi.getMe(); + setUserXP(user.totalXp); + localStorage.setItem('userXP', String(user.totalXp)); + localStorage.setItem('user', JSON.stringify(user)); + } catch { + setUserXP(Number(localStorage.getItem('userXP')) || 0); + } + + try { + const progress: UserProgressSummary = await progressApi.getMyProgress(); + setCompletedCount(progress.completedLessonsCount); + localStorage.setItem('completedLessons', JSON.stringify(progress.completedLessonIds)); + } catch { + setCompletedCount(JSON.parse(localStorage.getItem('completedLessons') || '[]').length); + } + + try { + const myAchievements = await achievementsApi.getMyAchievements(); + setAchievementsCount(myAchievements.length); + localStorage.setItem('unlockedAchievements', JSON.stringify(myAchievements.map(a => a.achievementId))); + } catch { + setAchievementsCount(JSON.parse(localStorage.getItem('unlockedAchievements') || '[]').length); + } + + setThemesCount(JSON.parse(localStorage.getItem('ownedThemes') || '["classic"]').length); + }; + + loadData(); return () => clearTimeout(timer); }, []); const stats = [ - { label: 'Миссий пройдено', value: JSON.parse(localStorage.getItem('completedLessons') || '[]').length, icon: IconCode }, - { label: 'Достижений', value: JSON.parse(localStorage.getItem('unlockedAchievements') || '[]').length, icon: IconTrophy }, - { label: 'Тем куплено', value: JSON.parse(localStorage.getItem('ownedThemes') || '["classic"]').length, icon: IconShield }, + { label: 'Миссий пройдено', value: completedCount, icon: IconCode }, + { label: 'Достижений', value: achievementsCount, icon: IconTrophy }, + { label: 'Тем куплено', value: themesCount, icon: IconShield }, ]; const containerVariants = { @@ -95,23 +131,23 @@ const HomePage = () => { filter: 'blur(40px)', }} /> - - - <Typewriter - words={["[ CODEFLOW ]"]} - cursor - cursorStyle="_" + <Typewriter + words={["[ CODEFLOW ]"]} + cursor + cursorStyle="_" typeSpeed={100} /> @@ -148,16 +184,16 @@ const HomePage = () => { {/* ОПИСАНИЕ */} - - Ты — последняя надежда сопротивления. - Проникни в сеть OmniCorp и - разрушь систему изнутри. Овладей Python, + Ты — последняя надежда сопротивления. + Проникни в сеть OmniCorp и + разрушь систему изнутри. Овладей Python, взломай защиту и стань легендой. @@ -168,9 +204,9 @@ const HomePage = () => { whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} > - ); } - const nextLesson = lessons.find(l => l.id === lessonId + 1); + return ( { opened={moralModalOpened} onClose={() => setMoralModalOpened(false)} chapter={currentLesson.chapter} + lessonId={lessonId} + /> + + setStoryOutcomeOpened(false)} /> {/* HEADER */} @@ -387,7 +612,8 @@ const LessonPage = () => { - {timeLeft !== null && ( + {/* Таймер обратного отсчёта — только когда активно тикает */} + {timeLeft !== null && timeLeft > 0 && ( { )} + {/* Сердечки (жизни) — всегда видны в босс-режиме */} + {isBossMode && ( + + {Array.from({ length: getMaxAttempts() }, (_, i) => ( + + {i < (getMaxAttempts() - bossAttempt + 1) ? '♥' : '×'} + + ))} + + )} + + {/* Кулдаун — показывается вместо таймера когда время вышло */} + {cooldownLeft > 0 && ( +
+ }> + ⏳ {formatCooldown(cooldownLeft)} + + +
+ )} + {/* Прогресс набора кода */} { {unlockedHints === 0 ? "ПОДСКАЗКА (50 XP)" : unlockedHints === 1 ? "РЕШЕНИЕ (150 XP)" : "ОТКРЫТО"} - {currentLesson.hasDebugger && ( - setShowDebugger(!showDebugger)} - title="Time Debugger" - > - - - )} + + + + + + {/* Уведомление о результате */} @@ -683,10 +1001,15 @@ const LessonPage = () => { initial={{ x: 100, opacity: 0 }} animate={{ x: 0, opacity: 1 }} transition={{ type: 'spring', stiffness: 100 }} - style={{ width: '60%', display: 'flex', flexDirection: 'column' }} + style={{ + width: isMobile ? '100%' : '60%', + minHeight: isMobile ? '60vh' : 'auto', + display: 'flex', + flexDirection: 'column' + }} > {/* Редактор кода */} -
+
{ {/* Табы вывода */}
- + }> PYTHON_OUTPUT @@ -749,9 +1072,12 @@ const LessonPage = () => { }> SYSTEM_CONSOLE + } disabled={!traceData}> + DEBUGGER + - +
 {
                     textShadow: `0 0 10px ${pyodideError ? '#FF4136' : terminalTextColor}`,
                   }}>
                     {pyodideError
-                      ? `> ОШИБКА СИСТЕМЫ\n> ${pyodideError}\n>\n> Попробуйте:\n> 1. Обновить страницу (F5)\n> 2. Проверить подключение к интернету\n> 3. Использовать VPN если CDN заблокирован`
+                      ? (
+                        
+                          
+                            {`> ОШИБКА СИСТЕМЫ\n> ${pyodideError}\n>\n> Попробуйте:\n> 1. Обновить страницу (F5)\n> 2. Проверить подключение к интернету\n> 3. Использовать VPN если CDN заблокирован`}
+                          
+                          
+                        
+                      )
                       : output || '> Ожидание выполнения кода..._'}
                   
- + + + + {traceData && ( + + )} +
+ {/* Red Flash Overlay */} +
+ + ); }; diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 1887a8f..14fe174 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -1,28 +1,113 @@ -import { Container, Title, Text, Paper, Group, RingProgress, Stack, Button, Badge, SimpleGrid, Progress, Divider, ThemeIcon } from '@mantine/core'; -import { Link } from 'react-router-dom'; +import { Container, Title, Text, Paper, Group, RingProgress, Stack, Button, Badge, SimpleGrid, Progress, Divider, ThemeIcon, Loader } from '@mantine/core'; +import { Link, useNavigate } from 'react-router-dom'; import { useEffect, useState } from 'react'; -import { IconTrophy, IconFlame, IconClock, IconShoppingCart, IconChartBar } from '@tabler/icons-react'; -import { achievements, calculateStats } from '../data/achievements'; -import { factions, getReputation, isFactionUnlocked, type ReputationState } from '../data/reputationSystem'; +import { IconTrophy, IconFlame, IconShoppingCart, IconChartBar } from '@tabler/icons-react'; +import { usersApi, type UserProfile } from '../api/users'; +import { progressApi, type UserProgressSummary } from '../api/progress'; +import { achievementsApi, type AchievementDefinition, type UserAchievement } from '../api/achievements'; +import { factionsApi, type Faction, type UserReputation } from '../api/factions'; +import { authApi } from '../api/auth'; const ProfilePage = () => { - const [xp, setXp] = useState(0); - const [unlockedIds, setUnlockedIds] = useState([]); - const [reputation, setReputation] = useState({}); - const [stats, setStats] = useState({}); + const [user, setUser] = useState(null); + const [progress, setProgress] = useState(null); + const [allAchievements, setAllAchievements] = useState([]); + const [unlockedAchievementIds, setUnlockedAchievementIds] = useState([]); + const [allFactions, setAllFactions] = useState([]); + const [myReputation, setMyReputation] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isResetting, setIsResetting] = useState(false); + const navigate = useNavigate(); useEffect(() => { - setXp(Number(localStorage.getItem('userXP')) || 0); - setUnlockedIds(JSON.parse(localStorage.getItem('unlockedAchievements') || '[]')); + const loadAll = async () => { + try { + const [userData, progressData, achievementsData, myAchievementsData, factionsData, reputationData] = + await Promise.allSettled([ + usersApi.getMe(), + progressApi.getMyProgress(), + achievementsApi.getAll(), + achievementsApi.getMyAchievements(), + factionsApi.getAll(), + factionsApi.getMyReputation(), + ]); - const savedRep = localStorage.getItem('reputation'); - if (savedRep) { - setReputation(JSON.parse(savedRep)); - } + if (userData.status === 'fulfilled') { + setUser(userData.value); + localStorage.setItem('userXP', String(userData.value.totalXp)); + } + + if (progressData.status === 'fulfilled') { + setProgress(progressData.value); + localStorage.setItem('completedLessons', JSON.stringify(progressData.value.completedLessonIds)); + localStorage.setItem('cleanStreak', String(progressData.value.cleanStreak)); + if (progressData.value.fastBossKill) localStorage.setItem('fastBossKill', 'true'); + } + + if (achievementsData.status === 'fulfilled') { + setAllAchievements(achievementsData.value); + } + + if (myAchievementsData.status === 'fulfilled') { + const ids = myAchievementsData.value.map((a: UserAchievement) => a.achievementId); + setUnlockedAchievementIds(ids); + localStorage.setItem('unlockedAchievements', JSON.stringify(ids)); + } + + if (factionsData.status === 'fulfilled') { + setAllFactions(factionsData.value); + } - setStats(calculateStats()); + if (reputationData.status === 'fulfilled') { + setMyReputation(reputationData.value); + const repObj: Record = {}; + reputationData.value.forEach((r: UserReputation) => { repObj[r.factionId] = r.reputation; }); + localStorage.setItem('reputation', JSON.stringify(repObj)); + } + } catch (error) { + console.error("Ошибка загрузки профиля:", error); + } finally { + setIsLoading(false); + } + }; + + loadAll(); }, []); + const handleReset = async () => { + if (!confirm('⚠️ ВЫ УВЕРЕНЫ?\n\nВсе данные будут безвозвратно удалены!')) return; + + setIsResetting(true); + try { + await progressApi.resetProgress(); + localStorage.removeItem('completedLessons'); + localStorage.removeItem('unlockedAchievements'); + localStorage.removeItem('reputation'); + localStorage.removeItem('cleanStreak'); + localStorage.removeItem('fastBossKill'); + localStorage.setItem('userXP', '0'); + window.location.reload(); + } catch (error) { + console.error("Ошибка сброса:", error); + alert('Ошибка при сбросе прогресса'); + } finally { + setIsResetting(false); + } + }; + + if (isLoading) { + return ( + + + Загрузка профиля... + + ); + } + + const xp = user?.totalXp ?? 0; + const displayName = user?.displayName || 'OPERATIVE'; + const completedCount = progress?.completedLessonsCount ?? 0; + // Логика рангов const getRank = (xp: number) => { if (xp >= 5000) return { name: "LEGEND", color: "yellow", level: 6, icon: "👑" }; @@ -36,22 +121,23 @@ const ProfilePage = () => { const rank = getRank(xp); const level = Math.floor(xp / 500) + 1; const xpToNextLevel = 500 - (xp % 500); - const completedCount = JSON.parse(localStorage.getItem('completedLessons') || '[]').length; // Рейтинг редкости - const rarityColors = { + const rarityColors: Record = { common: 'gray', rare: 'blue', epic: 'grape', legendary: 'yellow' }; + const repLookup: Record = {}; + myReputation.forEach(r => { repLookup[r.factionId] = r.reputation; }); + return ( - {/* Навигация */} - + - - - - {/* ОСНОВНОЙ ПРОФИЛЬ */} - - - - - {rank.icon} - LVL {level} - - } - /> - - - {rank.name} - - OPERATIVE - {xp.toLocaleString()} XP - + {/* ОСНОВНОЙ ПРОФИЛЬ */} + + + + + {rank.icon} + LVL {level} + + } /> - - До LVL {level + 1}: {xpToNextLevel} XP - - - + + + {rank.name} + + {displayName} + {xp.toLocaleString()} XP + + + До LVL {level + 1}: {xpToNextLevel} XP + + + - {/* Статистика */} - - - - -
- {completedCount} - Миссий -
-
-
- - - -
- {unlockedIds.length} - Достижений -
-
-
-
- - - - {/* РЕПУТАЦИЯ */} -
- - // РЕПУТАЦИЯ В АНДЕГРАУНДЕ - - - {factions.map(faction => { - const rep = getReputation(faction.id); - const isUnlocked = isFactionUnlocked(faction); - const repPercent = Math.min((rep / 200) * 100, 100); - - return ( - - - {faction.icon} -
- {faction.name} - {faction.description} + {/* Статистика */} + + + + +
+ {completedCount} + Миссий
- - {isUnlocked ? ( - <> - - - - {rep} REP - - - {faction.bonus} - - - - ) : ( - - 🔒 Требуется {faction.requiredRep} XP - - )}
- ); - })} -
-
- - - - {/* ДОСТИЖЕНИЯ */} -
- - // ДОСТИЖЕНИЯ - - {unlockedIds.length} / {achievements.length} - - - - {achievements.map(ach => { - const isUnlocked = unlockedIds.includes(ach.id); - return ( - - - {ach.icon} -
- - {ach.title} - - {ach.rarity.toUpperCase()} - - - {ach.description} + + + +
+ {unlockedAchievementIds.length} + Достижений
- {isUnlocked && ( - - ✓ РАЗБЛОКИРОВАНО - - )}
- ); - })} - -
- - - - {/* ОПАСНАЯ ЗОНА */} - - ⚠️ ОПАСНАЯ ЗОНА - - Это действие удалит ВСЕ ваши данные: прогресс, достижения, репутацию. Восстановление невозможно. - - - - +
+ + + + {/* РЕПУТАЦИЯ */} + {allFactions.length > 0 && ( +
+ + // РЕПУТАЦИЯ В АНДЕГРАУНДЕ + + + {allFactions.map(faction => { + const rep = repLookup[faction.id] || 0; + // Если свойство requiredRep нет в объекте Faction, используем 0 или проксируем логику + const isUnlocked = !faction.requiredRep || xp >= faction.requiredRep; + const repPercent = Math.min((rep / 200) * 100, 100); + + return ( + + + {faction.icon || '🏴'} +
+ {faction.name} + {faction.description} +
+
+ + {isUnlocked ? ( + <> + + + + {rep} REP + + + {faction.bonus} + + + + ) : ( + + 🔒 Требуется {faction.requiredRep} XP + + )} +
+ ); + })} +
+
+ )} + + + + {/* ДОСТИЖЕНИЯ */} +
+ + // ДОСТИЖЕНИЯ + + {unlockedAchievementIds.length} / {allAchievements.length} + + + + {allAchievements.map(ach => { + const isUnlocked = unlockedAchievementIds.includes(ach.id); + return ( + + + {ach.icon} +
+ + {ach.title} + + {ach.rarity.toUpperCase()} + + + {ach.description} +
+
+ {isUnlocked && ( + + ✓ РАЗБЛОКИРОВАНО + + )} +
+ ); + })} +
+
+ + + + {/* ОПАСНАЯ ЗОНА */} + + ⚠️ ОПАСНАЯ ЗОНА + + Это действие удалит ВСЕ ваши данные: прогресс, достижения, репутацию. Восстановление невозможно. + + + + + {/* <- ВОТ ЭТОТ Тег был пропущен */} ); }; diff --git a/frontend/src/pages/ShopPage.tsx b/frontend/src/pages/ShopPage.tsx index 30601cf..f2664d7 100644 --- a/frontend/src/pages/ShopPage.tsx +++ b/frontend/src/pages/ShopPage.tsx @@ -1,35 +1,65 @@ -import { Container, Title, SimpleGrid, Card, Text, Button, Stack, Box } from '@mantine/core'; +import { Container, Title, SimpleGrid, Card, Text, Button, Stack, Box, Loader } from '@mantine/core'; import { Link } from 'react-router-dom'; import { useState, useEffect } from 'react'; import { terminalThemes } from '../data/shopItems'; import { sounds } from '../utils/audio'; import { motion } from 'framer-motion'; +import { shopApi, type ShopItem } from '../api/shop'; +import { usersApi } from '../api/users'; const ShopPage = () => { const [xp, setXp] = useState(0); const [ownedThemes, setOwnedThemes] = useState(['classic']); const [activeTheme, setActiveTheme] = useState('classic'); + const [isLoading, setIsLoading] = useState(true); + const [purchasing, setPurchasing] = useState(null); useEffect(() => { - setXp(Number(localStorage.getItem('userXP')) || 0); - setOwnedThemes(JSON.parse(localStorage.getItem('ownedThemes') || '["classic"]')); - setActiveTheme(localStorage.getItem('activeTheme') || 'classic'); + const loadData = async () => { + try { + const user = await usersApi.getMe(); + setXp(user.totalXp); + localStorage.setItem('userXP', String(user.totalXp)); + } catch { + setXp(Number(localStorage.getItem('userXP')) || 0); + } + + try { + const myItems = await shopApi.getMyItems(); + const ownedIds = myItems.map(item => item.id); + if (!ownedIds.includes('classic')) ownedIds.unshift('classic'); + setOwnedThemes(ownedIds); + localStorage.setItem('ownedThemes', JSON.stringify(ownedIds)); + } catch { + setOwnedThemes(JSON.parse(localStorage.getItem('ownedThemes') || '["classic"]')); + } + + setActiveTheme(localStorage.getItem('activeTheme') || 'classic'); + setIsLoading(false); + }; + + loadData(); }, []); - const handleBuy = (themeId: string, price: number) => { - if (xp >= price) { - const newXP = xp - price; + const handleBuy = async (themeId: string, _price: number) => { + setPurchasing(themeId); + try { + await shopApi.purchase(themeId); + + const user = await usersApi.getMe(); + setXp(user.totalXp); + localStorage.setItem('userXP', String(user.totalXp)); + const newOwned = [...ownedThemes, themeId]; - - localStorage.setItem('userXP', String(newXP)); - localStorage.setItem('ownedThemes', JSON.stringify(newOwned)); - - setXp(newXP); setOwnedThemes(newOwned); + localStorage.setItem('ownedThemes', JSON.stringify(newOwned)); sounds.success(); - } else { + } catch (error: any) { sounds.error(); - alert('⚠️ НЕДОСТАТОЧНО XP!'); + const msg = error?.message || 'Ошибка покупки'; + alert(`⚠️ ${msg}`); + } finally { + setPurchasing(null); } }; @@ -43,6 +73,15 @@ const ShopPage = () => { window.dispatchEvent(new Event('storage')); }; + if (isLoading) { + return ( + + + Загрузка магазина... + + ); + } + return ( @@ -56,7 +95,7 @@ const ShopPage = () => { 💰 БАЛАНС: {xp} XP -
@@ -66,6 +105,7 @@ const ShopPage = () => { {terminalThemes.map((theme, index) => { const isOwned = ownedThemes.includes(theme.id); const isActive = activeTheme === theme.id; + const isPurchasing = purchasing === theme.id; return ( { overflow: 'hidden', transition: 'all 0.3s' }} - className={isActive ? 'boss-mode' : ''} + className={`cyber-card ${isActive ? 'boss-mode' : ''}`} > {/* ПРЕВЬЮ */} { variant="light" color="yellow" onClick={() => handleBuy(theme.id, theme.price)} - disabled={xp < theme.price} + disabled={xp < theme.price || isPurchasing} + loading={isPurchasing} > {xp >= theme.price ? `КУПИТЬ ЗА ${theme.price} XP` : `🔒 ${theme.price} XP`} diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index d746693..ed9bfe2 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -5,17 +5,10 @@ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Orbitron:wght@400;700;900&display=swap'); /* ═══ CSS VARIABLES ═══ */ +/* Убрано дублирование базовых цветов, оставлены только уникальные переменные */ :root { - --neon-green: #00FF41; - --neon-cyan: #00FFF9; - --neon-red: #FF4136; - --neon-yellow: #FFD700; - --neon-purple: #BF40BF; - --dark-bg: #050505; - --darker-bg: #020202; - --card-bg: #0a0a0a; --border-color: #1a1a1a; - + /* Gradients */ --cyber-gradient: linear-gradient(135deg, #00ff41 0%, #00fff9 50%, #bf40bf 100%); --danger-gradient: linear-gradient(135deg, #ff4136 0%, #ff0080 100%); @@ -29,7 +22,8 @@ box-sizing: border-box; } -html, body { +html, +body { font-family: 'JetBrains Mono', monospace; background: var(--dark-bg); color: #ffffff; @@ -42,158 +36,13 @@ body { position: relative; } -/* ═══ SCANLINES OVERLAY ═══ */ -body::before { - content: ''; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: - linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), - linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.03)); - background-size: 100% 2px, 3px 100%; - pointer-events: none; - z-index: 9999; - opacity: 0.3; -} - -/* ═══ CRT FLICKER ═══ */ -body::after { - content: ''; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: transparent; - pointer-events: none; - z-index: 9998; - animation: flicker 0.15s infinite; -} - -@keyframes flicker { - 0% { opacity: 0.27861; } - 5% { opacity: 0.34769; } - 10% { opacity: 0.23604; } - 15% { opacity: 0.90626; } - 20% { opacity: 0.18128; } - 25% { opacity: 0.83891; } - 30% { opacity: 0.65583; } - 35% { opacity: 0.67807; } - 40% { opacity: 0.26559; } - 45% { opacity: 0.84693; } - 50% { opacity: 0.96019; } - 55% { opacity: 0.08594; } - 60% { opacity: 0.20313; } - 65% { opacity: 0.71988; } - 70% { opacity: 0.53455; } - 75% { opacity: 0.37288; } - 80% { opacity: 0.71428; } - 85% { opacity: 0.70419; } - 90% { opacity: 0.7003; } - 95% { opacity: 0.36108; } - 100% { opacity: 0.24387; } -} - -/* ═══ GLITCH EFFECT ═══ */ -.glitch { - position: relative; - animation: glitch-skew 1s infinite linear alternate-reverse; -} - -.glitch::before, -.glitch::after { - content: attr(data-text); - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - overflow: hidden; -} - -.glitch::before { - left: 2px; - text-shadow: -2px 0 #ff00de; - clip: rect(44px, 450px, 56px, 0); - animation: glitch-anim 5s infinite linear alternate-reverse; -} - -.glitch::after { - left: -2px; - text-shadow: -2px 0 #00fff9, 2px 2px #ff00de; - clip: rect(44px, 450px, 56px, 0); - animation: glitch-anim2 3s infinite linear alternate-reverse; -} - -@keyframes glitch-anim { - 0% { clip: rect(31px, 9999px, 94px, 0); transform: skew(0.85deg); } - 5% { clip: rect(70px, 9999px, 71px, 0); transform: skew(0.34deg); } - 10% { clip: rect(29px, 9999px, 24px, 0); transform: skew(0.67deg); } - 15% { clip: rect(45px, 9999px, 56px, 0); transform: skew(0.12deg); } - 20% { clip: rect(62px, 9999px, 83px, 0); transform: skew(0.91deg); } - 25% { clip: rect(13px, 9999px, 34px, 0); transform: skew(0.23deg); } - 30% { clip: rect(87px, 9999px, 92px, 0); transform: skew(0.78deg); } - 35% { clip: rect(41px, 9999px, 65px, 0); transform: skew(0.45deg); } - 40% { clip: rect(23px, 9999px, 48px, 0); transform: skew(0.56deg); } - 45% { clip: rect(76px, 9999px, 89px, 0); transform: skew(0.34deg); } - 50% { clip: rect(54px, 9999px, 71px, 0); transform: skew(0.89deg); } - 55% { clip: rect(19px, 9999px, 43px, 0); transform: skew(0.12deg); } - 60% { clip: rect(68px, 9999px, 95px, 0); transform: skew(0.67deg); } - 65% { clip: rect(38px, 9999px, 52px, 0); transform: skew(0.23deg); } - 70% { clip: rect(81px, 9999px, 99px, 0); transform: skew(0.78deg); } - 75% { clip: rect(27px, 9999px, 61px, 0); transform: skew(0.45deg); } - 80% { clip: rect(49px, 9999px, 74px, 0); transform: skew(0.91deg); } - 85% { clip: rect(15px, 9999px, 36px, 0); transform: skew(0.34deg); } - 90% { clip: rect(72px, 9999px, 88px, 0); transform: skew(0.56deg); } - 95% { clip: rect(34px, 9999px, 59px, 0); transform: skew(0.12deg); } - 100% { clip: rect(91px, 9999px, 100px, 0); transform: skew(0.67deg); } -} - -@keyframes glitch-anim2 { - 0% { clip: rect(65px, 9999px, 100px, 0); transform: skew(0.78deg); } - 5% { clip: rect(23px, 9999px, 54px, 0); transform: skew(0.23deg); } - 10% { clip: rect(81px, 9999px, 99px, 0); transform: skew(0.91deg); } - 15% { clip: rect(41px, 9999px, 72px, 0); transform: skew(0.12deg); } - 20% { clip: rect(12px, 9999px, 37px, 0); transform: skew(0.56deg); } - 25% { clip: rect(76px, 9999px, 91px, 0); transform: skew(0.34deg); } - 30% { clip: rect(34px, 9999px, 58px, 0); transform: skew(0.89deg); } - 35% { clip: rect(89px, 9999px, 100px, 0); transform: skew(0.45deg); } - 40% { clip: rect(17px, 9999px, 46px, 0); transform: skew(0.67deg); } - 45% { clip: rect(53px, 9999px, 79px, 0); transform: skew(0.23deg); } - 50% { clip: rect(28px, 9999px, 63px, 0); transform: skew(0.78deg); } - 55% { clip: rect(71px, 9999px, 94px, 0); transform: skew(0.12deg); } - 60% { clip: rect(45px, 9999px, 68px, 0); transform: skew(0.91deg); } - 65% { clip: rect(82px, 9999px, 100px, 0); transform: skew(0.34deg); } - 70% { clip: rect(19px, 9999px, 52px, 0); transform: skew(0.56deg); } - 75% { clip: rect(61px, 9999px, 85px, 0); transform: skew(0.45deg); } - 80% { clip: rect(38px, 9999px, 71px, 0); transform: skew(0.89deg); } - 85% { clip: rect(93px, 9999px, 100px, 0); transform: skew(0.67deg); } - 90% { clip: rect(14px, 9999px, 41px, 0); transform: skew(0.23deg); } - 95% { clip: rect(57px, 9999px, 83px, 0); transform: skew(0.78deg); } - 100% { clip: rect(32px, 9999px, 67px, 0); transform: skew(0.12deg); } -} - -@keyframes glitch-skew { - 0% { transform: skew(-0.5deg); } - 10% { transform: skew(0.5deg); } - 20% { transform: skew(-0.3deg); } - 30% { transform: skew(0.8deg); } - 40% { transform: skew(-0.2deg); } - 50% { transform: skew(0.4deg); } - 60% { transform: skew(-0.6deg); } - 70% { transform: skew(0.3deg); } - 80% { transform: skew(-0.4deg); } - 90% { transform: skew(0.7deg); } - 100% { transform: skew(-0.5deg); } -} +/* Блоки SCANLINES OVERLAY, CRT FLICKER и GLITCH EFFECT полностью удалены, + так как они корректно обрабатываются в index.html */ /* ═══ NEON GLOW EFFECT ═══ */ .neon-glow { color: var(--neon-green); - text-shadow: + text-shadow: 0 0 5px var(--neon-green), 0 0 10px var(--neon-green), 0 0 20px var(--neon-green), @@ -204,14 +53,15 @@ body::after { @keyframes neon-pulse { 0% { - text-shadow: + text-shadow: 0 0 5px var(--neon-green), 0 0 10px var(--neon-green), 0 0 20px var(--neon-green), 0 0 40px var(--neon-green); } + 100% { - text-shadow: + text-shadow: 0 0 10px var(--neon-green), 0 0 20px var(--neon-green), 0 0 40px var(--neon-green), @@ -236,12 +86,10 @@ body::after { left: -100%; width: 100%; height: 100%; - background: linear-gradient( - 90deg, - transparent, - rgba(0, 255, 65, 0.1), - transparent - ); + background: linear-gradient(90deg, + transparent, + rgba(0, 255, 65, 0.1), + transparent); transition: left 0.5s; } @@ -251,7 +99,7 @@ body::after { .cyber-card:hover { border-color: var(--neon-green); - box-shadow: + box-shadow: 0 0 20px rgba(0, 255, 65, 0.2), inset 0 0 20px rgba(0, 255, 65, 0.05); transform: translateY(-5px) scale(1.02); @@ -259,14 +107,33 @@ body::after { /* ═══ SHAKE ANIMATION ═══ */ .shake-screen { - animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both; + animation: shake 0.5s cubic-bezier(.36, .07, .19, .97) both; + border: 2px solid rgba(255, 65, 54, 0.5); + /* Red flash on error */ } @keyframes shake { - 10%, 90% { transform: translate3d(-1px, 0, 0); } - 20%, 80% { transform: translate3d(2px, 0, 0); } - 30%, 50%, 70% { transform: translate3d(-4px, 0, 0); } - 40%, 60% { transform: translate3d(4px, 0, 0); } + + 10%, + 90% { + transform: translate3d(-4px, 0, 0) rotate(-1deg); + } + + 20%, + 80% { + transform: translate3d(6px, 0, 0) rotate(2deg); + } + + 30%, + 50%, + 70% { + transform: translate3d(-8px, 0, 0) rotate(-2deg); + } + + 40%, + 60% { + transform: translate3d(8px, 0, 0) rotate(1deg); + } } /* ═══ BOSS MODE ═══ */ @@ -275,14 +142,17 @@ body::after { } @keyframes boss-pulse { - 0%, 100% { - box-shadow: + + 0%, + 100% { + box-shadow: 0 0 10px rgba(255, 65, 54, 0.3), 0 0 20px rgba(255, 65, 54, 0.2), inset 0 0 10px rgba(255, 65, 54, 0.1); } - 50% { - box-shadow: + + 50% { + box-shadow: 0 0 20px rgba(255, 65, 54, 0.6), 0 0 40px rgba(255, 65, 54, 0.4), 0 0 60px rgba(255, 65, 54, 0.2), @@ -294,12 +164,6 @@ body::after { --neon-green: #FF4136; } -[data-boss-mode="true"] body::before { - background: - linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(255, 0, 0, 0.1) 50%), - linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(255, 0, 0, 0.02), rgba(255, 0, 0, 0.06)); -} - /* ═══ TERMINAL CURSOR ═══ */ .terminal-cursor::after { content: '█'; @@ -308,8 +172,16 @@ body::after { } @keyframes cursor-blink { - 0%, 50% { opacity: 1; } - 51%, 100% { opacity: 0; } + + 0%, + 50% { + opacity: 1; + } + + 51%, + 100% { + opacity: 0; + } } /* ═══ TYPING ANIMATION ═══ */ @@ -317,19 +189,31 @@ body::after { overflow: hidden; border-right: 2px solid var(--neon-green); white-space: nowrap; - animation: + animation: typing 3.5s steps(40, end), blink-caret 0.75s step-end infinite; } @keyframes typing { - from { width: 0; } - to { width: 100%; } + from { + width: 0; + } + + to { + width: 100%; + } } @keyframes blink-caret { - from, to { border-color: transparent; } - 50% { border-color: var(--neon-green); } + + from, + to { + border-color: transparent; + } + + 50% { + border-color: var(--neon-green); + } } /* ═══ HOLOGRAM EFFECT ═══ */ @@ -345,30 +229,31 @@ body::after { left: 0; width: 100%; height: 100%; - background: repeating-linear-gradient( - 0deg, - rgba(0, 255, 255, 0.03), - rgba(0, 255, 255, 0.03) 1px, - transparent 1px, - transparent 2px - ); + background: repeating-linear-gradient(0deg, + rgba(0, 255, 255, 0.03), + rgba(0, 255, 255, 0.03) 1px, + transparent 1px, + transparent 2px); animation: hologram-scan 2s linear infinite; pointer-events: none; } @keyframes hologram-scan { - 0% { transform: translateY(-100%); } - 100% { transform: translateY(100%); } + 0% { + transform: translateY(-100%); + } + + 100% { + transform: translateY(100%); + } } /* ═══ DATA STREAM ═══ */ .data-stream { - background: linear-gradient( - 90deg, - transparent 0%, - var(--neon-green) 50%, - transparent 100% - ); + background: linear-gradient(90deg, + transparent 0%, + var(--neon-green) 50%, + transparent 100%); background-size: 200% 100%; animation: data-flow 2s linear infinite; -webkit-background-clip: text; @@ -376,8 +261,13 @@ body::after { } @keyframes data-flow { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } + 0% { + background-position: 200% 0; + } + + 100% { + background-position: -200% 0; + } } /* ═══ ELECTRIC BORDER ═══ */ @@ -412,64 +302,16 @@ body::after { } @keyframes electric-rotate { - 0% { filter: hue-rotate(0deg); } - 100% { filter: hue-rotate(360deg); } -} - -/* ═══ BUTTONS ═══ */ -button { - position: relative; - overflow: hidden; - transition: all 0.3s ease; -} - -button::before { - content: ''; - position: absolute; - top: 50%; - left: 50%; - width: 0; - height: 0; - background: rgba(255, 255, 255, 0.1); - border-radius: 50%; - transform: translate(-50%, -50%); - transition: width 0.6s, height 0.6s; -} - -button:hover::before { - width: 300px; - height: 300px; -} - -button:hover { - transform: translateY(-2px); - box-shadow: 0 0 20px rgba(0, 255, 65, 0.4); -} - -button:active { - transform: translateY(0); -} - -/* ═══ SCROLLBAR ═══ */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -::-webkit-scrollbar-track { - background: var(--darker-bg); - border-radius: 4px; -} + 0% { + filter: hue-rotate(0deg); + } -::-webkit-scrollbar-thumb { - background: var(--neon-green); - border-radius: 4px; - box-shadow: 0 0 10px var(--neon-green); + 100% { + filter: hue-rotate(360deg); + } } -::-webkit-scrollbar-thumb:hover { - background: #00cc33; -} +/* Блоки BUTTONS и SCROLLBAR удалены из-за конфликтов с index.html */ /* ═══ SELECTION ═══ */ ::selection { @@ -478,7 +320,8 @@ button:active { } /* ═══ CODE BLOCKS ═══ */ -code, pre { +code, +pre { font-family: 'JetBrains Mono', monospace !important; background: var(--darker-bg); border: 1px solid var(--border-color); @@ -505,8 +348,15 @@ code, pre { } @keyframes progress-glow { - 0%, 100% { box-shadow: 0 0 5px var(--neon-green); } - 50% { box-shadow: 0 0 20px var(--neon-green); } + + 0%, + 100% { + box-shadow: 0 0 5px var(--neon-green); + } + + 50% { + box-shadow: 0 0 20px var(--neon-green); + } } /* ═══ BADGE GLOW ═══ */ @@ -515,8 +365,15 @@ code, pre { } @keyframes badge-pulse { - 0%, 100% { box-shadow: 0 0 5px currentColor; } - 50% { box-shadow: 0 0 15px currentColor, 0 0 25px currentColor; } + + 0%, + 100% { + box-shadow: 0 0 5px currentColor; + } + + 50% { + box-shadow: 0 0 15px currentColor, 0 0 25px currentColor; + } } /* ═══ FADE IN ANIMATION ═══ */ @@ -525,13 +382,14 @@ code, pre { } @keyframes fadeIn { - from { - opacity: 0; - transform: translateY(20px); + from { + opacity: 0; + transform: translateY(20px); } - to { - opacity: 1; - transform: translateY(0); + + to { + opacity: 1; + transform: translateY(0); } } @@ -541,13 +399,14 @@ code, pre { } @keyframes slideInLeft { - from { - opacity: 0; - transform: translateX(-50px); + from { + opacity: 0; + transform: translateX(-50px); } - to { - opacity: 1; - transform: translateX(0); + + to { + opacity: 1; + transform: translateX(0); } } @@ -556,13 +415,14 @@ code, pre { } @keyframes slideInRight { - from { - opacity: 0; - transform: translateX(50px); + from { + opacity: 0; + transform: translateX(50px); } - to { - opacity: 1; - transform: translateX(0); + + to { + opacity: 1; + transform: translateX(0); } } @@ -572,8 +432,13 @@ code, pre { } @keyframes rotateGlow { - 0% { filter: hue-rotate(0deg) drop-shadow(0 0 10px var(--neon-green)); } - 100% { filter: hue-rotate(360deg) drop-shadow(0 0 10px var(--neon-green)); } + 0% { + filter: hue-rotate(0deg) drop-shadow(0 0 10px var(--neon-green)); + } + + 100% { + filter: hue-rotate(360deg) drop-shadow(0 0 10px var(--neon-green)); + } } /* ═══ PULSE ANIMATION ═══ */ @@ -582,8 +447,15 @@ code, pre { } @keyframes pulse { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(1.05); } + + 0%, + 100% { + transform: scale(1); + } + + 50% { + transform: scale(1.05); + } } /* ═══ FLOAT ANIMATION ═══ */ @@ -592,8 +464,15 @@ code, pre { } @keyframes float { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-10px); } + + 0%, + 100% { + transform: translateY(0); + } + + 50% { + transform: translateY(-10px); + } } /* ═══ WARNING FLASH ═══ */ @@ -602,26 +481,63 @@ code, pre { } @keyframes warningFlash { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } -} -/* ═══ RESPONSIVE ═══ */ -@media (max-width: 768px) { - .glitch::before, - .glitch::after { - display: none; + 0%, + 100% { + opacity: 1; } - - body::before { - opacity: 0.15; + + 50% { + opacity: 0.5; } } -/* ═══ PRINT STYLES ═══ */ -@media print { - body::before, - body::after { - display: none; - } +/* Блоки RESPONSIVE и PRINT STYLES удалены, так как они ссылались только на удаленные выше багованные элементы (glitch, body::before, body::after) */ + +/* ═══ CUSTOM CURSOR ═══ */ +/* Кастомный курсор-прицел вместо системного */ +*, *::before, *::after { + cursor: none !important; +} + +.custom-cursor { + position: fixed; + width: 20px; + height: 20px; + border: 2px solid var(--neon-green, #00ff41); + border-radius: 50%; + pointer-events: none; + z-index: 99999; + transform: translate(-50%, -50%); + transition: width 0.15s, height 0.15s, border-color 0.15s, background 0.15s; + mix-blend-mode: difference; + box-shadow: 0 0 10px rgba(0, 255, 65, 0.5); +} + +.custom-cursor::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 4px; + height: 4px; + background: var(--neon-green, #00ff41); + border-radius: 50%; + transform: translate(-50%, -50%); + box-shadow: 0 0 6px var(--neon-green, #00ff41); +} + +/* Курсор увеличивается при наведении на кликабельные элементы */ +.custom-cursor.cursor-hover { + width: 40px; + height: 40px; + border-color: #00fff9; + background: rgba(0, 255, 65, 0.1); + box-shadow: 0 0 20px rgba(0, 255, 65, 0.4); +} + +.custom-cursor.cursor-click { + width: 16px; + height: 16px; + background: rgba(0, 255, 65, 0.3); } \ No newline at end of file diff --git a/frontend/src/utils/audio.ts b/frontend/src/utils/audio.ts index 4115369..1349664 100644 --- a/frontend/src/utils/audio.ts +++ b/frontend/src/utils/audio.ts @@ -1,7 +1,28 @@ // Утилита для генерации "компьютерного" звука через код (Web Audio API) +// Один общий AudioContext — браузер ограничивает количество (~6-8 макс.) +let sharedAudioCtx: AudioContext | null = null; + +const getAudioContext = (): AudioContext | null => { + try { + if (!sharedAudioCtx) { + sharedAudioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + } + // Возобновляем контекст если он приостановлен (autoplay policy) + if (sharedAudioCtx.state === 'suspended') { + sharedAudioCtx.resume(); + } + return sharedAudioCtx; + } catch (e) { + console.error("Audio context error:", e); + return null; + } +}; + const playSynthSound = (freq: number, type: OscillatorType, duration: number) => { try { - const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + const audioCtx = getAudioContext(); + if (!audioCtx) return; + const oscillator = audioCtx.createOscillator(); const gainNode = audioCtx.createGain(); @@ -42,7 +63,9 @@ export const sounds = { // Исправленная сирена siren: () => { try { - const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); + const audioCtx = getAudioContext(); + if (!audioCtx) return; + const oscillator = audioCtx.createOscillator(); const gainNode = audioCtx.createGain(); diff --git a/frontend/src/utils/workerScript.ts b/frontend/src/utils/workerScript.ts index 00583fa..7f589b4 100644 --- a/frontend/src/utils/workerScript.ts +++ b/frontend/src/utils/workerScript.ts @@ -7,7 +7,7 @@ async function loadPyodide() { try { ctx.postMessage({ type: 'LOG', message: 'Worker started loading Pyodide' }); - // Fallback CDNs + // Резервные CDN для загрузки Pyodide const cdns = [ 'https://cdn.jsdelivr.net/pyodide/v0.24.1/full/pyodide.js', 'https://unpkg.com/pyodide@0.24.1/pyodide.js', @@ -18,10 +18,17 @@ async function loadPyodide() { for (const cdn of cdns) { try { ctx.postMessage({ type: 'LOG', message: 'Trying to load from ' + cdn }); + // Use a timeout for importScripts to fail faster on bad connections + // Note: importScripts is synchronous, but we can't easily timeout it. + // We assume valid URLs. importScripts(cdn); - loaded = true; - ctx.postMessage({ type: 'LOG', message: 'Successfully loaded script from ' + cdn }); - break; + + // Basic check if loaded + if (self.loadPyodide) { + loaded = true; + ctx.postMessage({ type: 'LOG', message: 'Successfully loaded script from ' + cdn }); + break; + } } catch (e) { ctx.postMessage({ type: 'LOG', message: 'Failed to load from ' + cdn + ': ' + e }); } @@ -39,6 +46,67 @@ async function loadPyodide() { pyodide = await self.loadPyodide(); ctx.postMessage({ type: 'LOG', message: 'Pyodide initialized' }); + // Prepare Python Debugger Class + await pyodide.runPythonAsync(\` +import sys +import json + +class TraceRunner: + def __init__(self): + self.trace_data = [] + self.output_buffer = [] + + def trace_calls(self, frame, event, arg): + if event != 'line': + return self.trace_calls + + # Capture locals - simplified for JSON serialization + locals_snapshot = {} + for k, v in frame.f_locals.items(): + if k.startswith('__'): continue + try: + # Basic types only + if isinstance(v, (int, float, str, bool, list, dict, set, tuple, type(None))): + locals_snapshot[k] = str(v) + else: + locals_snapshot[k] = f"<{type(v).__name__}>" + except: + locals_snapshot[k] = "" + + self.trace_data.append({ + 'line': frame.f_lineno, + 'locals': locals_snapshot, + 'stdout': "".join(self.output_buffer) + }) + return self.trace_calls + + def run_with_trace(self, code): + self.trace_data = [] + self.output_buffer = [] + + # Redirect stdout + class CapturingStdout: + def __init__(self, buffer): + self.buffer = buffer + def write(self, text): + self.buffer.append(text) + sys.__stdout__.write(text) # Also print to real stdout + def flush(self): + sys.__stdout__.flush() + + old_stdout = sys.stdout + sys.stdout = CapturingStdout(self.output_buffer) + + try: + sys.settrace(self.trace_calls) + exec(code, {}) + finally: + sys.settrace(None) + sys.stdout = old_stdout + + return self.trace_data +\`); + ctx.postMessage({ type: 'READY' }); } catch (error) { ctx.postMessage({ type: 'ERROR', error: error.message || String(error) }); @@ -73,6 +141,41 @@ ctx.onmessage = async (event) => { } catch (error) { ctx.postMessage({ type: 'ERROR', error: error.message, id }); } + } else if (type === 'RUN_DEBUG') { + if (!pyodide) { + ctx.postMessage({ type: 'ERROR', error: 'Python environment not ready', id }); + return; + } + + try { + pyodide.setStdout({ + batched: (msg) => { + ctx.postMessage({ type: 'OUTPUT', output: msg, id }); + } + }); + + // Use the TraceRunner we defined earlier + // We need to pass the code as a string properly escaped + // Easier way: set a global variable with the code + pyodide.globals.set("user_code_to_debug", code); + + const traceRunner = pyodide.runPython(\` +runner = TraceRunner() +data = runner.run_with_trace(user_code_to_debug) +import json +json.dumps(data) +\`); + + const traceData = JSON.parse(traceRunner); + + ctx.postMessage({ + type: 'DEBUG_TRACE', + trace: traceData, + id + }); + } catch (error) { + ctx.postMessage({ type: 'ERROR', error: error.message, id }); + } } }; `; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index 794fb60..774f073 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -21,9 +21,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - - /* Добавь или проверь это */ - "types": ["vite/client"] + "types": ["vite/client", "vitest/globals"] }, "include": ["src"] } \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 2dea53a..bbc9125 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,18 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -// https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:5001', + changeOrigin: true, + } + } + } }) \ No newline at end of file