From f58782774b443685597a3b94e506466f3a2f2aaf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 19:10:10 +0000 Subject: [PATCH] feat: complete mealplanner app (backend + frontend + deployment) .NET 8 backend with Zitadel JWT auth, TheMealDB integration, weekly meal plan generation, shopping list aggregation. Vue 3 + Tailwind 4 frontend with dark emerald theme, manual OIDC PKCE auth, all views implemented. Multi-stage Dockerfile with nginx reverse proxy. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 25 ++ Dockerfile | 33 ++ backend/Controllers/MealPlanController.cs | 98 +++++ backend/Controllers/RecipeController.cs | 74 ++++ backend/Controllers/SettingsController.cs | 42 ++ backend/Controllers/ShoppingListController.cs | 45 +++ backend/Data/AppDbContext.cs | 71 ++++ .../20260414190645_InitialCreate.Designer.cs | 225 +++++++++++ .../20260414190645_InitialCreate.cs | 180 +++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 222 +++++++++++ backend/Models/CheckedShoppingItem.cs | 10 + backend/Models/MealPlan.cs | 11 + backend/Models/MealPlanEntry.cs | 12 + backend/Models/Recipe.cs | 21 + backend/Models/RecipeIngredient.cs | 13 + backend/Models/ShoppingItem.cs | 10 + backend/Models/UserSettings.cs | 7 + backend/Program.cs | 70 ++++ backend/Properties/launchSettings.json | 13 + backend/Services/MealPlanService.cs | 180 +++++++++ backend/Services/RecipeService.cs | 128 ++++++ backend/Services/ShoppingListService.cs | 91 +++++ backend/Services/TheMealDbClient.cs | 134 +++++++ backend/appsettings.Development.json | 6 + backend/appsettings.json | 16 + backend/backend.csproj | 16 + docker-compose.yml | 13 + .../specs/2026-04-14-mealplanner-plan.md | 70 ++++ frontend/bun.lock | 315 +++++++++++++++ frontend/index.html | 13 + frontend/package.json | 24 ++ frontend/src/App.vue | 234 +++++++++++ frontend/src/auth.ts | 185 +++++++++ frontend/src/components/MealCard.vue | 82 ++++ frontend/src/components/RecipeDetail.vue | 78 ++++ frontend/src/components/ShoppingItem.vue | 42 ++ frontend/src/components/SwapModal.vue | 88 +++++ frontend/src/composables/useApi.ts | 24 ++ frontend/src/env.d.ts | 10 + frontend/src/main.ts | 11 + frontend/src/router.ts | 16 + frontend/src/stores/mealPlan.ts | 232 +++++++++++ frontend/src/theme.css | 11 + frontend/src/views/RecipesView.vue | 369 ++++++++++++++++++ frontend/src/views/SettingsView.vue | 103 +++++ frontend/src/views/ShoppingListView.vue | 147 +++++++ frontend/src/views/WeekPlanView.vue | 164 ++++++++ frontend/tsconfig.json | 25 ++ frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 12 + nginx.conf | 30 ++ 51 files changed, 4061 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 backend/Controllers/MealPlanController.cs create mode 100644 backend/Controllers/RecipeController.cs create mode 100644 backend/Controllers/SettingsController.cs create mode 100644 backend/Controllers/ShoppingListController.cs create mode 100644 backend/Data/AppDbContext.cs create mode 100644 backend/Migrations/20260414190645_InitialCreate.Designer.cs create mode 100644 backend/Migrations/20260414190645_InitialCreate.cs create mode 100644 backend/Migrations/AppDbContextModelSnapshot.cs create mode 100644 backend/Models/CheckedShoppingItem.cs create mode 100644 backend/Models/MealPlan.cs create mode 100644 backend/Models/MealPlanEntry.cs create mode 100644 backend/Models/Recipe.cs create mode 100644 backend/Models/RecipeIngredient.cs create mode 100644 backend/Models/ShoppingItem.cs create mode 100644 backend/Models/UserSettings.cs create mode 100644 backend/Program.cs create mode 100644 backend/Properties/launchSettings.json create mode 100644 backend/Services/MealPlanService.cs create mode 100644 backend/Services/RecipeService.cs create mode 100644 backend/Services/ShoppingListService.cs create mode 100644 backend/Services/TheMealDbClient.cs create mode 100644 backend/appsettings.Development.json create mode 100644 backend/appsettings.json create mode 100644 backend/backend.csproj create mode 100644 docker-compose.yml create mode 100644 docs/superpowers/specs/2026-04-14-mealplanner-plan.md create mode 100644 frontend/bun.lock create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/auth.ts create mode 100644 frontend/src/components/MealCard.vue create mode 100644 frontend/src/components/RecipeDetail.vue create mode 100644 frontend/src/components/ShoppingItem.vue create mode 100644 frontend/src/components/SwapModal.vue create mode 100644 frontend/src/composables/useApi.ts create mode 100644 frontend/src/env.d.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router.ts create mode 100644 frontend/src/stores/mealPlan.ts create mode 100644 frontend/src/theme.css create mode 100644 frontend/src/views/RecipesView.vue create mode 100644 frontend/src/views/SettingsView.vue create mode 100644 frontend/src/views/ShoppingListView.vue create mode 100644 frontend/src/views/WeekPlanView.vue create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 nginx.conf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc3cb1f --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# .NET +bin/ +obj/ +*.user +*.suo +appsettings.*.local.json + +# Node/Bun +node_modules/ +frontend/dist/ +frontend/.vite/ + +# IDE +.vs/ +.vscode/ +.idea/ +*.swp + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5193cff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Stage 1: Build frontend +FROM oven/bun:1 AS frontend-build +WORKDIR /app +COPY frontend/package.json frontend/bun.lock* ./ +RUN bun install --frozen-lockfile || bun install +COPY frontend/ . +RUN bun run build + +# Stage 2: Build backend +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS backend-build +WORKDIR /src +COPY backend/*.csproj . +RUN dotnet restore +COPY backend/ . +RUN dotnet publish -c Release -o /app + +# Stage 3: Runtime +FROM mcr.microsoft.com/dotnet/aspnet:8.0 +RUN apt-get update && apt-get install -y nginx curl && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY --from=backend-build /app . +COPY --from=frontend-build /app/dist /var/www/html + +RUN rm /etc/nginx/sites-enabled/default +COPY nginx.conf /etc/nginx/sites-enabled/default + +RUN printf '#!/bin/bash\nnginx &\ncd /app && dotnet backend.dll\n' > /start.sh && chmod +x /start.sh + +EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=30s \ + CMD ["curl", "-f", "http://localhost:80/health"] +CMD ["/bin/bash", "/start.sh"] diff --git a/backend/Controllers/MealPlanController.cs b/backend/Controllers/MealPlanController.cs new file mode 100644 index 0000000..83442a6 --- /dev/null +++ b/backend/Controllers/MealPlanController.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MealPlanner.Services; + +namespace MealPlanner.Controllers; + +[ApiController] +[Route("api/mealplan")] +[Authorize] +public class MealPlanController(MealPlanService mealPlanService) : ControllerBase +{ + private string UserId => User.FindFirst("sub")?.Value ?? throw new UnauthorizedAccessException(); + + public record GenerateRequest(string? WeekStart); + public record SwapRequest(Guid RecipeId); + + [HttpPost("generate")] + public async Task Generate([FromBody] GenerateRequest? body) + { + DateOnly weekStart; + + if (body?.WeekStart is not null && DateOnly.TryParse(body.WeekStart, out var parsed)) + { + weekStart = MealPlanService.GetWeekStart(parsed); + } + else + { + weekStart = MealPlanService.GetWeekStart(DateOnly.FromDateTime(DateTime.UtcNow)); + } + + var plan = await mealPlanService.GenerateWeekPlanAsync(UserId, weekStart); + return Ok(plan); + } + + [HttpGet("current")] + public async Task GetCurrent() + { + var plan = await mealPlanService.GetCurrentPlanAsync(UserId); + if (plan is null) return NotFound(); + return Ok(plan); + } + + [HttpGet("{weekStart}")] + public async Task GetByWeek(string weekStart) + { + if (!DateOnly.TryParse(weekStart, out var date)) return BadRequest("Invalid date format."); + var plan = await mealPlanService.GetPlanAsync(UserId, date); + if (plan is null) return NotFound(); + return Ok(plan); + } + + [HttpPut("{id:guid}/entry/{date}")] + public async Task SwapEntry(Guid id, string date, [FromBody] SwapRequest body) + { + if (!DateOnly.TryParse(date, out var parsedDate)) return BadRequest("Invalid date format."); + + // Find entry by plan id + date + var plan = await mealPlanService.GetPlanAsync(UserId, MealPlanService.GetWeekStart(parsedDate)); + if (plan is null || plan.Id != id) return NotFound(); + + var entry = plan.Entries.FirstOrDefault(e => e.Date == parsedDate); + if (entry is null) return NotFound(); + + try + { + var updated = await mealPlanService.SwapEntryAsync(entry.Id, body.RecipeId, UserId); + if (updated is null) return NotFound(); + return Ok(updated); + } + catch (UnauthorizedAccessException) + { + return Forbid(); + } + } + + [HttpPost("{id:guid}/entry/{date}/reroll")] + public async Task RerollEntry(Guid id, string date) + { + if (!DateOnly.TryParse(date, out var parsedDate)) return BadRequest("Invalid date format."); + + var plan = await mealPlanService.GetPlanAsync(UserId, MealPlanService.GetWeekStart(parsedDate)); + if (plan is null || plan.Id != id) return NotFound(); + + var entry = plan.Entries.FirstOrDefault(e => e.Date == parsedDate); + if (entry is null) return NotFound(); + + try + { + var updated = await mealPlanService.RerollEntryAsync(entry.Id, UserId); + if (updated is null) return StatusCode(503, "Could not fetch a new recipe. Try again."); + return Ok(updated); + } + catch (UnauthorizedAccessException) + { + return Forbid(); + } + } +} diff --git a/backend/Controllers/RecipeController.cs b/backend/Controllers/RecipeController.cs new file mode 100644 index 0000000..12ff8b4 --- /dev/null +++ b/backend/Controllers/RecipeController.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MealPlanner.Models; +using MealPlanner.Services; + +namespace MealPlanner.Controllers; + +[ApiController] +[Route("api/recipes")] +[Authorize] +public class RecipeController(RecipeService recipeService) : ControllerBase +{ + private string UserId => User.FindFirst("sub")?.Value ?? throw new UnauthorizedAccessException(); + + [HttpGet] + public async Task GetOwn() + { + var recipes = await recipeService.GetOwnRecipesAsync(UserId); + return Ok(recipes); + } + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id) + { + var recipe = await recipeService.GetByIdOrFetchAsync(id); + if (recipe is null) return NotFound(); + return Ok(recipe); + } + + [HttpPost] + public async Task Create([FromBody] Recipe recipe) + { + var created = await recipeService.CreateAsync(UserId, recipe); + return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); + } + + [HttpPut("{id:guid}")] + public async Task Update(Guid id, [FromBody] Recipe recipe) + { + try + { + var updated = await recipeService.UpdateAsync(id, UserId, recipe); + if (updated is null) return NotFound(); + return Ok(updated); + } + catch (UnauthorizedAccessException) + { + return Forbid(); + } + } + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id) + { + try + { + var deleted = await recipeService.DeleteAsync(id, UserId); + if (!deleted) return NotFound(); + return NoContent(); + } + catch (UnauthorizedAccessException) + { + return Forbid(); + } + } + + [HttpGet("search")] + public async Task Search([FromQuery] string q) + { + if (string.IsNullOrWhiteSpace(q)) return BadRequest("Query parameter 'q' is required."); + var results = await recipeService.SearchAsync(q, UserId); + return Ok(results); + } +} diff --git a/backend/Controllers/SettingsController.cs b/backend/Controllers/SettingsController.cs new file mode 100644 index 0000000..8332bc9 --- /dev/null +++ b/backend/Controllers/SettingsController.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MealPlanner.Data; +using MealPlanner.Models; + +namespace MealPlanner.Controllers; + +[ApiController] +[Route("api/settings")] +[Authorize] +public class SettingsController(AppDbContext db) : ControllerBase +{ + private string UserId => User.FindFirst("sub")?.Value ?? throw new UnauthorizedAccessException(); + + [HttpGet] + public async Task Get() + { + var settings = await db.UserSettings.FindAsync(UserId); + if (settings is null) + { + settings = new UserSettings { UserId = UserId, HouseholdSize = 2 }; + db.UserSettings.Add(settings); + await db.SaveChangesAsync(); + } + return Ok(settings); + } + + [HttpPut] + public async Task Update([FromBody] UserSettings updated) + { + var settings = await db.UserSettings.FindAsync(UserId); + if (settings is null) + { + settings = new UserSettings { UserId = UserId }; + db.UserSettings.Add(settings); + } + + settings.HouseholdSize = updated.HouseholdSize; + await db.SaveChangesAsync(); + return Ok(settings); + } +} diff --git a/backend/Controllers/ShoppingListController.cs b/backend/Controllers/ShoppingListController.cs new file mode 100644 index 0000000..99974a8 --- /dev/null +++ b/backend/Controllers/ShoppingListController.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using MealPlanner.Services; + +namespace MealPlanner.Controllers; + +[ApiController] +[Route("api/shoppinglist")] +[Authorize] +public class ShoppingListController(ShoppingListService shoppingListService) : ControllerBase +{ + private string UserId => User.FindFirst("sub")?.Value ?? throw new UnauthorizedAccessException(); + + [HttpGet("{mealPlanId:guid}")] + public async Task GetList(Guid mealPlanId) + { + try + { + var items = await shoppingListService.GetShoppingListAsync(mealPlanId, UserId); + return Ok(items); + } + catch (UnauthorizedAccessException) + { + return Forbid(); + } + } + + [HttpPut("{mealPlanId:guid}/check/{itemName}")] + public async Task ToggleCheck(Guid mealPlanId, string itemName) + { + try + { + var result = await shoppingListService.ToggleCheckAsync(mealPlanId, itemName, UserId); + return Ok(result); + } + catch (KeyNotFoundException ex) + { + return NotFound(ex.Message); + } + catch (UnauthorizedAccessException) + { + return Forbid(); + } + } +} diff --git a/backend/Data/AppDbContext.cs b/backend/Data/AppDbContext.cs new file mode 100644 index 0000000..4c5472a --- /dev/null +++ b/backend/Data/AppDbContext.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore; +using MealPlanner.Models; + +namespace MealPlanner.Data; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Recipes => Set(); + public DbSet RecipeIngredients => Set(); + public DbSet MealPlans => Set(); + public DbSet MealPlanEntries => Set(); + public DbSet UserSettings => Set(); + public DbSet CheckedShoppingItems => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Recipe + modelBuilder.Entity(e => + { + e.HasKey(r => r.Id); + e.HasIndex(r => r.UserId); + e.HasIndex(r => r.ExternalId); + e.HasMany(r => r.Ingredients) + .WithOne(i => i.Recipe) + .HasForeignKey(i => i.RecipeId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // RecipeIngredient + modelBuilder.Entity(e => + { + e.HasKey(i => i.Id); + }); + + // MealPlan + modelBuilder.Entity(e => + { + e.HasKey(p => p.Id); + e.HasIndex(p => new { p.UserId, p.WeekStart }).IsUnique(); + e.HasMany(p => p.Entries) + .WithOne(en => en.MealPlan) + .HasForeignKey(en => en.MealPlanId) + .OnDelete(DeleteBehavior.Cascade); + }); + + // MealPlanEntry + modelBuilder.Entity(e => + { + e.HasKey(en => en.Id); + e.HasOne(en => en.Recipe) + .WithMany() + .HasForeignKey(en => en.RecipeId) + .OnDelete(DeleteBehavior.Restrict); + }); + + // UserSettings + modelBuilder.Entity(e => + { + e.HasKey(s => s.UserId); + }); + + // CheckedShoppingItem + modelBuilder.Entity(e => + { + e.HasKey(c => c.Id); + e.HasIndex(c => new { c.MealPlanId, c.UserId, c.ItemName }); + }); + } +} diff --git a/backend/Migrations/20260414190645_InitialCreate.Designer.cs b/backend/Migrations/20260414190645_InitialCreate.Designer.cs new file mode 100644 index 0000000..8b52be6 --- /dev/null +++ b/backend/Migrations/20260414190645_InitialCreate.Designer.cs @@ -0,0 +1,225 @@ +// +using System; +using MealPlanner.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 backend.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260414190645_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.26") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MealPlanner.Models.CheckedShoppingItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsChecked") + .HasColumnType("boolean"); + + b.Property("ItemName") + .IsRequired() + .HasColumnType("text"); + + b.Property("MealPlanId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MealPlanId", "UserId", "ItemName"); + + b.ToTable("CheckedShoppingItems"); + }); + + modelBuilder.Entity("MealPlanner.Models.MealPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("WeekStart") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "WeekStart") + .IsUnique(); + + b.ToTable("MealPlans"); + }); + + modelBuilder.Entity("MealPlanner.Models.MealPlanEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("MealPlanId") + .HasColumnType("uuid"); + + b.Property("RecipeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MealPlanId"); + + b.HasIndex("RecipeId"); + + b.ToTable("MealPlanEntries"); + }); + + modelBuilder.Entity("MealPlanner.Models.Recipe", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("Instructions") + .IsRequired() + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId"); + + b.HasIndex("UserId"); + + b.ToTable("Recipes"); + }); + + modelBuilder.Entity("MealPlanner.Models.RecipeIngredient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecipeId") + .HasColumnType("uuid"); + + b.Property("Unit") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RecipeId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("MealPlanner.Models.UserSettings", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("HouseholdSize") + .HasColumnType("integer"); + + b.HasKey("UserId"); + + b.ToTable("UserSettings"); + }); + + modelBuilder.Entity("MealPlanner.Models.MealPlanEntry", b => + { + b.HasOne("MealPlanner.Models.MealPlan", "MealPlan") + .WithMany("Entries") + .HasForeignKey("MealPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MealPlanner.Models.Recipe", "Recipe") + .WithMany() + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("MealPlan"); + + b.Navigation("Recipe"); + }); + + modelBuilder.Entity("MealPlanner.Models.RecipeIngredient", b => + { + b.HasOne("MealPlanner.Models.Recipe", "Recipe") + .WithMany("Ingredients") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Recipe"); + }); + + modelBuilder.Entity("MealPlanner.Models.MealPlan", b => + { + b.Navigation("Entries"); + }); + + modelBuilder.Entity("MealPlanner.Models.Recipe", b => + { + b.Navigation("Ingredients"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Migrations/20260414190645_InitialCreate.cs b/backend/Migrations/20260414190645_InitialCreate.cs new file mode 100644 index 0000000..b3a3dfe --- /dev/null +++ b/backend/Migrations/20260414190645_InitialCreate.cs @@ -0,0 +1,180 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace backend.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CheckedShoppingItems", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MealPlanId = table.Column(type: "uuid", nullable: false), + ItemName = table.Column(type: "text", nullable: false), + UserId = table.Column(type: "text", nullable: false), + IsChecked = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CheckedShoppingItems", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "MealPlans", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "text", nullable: false), + WeekStart = table.Column(type: "date", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MealPlans", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Recipes", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "text", nullable: true), + ExternalId = table.Column(type: "text", nullable: true), + Title = table.Column(type: "text", nullable: false), + Instructions = table.Column(type: "text", nullable: false), + ImageUrl = table.Column(type: "text", nullable: true), + Source = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Recipes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UserSettings", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + HouseholdSize = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserSettings", x => x.UserId); + }); + + migrationBuilder.CreateTable( + name: "MealPlanEntries", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MealPlanId = table.Column(type: "uuid", nullable: false), + Date = table.Column(type: "date", nullable: false), + RecipeId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MealPlanEntries", x => x.Id); + table.ForeignKey( + name: "FK_MealPlanEntries_MealPlans_MealPlanId", + column: x => x.MealPlanId, + principalTable: "MealPlans", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_MealPlanEntries_Recipes_RecipeId", + column: x => x.RecipeId, + principalTable: "Recipes", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "RecipeIngredients", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RecipeId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + Amount = table.Column(type: "numeric", nullable: true), + Unit = table.Column(type: "text", nullable: true), + Category = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RecipeIngredients", x => x.Id); + table.ForeignKey( + name: "FK_RecipeIngredients_Recipes_RecipeId", + column: x => x.RecipeId, + principalTable: "Recipes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_CheckedShoppingItems_MealPlanId_UserId_ItemName", + table: "CheckedShoppingItems", + columns: new[] { "MealPlanId", "UserId", "ItemName" }); + + migrationBuilder.CreateIndex( + name: "IX_MealPlanEntries_MealPlanId", + table: "MealPlanEntries", + column: "MealPlanId"); + + migrationBuilder.CreateIndex( + name: "IX_MealPlanEntries_RecipeId", + table: "MealPlanEntries", + column: "RecipeId"); + + migrationBuilder.CreateIndex( + name: "IX_MealPlans_UserId_WeekStart", + table: "MealPlans", + columns: new[] { "UserId", "WeekStart" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RecipeIngredients_RecipeId", + table: "RecipeIngredients", + column: "RecipeId"); + + migrationBuilder.CreateIndex( + name: "IX_Recipes_ExternalId", + table: "Recipes", + column: "ExternalId"); + + migrationBuilder.CreateIndex( + name: "IX_Recipes_UserId", + table: "Recipes", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CheckedShoppingItems"); + + migrationBuilder.DropTable( + name: "MealPlanEntries"); + + migrationBuilder.DropTable( + name: "RecipeIngredients"); + + migrationBuilder.DropTable( + name: "UserSettings"); + + migrationBuilder.DropTable( + name: "MealPlans"); + + migrationBuilder.DropTable( + name: "Recipes"); + } + } +} diff --git a/backend/Migrations/AppDbContextModelSnapshot.cs b/backend/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..d3fd9a7 --- /dev/null +++ b/backend/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,222 @@ +// +using System; +using MealPlanner.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace backend.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.26") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MealPlanner.Models.CheckedShoppingItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsChecked") + .HasColumnType("boolean"); + + b.Property("ItemName") + .IsRequired() + .HasColumnType("text"); + + b.Property("MealPlanId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MealPlanId", "UserId", "ItemName"); + + b.ToTable("CheckedShoppingItems"); + }); + + modelBuilder.Entity("MealPlanner.Models.MealPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("WeekStart") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "WeekStart") + .IsUnique(); + + b.ToTable("MealPlans"); + }); + + modelBuilder.Entity("MealPlanner.Models.MealPlanEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("MealPlanId") + .HasColumnType("uuid"); + + b.Property("RecipeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MealPlanId"); + + b.HasIndex("RecipeId"); + + b.ToTable("MealPlanEntries"); + }); + + modelBuilder.Entity("MealPlanner.Models.Recipe", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("Instructions") + .IsRequired() + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId"); + + b.HasIndex("UserId"); + + b.ToTable("Recipes"); + }); + + modelBuilder.Entity("MealPlanner.Models.RecipeIngredient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecipeId") + .HasColumnType("uuid"); + + b.Property("Unit") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RecipeId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("MealPlanner.Models.UserSettings", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("HouseholdSize") + .HasColumnType("integer"); + + b.HasKey("UserId"); + + b.ToTable("UserSettings"); + }); + + modelBuilder.Entity("MealPlanner.Models.MealPlanEntry", b => + { + b.HasOne("MealPlanner.Models.MealPlan", "MealPlan") + .WithMany("Entries") + .HasForeignKey("MealPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MealPlanner.Models.Recipe", "Recipe") + .WithMany() + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("MealPlan"); + + b.Navigation("Recipe"); + }); + + modelBuilder.Entity("MealPlanner.Models.RecipeIngredient", b => + { + b.HasOne("MealPlanner.Models.Recipe", "Recipe") + .WithMany("Ingredients") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Recipe"); + }); + + modelBuilder.Entity("MealPlanner.Models.MealPlan", b => + { + b.Navigation("Entries"); + }); + + modelBuilder.Entity("MealPlanner.Models.Recipe", b => + { + b.Navigation("Ingredients"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Models/CheckedShoppingItem.cs b/backend/Models/CheckedShoppingItem.cs new file mode 100644 index 0000000..2e1ce2d --- /dev/null +++ b/backend/Models/CheckedShoppingItem.cs @@ -0,0 +1,10 @@ +namespace MealPlanner.Models; + +public class CheckedShoppingItem +{ + public Guid Id { get; set; } + public Guid MealPlanId { get; set; } + public string ItemName { get; set; } = ""; + public string UserId { get; set; } = ""; + public bool IsChecked { get; set; } +} diff --git a/backend/Models/MealPlan.cs b/backend/Models/MealPlan.cs new file mode 100644 index 0000000..46f081a --- /dev/null +++ b/backend/Models/MealPlan.cs @@ -0,0 +1,11 @@ +namespace MealPlanner.Models; + +public class MealPlan +{ + public Guid Id { get; set; } + public string UserId { get; set; } = ""; + public DateOnly WeekStart { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public List Entries { get; set; } = []; +} diff --git a/backend/Models/MealPlanEntry.cs b/backend/Models/MealPlanEntry.cs new file mode 100644 index 0000000..418a870 --- /dev/null +++ b/backend/Models/MealPlanEntry.cs @@ -0,0 +1,12 @@ +namespace MealPlanner.Models; + +public class MealPlanEntry +{ + public Guid Id { get; set; } + public Guid MealPlanId { get; set; } + public DateOnly Date { get; set; } + public Guid RecipeId { get; set; } + + public MealPlan MealPlan { get; set; } = null!; + public Recipe Recipe { get; set; } = null!; +} diff --git a/backend/Models/Recipe.cs b/backend/Models/Recipe.cs new file mode 100644 index 0000000..405dea2 --- /dev/null +++ b/backend/Models/Recipe.cs @@ -0,0 +1,21 @@ +namespace MealPlanner.Models; + +public enum RecipeSource +{ + Own, + TheMealDb +} + +public class Recipe +{ + public Guid Id { get; set; } + public string? UserId { get; set; } + public string? ExternalId { get; set; } + public string Title { get; set; } = ""; + public string Instructions { get; set; } = ""; + public string? ImageUrl { get; set; } + public RecipeSource Source { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public List Ingredients { get; set; } = []; +} diff --git a/backend/Models/RecipeIngredient.cs b/backend/Models/RecipeIngredient.cs new file mode 100644 index 0000000..bf4702d --- /dev/null +++ b/backend/Models/RecipeIngredient.cs @@ -0,0 +1,13 @@ +namespace MealPlanner.Models; + +public class RecipeIngredient +{ + public Guid Id { get; set; } + public Guid RecipeId { get; set; } + public string Name { get; set; } = ""; + public decimal? Amount { get; set; } + public string? Unit { get; set; } + public string? Category { get; set; } + + public Recipe Recipe { get; set; } = null!; +} diff --git a/backend/Models/ShoppingItem.cs b/backend/Models/ShoppingItem.cs new file mode 100644 index 0000000..534af79 --- /dev/null +++ b/backend/Models/ShoppingItem.cs @@ -0,0 +1,10 @@ +namespace MealPlanner.Models; + +public class ShoppingItem +{ + public string Name { get; set; } = ""; + public decimal? TotalAmount { get; set; } + public string? Unit { get; set; } + public string? Category { get; set; } + public bool IsChecked { get; set; } +} diff --git a/backend/Models/UserSettings.cs b/backend/Models/UserSettings.cs new file mode 100644 index 0000000..df2c059 --- /dev/null +++ b/backend/Models/UserSettings.cs @@ -0,0 +1,7 @@ +namespace MealPlanner.Models; + +public class UserSettings +{ + public string UserId { get; set; } = ""; + public int HouseholdSize { get; set; } = 2; +} diff --git a/backend/Program.cs b/backend/Program.cs new file mode 100644 index 0000000..0cf79c2 --- /dev/null +++ b/backend/Program.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using MealPlanner.Data; +using MealPlanner.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers() + .AddJsonOptions(o => + { + o.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles; + o.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); + }); + +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); + +// Zitadel JWT Bearer Auth +builder.Services.AddAuthentication("Bearer") + .AddJwtBearer("Bearer", options => + { + options.Authority = builder.Configuration["Zitadel:Issuer"] ?? "https://auth.kuns.dev"; + options.Audience = builder.Configuration["Zitadel:ClientId"] ?? ""; + options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = false, // Zitadel uses client_id as audience in some configs + ValidateLifetime = true, + NameClaimType = "sub", + }; + }); +builder.Services.AddAuthorization(); + +builder.Services.AddHttpClient(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var allowedOrigin = builder.Configuration["AllowedOrigin"] ?? "https://essen.kuns.dev"; +builder.Services.AddCors(options => + options.AddDefaultPolicy(policy => + policy.WithOrigins(allowedOrigin, "http://localhost:5173") + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials())); + +builder.Services.AddHealthChecks(); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); +} + +app.UseCors(); +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.MapHealthChecks("/health"); +app.UseStaticFiles(); +app.Run(); diff --git a/backend/Properties/launchSettings.json b/backend/Properties/launchSettings.json new file mode 100644 index 0000000..b9445a1 --- /dev/null +++ b/backend/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:8080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/backend/Services/MealPlanService.cs b/backend/Services/MealPlanService.cs new file mode 100644 index 0000000..aac4918 --- /dev/null +++ b/backend/Services/MealPlanService.cs @@ -0,0 +1,180 @@ +using Microsoft.EntityFrameworkCore; +using MealPlanner.Data; +using MealPlanner.Models; + +namespace MealPlanner.Services; + +public class MealPlanService(AppDbContext db, TheMealDbClient mealDbClient, RecipeService recipeService) +{ + public async Task GenerateWeekPlanAsync(string userId, DateOnly weekStart) + { + // Remove existing plan for this week if present + var existing = await db.MealPlans + .Include(p => p.Entries) + .FirstOrDefaultAsync(p => p.UserId == userId && p.WeekStart == weekStart); + if (existing is not null) + { + db.MealPlans.Remove(existing); + await db.SaveChangesAsync(); + } + + var plan = new MealPlan + { + Id = Guid.NewGuid(), + UserId = userId, + WeekStart = weekStart, + CreatedAt = DateTime.UtcNow, + }; + + // Get own recipes to optionally mix in (up to 3) + var ownRecipes = await db.Recipes + .Include(r => r.Ingredients) + .Where(r => r.UserId == userId) + .ToListAsync(); + + var usedRecipeIds = new HashSet(); + var entries = new List(); + + // Shuffle own recipes and pick up to 3 + var ownToUse = ownRecipes + .OrderBy(_ => Random.Shared.Next()) + .Take(Math.Min(3, ownRecipes.Count)) + .ToList(); + + int ownIndex = 0; + + for (int dayOffset = 0; dayOffset < 7; dayOffset++) + { + var date = weekStart.AddDays(dayOffset); + Recipe? recipe = null; + + // Use an own recipe for this slot if available + if (ownIndex < ownToUse.Count) + { + recipe = ownToUse[ownIndex++]; + } + else + { + // Fetch random from TheMealDB, retry if duplicate + for (int attempt = 0; attempt < 5; attempt++) + { + var candidate = await mealDbClient.GetRandomAsync(); + if (candidate is null) continue; + if (candidate.ExternalId is not null && usedRecipeIds.Any(id => + db.Recipes.Any(r => r.Id == id && r.ExternalId == candidate.ExternalId))) + continue; + + recipe = await recipeService.EnsurePersistedAsync(candidate); + break; + } + } + + if (recipe is null) continue; + + usedRecipeIds.Add(recipe.Id); + entries.Add(new MealPlanEntry + { + Id = Guid.NewGuid(), + MealPlanId = plan.Id, + Date = date, + RecipeId = recipe.Id, + }); + } + + plan.Entries = entries; + db.MealPlans.Add(plan); + await db.SaveChangesAsync(); + + return await LoadPlanWithDetailsAsync(plan.Id) ?? plan; + } + + public async Task GetCurrentPlanAsync(string userId) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var weekStart = GetWeekStart(today); + return await GetPlanAsync(userId, weekStart); + } + + public async Task GetPlanAsync(string userId, DateOnly weekStart) + { + var plan = await db.MealPlans + .Where(p => p.UserId == userId && p.WeekStart == weekStart) + .Select(p => p.Id) + .FirstOrDefaultAsync(); + + if (plan == default) return null; + return await LoadPlanWithDetailsAsync(plan); + } + + public async Task SwapEntryAsync(Guid entryId, Guid newRecipeId, string userId) + { + var entry = await db.MealPlanEntries + .Include(e => e.MealPlan) + .FirstOrDefaultAsync(e => e.Id == entryId); + + if (entry is null) return null; + if (entry.MealPlan.UserId != userId) throw new UnauthorizedAccessException(); + + var recipe = await db.Recipes.FindAsync(newRecipeId); + if (recipe is null) return null; + + entry.RecipeId = newRecipeId; + await db.SaveChangesAsync(); + + return await db.MealPlanEntries + .Include(e => e.Recipe).ThenInclude(r => r.Ingredients) + .FirstOrDefaultAsync(e => e.Id == entryId); + } + + public async Task RerollEntryAsync(Guid entryId, string userId) + { + var entry = await db.MealPlanEntries + .Include(e => e.MealPlan).ThenInclude(p => p.Entries) + .FirstOrDefaultAsync(e => e.Id == entryId); + + if (entry is null) return null; + if (entry.MealPlan.UserId != userId) throw new UnauthorizedAccessException(); + + var usedExternalIds = await db.MealPlanEntries + .Where(e => e.MealPlanId == entry.MealPlanId) + .Include(e => e.Recipe) + .Select(e => e.Recipe.ExternalId) + .ToListAsync(); + + Recipe? newRecipe = null; + for (int attempt = 0; attempt < 10; attempt++) + { + var candidate = await mealDbClient.GetRandomAsync(); + if (candidate is null) continue; + if (usedExternalIds.Contains(candidate.ExternalId)) continue; + + newRecipe = await recipeService.EnsurePersistedAsync(candidate); + break; + } + + if (newRecipe is null) return null; + + entry.RecipeId = newRecipe.Id; + await db.SaveChangesAsync(); + + return await db.MealPlanEntries + .Include(e => e.Recipe).ThenInclude(r => r.Ingredients) + .FirstOrDefaultAsync(e => e.Id == entryId); + } + + private async Task LoadPlanWithDetailsAsync(Guid planId) + { + return await db.MealPlans + .Include(p => p.Entries) + .ThenInclude(e => e.Recipe) + .ThenInclude(r => r.Ingredients) + .FirstOrDefaultAsync(p => p.Id == planId); + } + + public static DateOnly GetWeekStart(DateOnly date) + { + // Monday as week start + int diff = ((int)date.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7; + return date.AddDays(-diff); + } +} diff --git a/backend/Services/RecipeService.cs b/backend/Services/RecipeService.cs new file mode 100644 index 0000000..8f5d290 --- /dev/null +++ b/backend/Services/RecipeService.cs @@ -0,0 +1,128 @@ +using Microsoft.EntityFrameworkCore; +using MealPlanner.Data; +using MealPlanner.Models; + +namespace MealPlanner.Services; + +public class RecipeService(AppDbContext db, TheMealDbClient mealDbClient) +{ + public async Task> GetOwnRecipesAsync(string userId) + { + return await db.Recipes + .Include(r => r.Ingredients) + .Where(r => r.UserId == userId) + .OrderByDescending(r => r.CreatedAt) + .ToListAsync(); + } + + public async Task GetByIdAsync(Guid id) + { + return await db.Recipes + .Include(r => r.Ingredients) + .FirstOrDefaultAsync(r => r.Id == id); + } + + public async Task GetByIdOrFetchAsync(Guid id) + { + var recipe = await GetByIdAsync(id); + return recipe; + } + + public async Task> SearchAsync(string query, string userId) + { + // Local own recipes + var own = await db.Recipes + .Include(r => r.Ingredients) + .Where(r => r.UserId == userId && r.Title.ToLower().Contains(query.ToLower())) + .ToListAsync(); + + // External results + var external = await mealDbClient.SearchAsync(query); + + // Deduplicate external by ExternalId + var existingExternalIds = await db.Recipes + .Where(r => r.Source == RecipeSource.TheMealDb && r.ExternalId != null) + .Select(r => r.ExternalId!) + .ToListAsync(); + + var newExternal = external + .Where(r => r.ExternalId != null && !existingExternalIds.Contains(r.ExternalId)) + .ToList(); + + return [.. own, .. newExternal]; + } + + public async Task CreateAsync(string userId, Recipe recipe) + { + recipe.Id = Guid.NewGuid(); + recipe.UserId = userId; + recipe.Source = RecipeSource.Own; + recipe.CreatedAt = DateTime.UtcNow; + + foreach (var ing in recipe.Ingredients) + { + ing.Id = Guid.NewGuid(); + ing.RecipeId = recipe.Id; + } + + db.Recipes.Add(recipe); + await db.SaveChangesAsync(); + return recipe; + } + + public async Task UpdateAsync(Guid id, string userId, Recipe updated) + { + var recipe = await db.Recipes + .Include(r => r.Ingredients) + .FirstOrDefaultAsync(r => r.Id == id); + + if (recipe is null) return null; + if (recipe.UserId != userId) throw new UnauthorizedAccessException(); + + recipe.Title = updated.Title; + recipe.Instructions = updated.Instructions; + recipe.ImageUrl = updated.ImageUrl; + + // Replace ingredients + db.RecipeIngredients.RemoveRange(recipe.Ingredients); + recipe.Ingredients = updated.Ingredients.Select(ing => new RecipeIngredient + { + Id = Guid.NewGuid(), + RecipeId = recipe.Id, + Name = ing.Name, + Amount = ing.Amount, + Unit = ing.Unit, + Category = ing.Category, + }).ToList(); + + await db.SaveChangesAsync(); + return recipe; + } + + public async Task DeleteAsync(Guid id, string userId) + { + var recipe = await db.Recipes.FirstOrDefaultAsync(r => r.Id == id); + if (recipe is null) return false; + if (recipe.UserId != userId) throw new UnauthorizedAccessException(); + + db.Recipes.Remove(recipe); + await db.SaveChangesAsync(); + return true; + } + + // Save an external recipe to DB so it can be referenced in meal plans + public async Task EnsurePersistedAsync(Recipe recipe) + { + if (recipe.ExternalId is not null) + { + var existing = await db.Recipes + .Include(r => r.Ingredients) + .FirstOrDefaultAsync(r => r.ExternalId == recipe.ExternalId); + if (existing is not null) return existing; + } + + db.Recipes.Add(recipe); + await db.SaveChangesAsync(); + return recipe; + } +} diff --git a/backend/Services/ShoppingListService.cs b/backend/Services/ShoppingListService.cs new file mode 100644 index 0000000..a1db829 --- /dev/null +++ b/backend/Services/ShoppingListService.cs @@ -0,0 +1,91 @@ +using Microsoft.EntityFrameworkCore; +using MealPlanner.Data; +using MealPlanner.Models; + +namespace MealPlanner.Services; + +public class ShoppingListService(AppDbContext db) +{ + public async Task> GetShoppingListAsync(Guid mealPlanId, string userId) + { + var plan = await db.MealPlans + .Include(p => p.Entries) + .ThenInclude(e => e.Recipe) + .ThenInclude(r => r.Ingredients) + .FirstOrDefaultAsync(p => p.Id == mealPlanId); + + if (plan is null) return []; + if (plan.UserId != userId) throw new UnauthorizedAccessException(); + + var settings = await db.UserSettings.FindAsync(userId); + int householdSize = settings?.HouseholdSize ?? 2; + + var checkedItems = await db.CheckedShoppingItems + .Where(c => c.MealPlanId == mealPlanId && c.UserId == userId) + .ToDictionaryAsync(c => c.ItemName.ToLowerInvariant(), c => c.IsChecked); + + // Aggregate ingredients + var aggregated = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in plan.Entries) + { + foreach (var ing in entry.Recipe.Ingredients) + { + var key = ing.Name.Trim().ToLowerInvariant(); + + if (!aggregated.TryGetValue(key, out var item)) + { + item = new ShoppingItem + { + Name = ing.Name.Trim(), + Unit = ing.Unit, + Category = ing.Category, + TotalAmount = null, + IsChecked = checkedItems.TryGetValue(key, out var chk) && chk, + }; + aggregated[key] = item; + } + + if (ing.Amount.HasValue) + { + item.TotalAmount = (item.TotalAmount ?? 0) + ing.Amount.Value * householdSize; + } + } + } + + return [.. aggregated.Values.OrderBy(i => i.Category).ThenBy(i => i.Name)]; + } + + public async Task ToggleCheckAsync(Guid mealPlanId, string itemName, string userId) + { + var plan = await db.MealPlans.FindAsync(mealPlanId); + if (plan is null) throw new KeyNotFoundException("Meal plan not found"); + if (plan.UserId != userId) throw new UnauthorizedAccessException(); + + var key = itemName.ToLowerInvariant(); + + var existing = await db.CheckedShoppingItems + .FirstOrDefaultAsync(c => c.MealPlanId == mealPlanId && c.UserId == userId + && c.ItemName.ToLower() == key); + + if (existing is null) + { + existing = new CheckedShoppingItem + { + Id = Guid.NewGuid(), + MealPlanId = mealPlanId, + ItemName = itemName, + UserId = userId, + IsChecked = true, + }; + db.CheckedShoppingItems.Add(existing); + } + else + { + existing.IsChecked = !existing.IsChecked; + } + + await db.SaveChangesAsync(); + return existing; + } +} diff --git a/backend/Services/TheMealDbClient.cs b/backend/Services/TheMealDbClient.cs new file mode 100644 index 0000000..e4b9a90 --- /dev/null +++ b/backend/Services/TheMealDbClient.cs @@ -0,0 +1,134 @@ +using System.Text.Json; +using MealPlanner.Models; + +namespace MealPlanner.Services; + +public class TheMealDbClient(HttpClient httpClient) +{ + private const string BaseUrl = "https://www.themealdb.com/api/json/v1/1/"; + + public async Task GetRandomAsync() + { + var response = await httpClient.GetAsync($"{BaseUrl}random.php"); + if (!response.IsSuccessStatusCode) return null; + + var json = await response.Content.ReadAsStringAsync(); + var meals = ParseMeals(json); + return meals.FirstOrDefault(); + } + + public async Task> SearchAsync(string query) + { + var response = await httpClient.GetAsync($"{BaseUrl}search.php?s={Uri.EscapeDataString(query)}"); + if (!response.IsSuccessStatusCode) return []; + + var json = await response.Content.ReadAsStringAsync(); + return ParseMeals(json); + } + + public async Task GetByIdAsync(string id) + { + var response = await httpClient.GetAsync($"{BaseUrl}lookup.php?i={id}"); + if (!response.IsSuccessStatusCode) return null; + + var json = await response.Content.ReadAsStringAsync(); + return ParseMeals(json).FirstOrDefault(); + } + + private static List ParseMeals(string json) + { + var result = new List(); + + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("meals", out var mealsEl) || mealsEl.ValueKind == JsonValueKind.Null) + return result; + + foreach (var meal in mealsEl.EnumerateArray()) + { + var recipe = MapMeal(meal); + if (recipe is not null) + result.Add(recipe); + } + + return result; + } + + private static Recipe? MapMeal(JsonElement meal) + { + var id = meal.GetStringOrNull("idMeal"); + var title = meal.GetStringOrNull("strMeal"); + if (id is null || title is null) return null; + + var recipe = new Recipe + { + Id = Guid.NewGuid(), + ExternalId = id, + Title = title, + Instructions = meal.GetStringOrNull("strInstructions") ?? "", + ImageUrl = meal.GetStringOrNull("strMealThumb"), + Source = RecipeSource.TheMealDb, + CreatedAt = DateTime.UtcNow, + }; + + // Parse strIngredient1-20 / strMeasure1-20 + for (int i = 1; i <= 20; i++) + { + var name = meal.GetStringOrNull($"strIngredient{i}"); + var measure = meal.GetStringOrNull($"strMeasure{i}"); + + if (string.IsNullOrWhiteSpace(name)) continue; + + var ingredient = new RecipeIngredient + { + Id = Guid.NewGuid(), + RecipeId = recipe.Id, + Name = name.Trim(), + }; + + if (!string.IsNullOrWhiteSpace(measure)) + { + ParseMeasure(measure.Trim(), out var amount, out var unit); + ingredient.Amount = amount; + ingredient.Unit = unit; + } + + recipe.Ingredients.Add(ingredient); + } + + return recipe; + } + + private static void ParseMeasure(string measure, out decimal? amount, out string? unit) + { + amount = null; + unit = null; + + if (string.IsNullOrWhiteSpace(measure)) return; + + // Try to extract leading number + var parts = measure.Trim().Split(' ', 2); + if (parts.Length > 0 && decimal.TryParse(parts[0], System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + { + amount = parsed; + unit = parts.Length > 1 ? parts[1].Trim() : null; + } + else + { + unit = measure; + } + } +} + +file static class JsonElementExtensions +{ + public static string? GetStringOrNull(this JsonElement el, string propertyName) + { + if (el.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String) + { + var val = prop.GetString(); + return string.IsNullOrWhiteSpace(val) ? null : val; + } + return null; + } +} diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json new file mode 100644 index 0000000..511f35a --- /dev/null +++ b/backend/appsettings.Development.json @@ -0,0 +1,6 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=54320;Database=mealplanner;Username=mika;Password=changeme" + }, + "AllowedOrigin": "http://localhost:5173" +} diff --git a/backend/appsettings.json b/backend/appsettings.json new file mode 100644 index 0000000..3c87a58 --- /dev/null +++ b/backend/appsettings.json @@ -0,0 +1,16 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=54320;Database=mealplanner;Username=mika;Password=changeme" + }, + "Zitadel": { + "Issuer": "https://auth.kuns.dev", + "ClientId": "" + }, + "AllowedOrigin": "https://essen.kuns.dev", + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.EntityFrameworkCore": "Warning" + } + } +} diff --git a/backend/backend.csproj b/backend/backend.csproj new file mode 100644 index 0000000..70b44d1 --- /dev/null +++ b/backend/backend.csproj @@ -0,0 +1,16 @@ + + + net8.0 + enable + enable + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5ee8bbc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3.8' +services: + app: + build: . + ports: + - "3000:80" + environment: + - ConnectionStrings__DefaultConnection=Host=host.docker.internal;Port=54320;Database=mealplanner;Username=mika;Password=${SHARED_POSTGRES_PASSWORD} + - Zitadel__Issuer=https://auth.kuns.dev + - Zitadel__ClientId=${ZITADEL_CLIENT_ID} + - AllowedOrigin=http://localhost:3000 + - ASPNETCORE_URLS=http://+:5000 + - ASPNETCORE_ENVIRONMENT=Production diff --git a/docs/superpowers/specs/2026-04-14-mealplanner-plan.md b/docs/superpowers/specs/2026-04-14-mealplanner-plan.md new file mode 100644 index 0000000..8423a72 --- /dev/null +++ b/docs/superpowers/specs/2026-04-14-mealplanner-plan.md @@ -0,0 +1,70 @@ +# Mealplanner — Implementation Plan + +## Phase 1: Project Scaffolding (parallel) + +### 1A: Backend Setup +- Create `backend/backend.csproj` (.NET 8, Nullable, ImplicitUsings) +- NuGet packages: Npgsql.EntityFrameworkCore.PostgreSQL, Microsoft.AspNetCore.Authentication.JwtBearer, Microsoft.EntityFrameworkCore.Design +- `Program.cs` with EF Core, JWT Auth, CORS, Swagger +- `Data/AppDbContext.cs` with all entities +- Models: Recipe, RecipeIngredient, MealPlan, MealPlanEntry, UserSettings +- Initial EF migration + +### 1B: Frontend Setup +- `bun create vue` in `frontend/` +- Tailwind 4 via `@tailwindcss/vite` +- Vue Router, Pinia +- Theme CSS variables (task-scheduler dark theme) +- Auth module (manual OIDC PKCE flow, reference: ~/tasks-kuns-dev/frontend/src/auth.ts) +- API composable (`useApi.ts`) with auth token injection +- App shell: sidebar nav with 4 tabs + +## Phase 2: Core Features (parallel) + +### 2A: Recipe System (Backend) +- TheMealDbClient service (HttpClient, random + search + lookup) +- RecipeService (CRUD for own recipes + search combining both sources) +- RecipeController with all endpoints +- Map TheMealDB response to internal Recipe model + +### 2B: Recipe System (Frontend) +- RecipesView.vue — list own recipes as cards +- Recipe create/edit form with dynamic ingredient rows +- RecipeDetail.vue — full recipe view with ingredients + instructions +- Search functionality (own + external) + +### 2C: MealPlan System (Backend) +- MealPlanService: generate (pick 7 random unique), reroll single day +- MealPlanController with all endpoints +- ShoppingListService: aggregate ingredients, scale by household size, group by category +- ShoppingListController + +## Phase 3: Main UI (sequential, depends on Phase 2) + +### 3A: WeekPlan View +- 7 day cards (Mon-Sun) with recipe image + title +- "Generate new week" button +- Per-day: "Swap" (opens recipe search) + "Reroll" (random) buttons +- Loading states, empty state for first visit + +### 3B: Shopping List View +- Grouped by category +- Checkbox per item (persisted) +- Badge with open item count +- "Alles abhaken" reset button + +### 3C: Settings View +- Household size input +- Simple form, save to API + +## Phase 4: Deployment + +### 4A: Dockerfile + Docker Compose +- Multi-stage: Bun build frontend → .NET publish → ASP.NET runtime +- Serve frontend static files from wwwroot +- docker-compose.yml for local dev + +### 4B: Gitea + Coolify Setup +- Create Gitea repo +- Push code +- Coolify deploy config (env vars, domain essen.kuns.dev) diff --git a/frontend/bun.lock b/frontend/bun.lock new file mode 100644 index 0000000..04093a4 --- /dev/null +++ b/frontend/bun.lock @@ -0,0 +1,315 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "mealplanner-frontend", + "dependencies": { + "pinia": "^2.0.0", + "vue": "^3.5.0", + "vue-router": "^4.0.0", + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-vue": "^5.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vue-tsc": "^2.0.0", + }, + }, + }, + "packages": { + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="], + + "@volar/language-core": ["@volar/language-core@2.4.15", "", { "dependencies": { "@volar/source-map": "2.4.15" } }, "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA=="], + + "@volar/source-map": ["@volar/source-map@2.4.15", "", {}, "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg=="], + + "@volar/typescript": ["@volar/typescript@2.4.15", "", { "dependencies": { "@volar/language-core": "2.4.15", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg=="], + + "@vue/compiler-core": ["@vue/compiler-core@3.5.32", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/shared": "3.5.32", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ=="], + + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.32", "", { "dependencies": { "@vue/compiler-core": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q=="], + + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.32", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/compiler-core": "3.5.32", "@vue/compiler-dom": "3.5.32", "@vue/compiler-ssr": "3.5.32", "@vue/shared": "3.5.32", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg=="], + + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.32", "", { "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw=="], + + "@vue/compiler-vue2": ["@vue/compiler-vue2@2.7.16", "", { "dependencies": { "de-indent": "^1.0.2", "he": "^1.2.0" } }, "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A=="], + + "@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="], + + "@vue/language-core": ["@vue/language-core@2.2.12", "", { "dependencies": { "@volar/language-core": "2.4.15", "@vue/compiler-dom": "^3.5.0", "@vue/compiler-vue2": "^2.7.16", "@vue/shared": "^3.5.0", "alien-signals": "^1.0.3", "minimatch": "^9.0.3", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA=="], + + "@vue/reactivity": ["@vue/reactivity@3.5.32", "", { "dependencies": { "@vue/shared": "3.5.32" } }, "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ=="], + + "@vue/runtime-core": ["@vue/runtime-core@3.5.32", "", { "dependencies": { "@vue/reactivity": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ=="], + + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.32", "", { "dependencies": { "@vue/reactivity": "3.5.32", "@vue/runtime-core": "3.5.32", "@vue/shared": "3.5.32", "csstype": "^3.2.3" } }, "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ=="], + + "@vue/server-renderer": ["@vue/server-renderer@3.5.32", "", { "dependencies": { "@vue/compiler-ssr": "3.5.32", "@vue/shared": "3.5.32" }, "peerDependencies": { "vue": "3.5.32" } }, "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ=="], + + "@vue/shared": ["@vue/shared@3.5.32", "", {}, "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg=="], + + "alien-signals": ["alien-signals@1.0.13", "", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "pinia": ["pinia@2.3.1", "", { "dependencies": { "@vue/devtools-api": "^6.6.3", "vue-demi": "^0.14.10" }, "peerDependencies": { "typescript": ">=4.4.4", "vue": "^2.7.0 || ^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug=="], + + "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], + + "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], + + "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + + "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + + "vue": ["vue@3.5.32", "", { "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/compiler-sfc": "3.5.32", "@vue/runtime-dom": "3.5.32", "@vue/server-renderer": "3.5.32", "@vue/shared": "3.5.32" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw=="], + + "vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="], + + "vue-router": ["vue-router@4.6.4", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="], + + "vue-tsc": ["vue-tsc@2.2.12", "", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..bb78372 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Mealplanner + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..4647100 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "mealplanner-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.0", + "vue-router": "^4.0.0", + "pinia": "^2.0.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "@tailwindcss/vite": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vue-tsc": "^2.0.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..23ed5c7 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,234 @@ + + + + + + + diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts new file mode 100644 index 0000000..5a65ae4 --- /dev/null +++ b/frontend/src/auth.ts @@ -0,0 +1,185 @@ +import { ref } from 'vue' + +const ZITADEL_ISSUER = import.meta.env.VITE_ZITADEL_ISSUER || 'https://auth.kuns.dev' +const CLIENT_ID = import.meta.env.VITE_ZITADEL_CLIENT_ID || '' +const REDIRECT_URI = `${window.location.origin}/auth/callback` +const SCOPES = 'openid profile email urn:zitadel:iam:org:project:roles' + +export const isAuthenticated = ref(false) +export const isLoading = ref(true) +export const authError = ref(null) +export const currentUser = ref<{ name?: string; email?: string } | null>(null) + +function randomString(len: number): string { + const arr = new Uint8Array(len) + crypto.getRandomValues(arr) + return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('') +} + +async function sha256(plain: string): Promise { + const data = new TextEncoder().encode(plain) + const hash = await crypto.subtle.digest('SHA-256', data) + return btoa(String.fromCharCode(...new Uint8Array(hash))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function checkRedirectLoop(): boolean { + const key = 'oidc_redirect_count' + const tsKey = 'oidc_redirect_ts' + const now = Date.now() + const ts = parseInt(sessionStorage.getItem(tsKey) || '0') + let count = parseInt(sessionStorage.getItem(key) || '0') + if (now - ts > 30000) count = 0 + count++ + sessionStorage.setItem(key, String(count)) + sessionStorage.setItem(tsKey, String(now)) + if (count > 3) { + console.error('[auth] Redirect loop detected') + return true + } + return false +} + +function clearRedirectCounter(): void { + sessionStorage.removeItem('oidc_redirect_count') + sessionStorage.removeItem('oidc_redirect_ts') +} + +export function getAccessToken(): string | null { + return localStorage.getItem('oidc_access_token') +} + +export async function initAuth(): Promise { + try { + if (window.location.pathname === '/auth/callback') { + const params = new URLSearchParams(window.location.search) + const code = params.get('code') + const error = params.get('error') + const verifier = localStorage.getItem('oidc_code_verifier') + + if (error) { + authError.value = `Login fehlgeschlagen: ${params.get('error_description') || error}` + localStorage.removeItem('oidc_code_verifier') + window.history.replaceState({}, '', '/') + return + } + + if (!code || !verifier) { + authError.value = 'Login fehlgeschlagen: Auth-State verloren.' + localStorage.removeItem('oidc_code_verifier') + window.history.replaceState({}, '', '/') + return + } + + const res = await fetch(`${ZITADEL_ISSUER}/oauth/v2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT_URI, + client_id: CLIENT_ID, + code_verifier: verifier, + }), + }) + + if (!res.ok) { + authError.value = `Token-Exchange fehlgeschlagen (HTTP ${res.status})` + localStorage.removeItem('oidc_code_verifier') + window.history.replaceState({}, '', '/') + return + } + + const tokens = await res.json() as { access_token: string; id_token?: string } + localStorage.setItem('oidc_access_token', tokens.access_token) + if (tokens.id_token) localStorage.setItem('oidc_id_token', tokens.id_token) + localStorage.removeItem('oidc_code_verifier') + + try { + if (tokens.id_token) { + const payload = JSON.parse(atob(tokens.id_token.split('.')[1] ?? '')) as { + name?: string + email?: string + sub?: string + exp?: number + } + localStorage.setItem('oidc_user_name', payload.name || '') + localStorage.setItem('oidc_user_email', payload.email || '') + currentUser.value = { + ...(payload.name !== undefined ? { name: payload.name } : {}), + ...(payload.email !== undefined ? { email: payload.email } : {}), + } + } + } catch { /* ignore */ } + + isAuthenticated.value = true + clearRedirectCounter() + const returnUrl = localStorage.getItem('oidc_return_url') || '/' + localStorage.removeItem('oidc_return_url') + window.location.replace(returnUrl) + return + } + + const token = localStorage.getItem('oidc_access_token') + if (token) { + try { + const payload = JSON.parse(atob(token.split('.')[1] ?? '')) as { + exp?: number + sub?: string + } + if (payload.exp && payload.exp * 1000 > Date.now()) { + isAuthenticated.value = true + const storedName = localStorage.getItem('oidc_user_name') || payload.sub + const storedEmail = localStorage.getItem('oidc_user_email') + currentUser.value = { + ...(storedName !== undefined ? { name: storedName } : {}), + ...(storedEmail ? { email: storedEmail } : {}), + } + clearRedirectCounter() + return + } + } catch { /* ignore */ } + localStorage.removeItem('oidc_access_token') + localStorage.removeItem('oidc_id_token') + } + } catch (e) { + authError.value = `Auth-Initialisierung fehlgeschlagen: ${e}` + } finally { + isLoading.value = false + } +} + +let redirecting = false +export async function login(returnUrl?: string): Promise { + if (redirecting) return + if (checkRedirectLoop()) { + authError.value = 'Redirect-Loop erkannt. Bitte Browser-Cache leeren.' + return + } + redirecting = true + authError.value = null + const verifier = randomString(32) + const challenge = await sha256(verifier) + localStorage.setItem('oidc_code_verifier', verifier) + localStorage.setItem('oidc_return_url', returnUrl || '/') + const params = new URLSearchParams({ + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + response_type: 'code', + scope: SCOPES, + code_challenge: challenge, + code_challenge_method: 'S256', + }) + window.location.href = `${ZITADEL_ISSUER}/oauth/v2/authorize?${params}` +} + +export function logout(): void { + localStorage.removeItem('oidc_access_token') + localStorage.removeItem('oidc_id_token') + localStorage.removeItem('oidc_code_verifier') + localStorage.removeItem('oidc_return_url') + clearRedirectCounter() + isAuthenticated.value = false + currentUser.value = null + window.location.href = `${ZITADEL_ISSUER}/oidc/v1/end_session?post_logout_redirect_uri=${encodeURIComponent(window.location.origin)}` +} diff --git a/frontend/src/components/MealCard.vue b/frontend/src/components/MealCard.vue new file mode 100644 index 0000000..4772cb1 --- /dev/null +++ b/frontend/src/components/MealCard.vue @@ -0,0 +1,82 @@ + + + diff --git a/frontend/src/components/RecipeDetail.vue b/frontend/src/components/RecipeDetail.vue new file mode 100644 index 0000000..1a541fc --- /dev/null +++ b/frontend/src/components/RecipeDetail.vue @@ -0,0 +1,78 @@ + + + diff --git a/frontend/src/components/ShoppingItem.vue b/frontend/src/components/ShoppingItem.vue new file mode 100644 index 0000000..fde40bc --- /dev/null +++ b/frontend/src/components/ShoppingItem.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/src/components/SwapModal.vue b/frontend/src/components/SwapModal.vue new file mode 100644 index 0000000..b4d7123 --- /dev/null +++ b/frontend/src/components/SwapModal.vue @@ -0,0 +1,88 @@ + + + diff --git a/frontend/src/composables/useApi.ts b/frontend/src/composables/useApi.ts new file mode 100644 index 0000000..3272caf --- /dev/null +++ b/frontend/src/composables/useApi.ts @@ -0,0 +1,24 @@ +import { getAccessToken, login } from '../auth' + +const BASE = '/api' + +async function request(path: string, options: RequestInit = {}): Promise { + const token = getAccessToken() + if (!token) { login(window.location.pathname); throw new Error('Not authenticated') } + const res = await fetch(`${BASE}${path}`, { + ...options, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...options.headers }, + }) + if (res.status === 401) { login(window.location.pathname); throw new Error('Unauthorized') } + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() as Promise +} + +export function useApi() { + return { + get: (path: string) => request(path), + post: (path: string, body?: unknown) => request(path, { method: 'POST', ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }), + put: (path: string, body?: unknown) => request(path, { method: 'PUT', ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }), + del: (path: string) => request(path, { method: 'DELETE' }), + } +} diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..2bb3fe1 --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_ZITADEL_ISSUER: string + readonly VITE_ZITADEL_CLIENT_ID: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..7eeea75 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,11 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import { router } from './router' +import { initAuth } from './auth' +import './theme.css' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +initAuth().then(() => app.mount('#app')) diff --git a/frontend/src/router.ts b/frontend/src/router.ts new file mode 100644 index 0000000..d55cbc7 --- /dev/null +++ b/frontend/src/router.ts @@ -0,0 +1,16 @@ +import { createRouter, createWebHistory } from 'vue-router' +import WeekPlanView from './views/WeekPlanView.vue' +import ShoppingListView from './views/ShoppingListView.vue' +import RecipesView from './views/RecipesView.vue' +import SettingsView from './views/SettingsView.vue' + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', component: WeekPlanView }, + { path: '/shopping/:mealPlanId?', component: ShoppingListView }, + { path: '/recipes', component: RecipesView }, + { path: '/settings', component: SettingsView }, + { path: '/auth/callback', redirect: '/' }, + ], +}) diff --git a/frontend/src/stores/mealPlan.ts b/frontend/src/stores/mealPlan.ts new file mode 100644 index 0000000..f9a01ca --- /dev/null +++ b/frontend/src/stores/mealPlan.ts @@ -0,0 +1,232 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { useApi } from '../composables/useApi' + +export interface Ingredient { + id?: number + name: string + amount: number + unit: string + category: string +} + +export interface Recipe { + id: number + title: string + imageUrl?: string + instructions?: string + ingredients: Ingredient[] +} + +export interface MealPlanEntry { + id: number + date: string + dayOfWeek: number + recipeId: number + recipe: Recipe +} + +export interface MealPlan { + id: number + weekStartDate: string + entries: MealPlanEntry[] +} + +export interface ShoppingItem { + id: number + name: string + amount: number + unit: string + category: string + isChecked: boolean +} + +export const useMealPlanStore = defineStore('mealPlan', () => { + const api = useApi() + + const currentPlan = ref(null) + const loading = ref(false) + const generating = ref(false) + const error = ref(null) + + async function fetchCurrentPlan(): Promise { + loading.value = true + error.value = null + try { + currentPlan.value = await api.get('/mealplans/current') + } catch (e) { + if (e instanceof Error && e.message === 'HTTP 404') { + currentPlan.value = null + } else { + error.value = 'Fehler beim Laden des Wochenplans.' + } + } finally { + loading.value = false + } + } + + async function generatePlan(): Promise { + generating.value = true + error.value = null + try { + currentPlan.value = await api.post('/mealplans/generate') + } catch { + error.value = 'Fehler beim Generieren des Plans.' + } finally { + generating.value = false + } + } + + async function swapMeal(entryId: number, recipeId: number): Promise { + error.value = null + try { + const updated = await api.put(`/mealplans/entries/${entryId}/swap`, { recipeId }) + if (currentPlan.value) { + const idx = currentPlan.value.entries.findIndex(e => e.id === entryId) + if (idx !== -1) { + currentPlan.value.entries[idx] = updated + } + } + } catch { + error.value = 'Fehler beim Austauschen des Rezepts.' + } + } + + async function rerollMeal(entryId: number): Promise { + error.value = null + try { + const updated = await api.post(`/mealplans/entries/${entryId}/reroll`) + if (currentPlan.value) { + const idx = currentPlan.value.entries.findIndex(e => e.id === entryId) + if (idx !== -1) { + currentPlan.value.entries[idx] = updated + } + } + } catch { + error.value = 'Fehler beim Neu-Würfeln des Rezepts.' + } + } + + return { currentPlan, loading, generating, error, fetchCurrentPlan, generatePlan, swapMeal, rerollMeal } +}) + +export const useShoppingStore = defineStore('shopping', () => { + const api = useApi() + + const items = ref([]) + const loading = ref(false) + const error = ref(null) + + async function fetchItems(mealPlanId?: number | string): Promise { + loading.value = true + error.value = null + try { + const path = mealPlanId ? `/shopping/${mealPlanId}` : '/shopping/current' + items.value = await api.get(path) + } catch (e) { + if (e instanceof Error && e.message === 'HTTP 404') { + items.value = [] + } else { + error.value = 'Fehler beim Laden der Einkaufsliste.' + } + } finally { + loading.value = false + } + } + + async function toggleItem(id: number): Promise { + const item = items.value.find(i => i.id === id) + if (!item) return + const prev = item.isChecked + item.isChecked = !prev + try { + await api.put(`/shopping/${id}/toggle`) + } catch { + item.isChecked = prev + error.value = 'Fehler beim Aktualisieren.' + } + } + + const uncheckedCount = () => items.value.filter(i => !i.isChecked).length + + return { items, loading, error, fetchItems, toggleItem, uncheckedCount } +}) + +export const useRecipesStore = defineStore('recipes', () => { + const api = useApi() + + const recipes = ref([]) + const loading = ref(false) + const error = ref(null) + + async function fetchRecipes(): Promise { + loading.value = true + error.value = null + try { + recipes.value = await api.get('/recipes') + } catch { + error.value = 'Fehler beim Laden der Rezepte.' + } finally { + loading.value = false + } + } + + async function createRecipe(data: Omit): Promise { + const created = await api.post('/recipes', data) + recipes.value.push(created) + return created + } + + async function updateRecipe(id: number, data: Partial>): Promise { + const updated = await api.put(`/recipes/${id}`, data) + const idx = recipes.value.findIndex(r => r.id === id) + if (idx !== -1) recipes.value[idx] = updated + } + + async function deleteRecipe(id: number): Promise { + await api.del(`/recipes/${id}`) + recipes.value = recipes.value.filter(r => r.id !== id) + } + + return { recipes, loading, error, fetchRecipes, createRecipe, updateRecipe, deleteRecipe } +}) + +export const useSettingsStore = defineStore('settings', () => { + const api = useApi() + + const householdSize = ref(2) + const loading = ref(false) + const saving = ref(false) + const error = ref(null) + const saved = ref(false) + + async function fetchSettings(): Promise { + loading.value = true + error.value = null + try { + const data = await api.get<{ householdSize: number }>('/settings') + householdSize.value = data.householdSize + } catch { + // use defaults + } finally { + loading.value = false + } + } + + async function saveSettings(): Promise { + saving.value = true + error.value = null + saved.value = false + try { + await api.put('/settings', { householdSize: householdSize.value }) + saved.value = true + setTimeout(() => { saved.value = false }, 2000) + } catch { + error.value = 'Fehler beim Speichern.' + } finally { + saving.value = false + } + } + + return { householdSize, loading, saving, error, saved, fetchSettings, saveSettings } +}) diff --git a/frontend/src/theme.css b/frontend/src/theme.css new file mode 100644 index 0000000..15e8b7e --- /dev/null +++ b/frontend/src/theme.css @@ -0,0 +1,11 @@ +@import "tailwindcss"; +@plugin "@tailwindcss/vite"; + +@theme { + --color-accent: #10b981; + --color-accent-hover: #059669; + --color-accent-dim: #064e3b; + --color-success: #4ade80; + --color-error: #ef4444; + --color-warning: #f59e0b; +} diff --git a/frontend/src/views/RecipesView.vue b/frontend/src/views/RecipesView.vue new file mode 100644 index 0000000..daabf9d --- /dev/null +++ b/frontend/src/views/RecipesView.vue @@ -0,0 +1,369 @@ +