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) <noreply@anthropic.com>
This commit is contained in:
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -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
|
||||||
33
Dockerfile
Normal file
33
Dockerfile
Normal file
@@ -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"]
|
||||||
98
backend/Controllers/MealPlanController.cs
Normal file
98
backend/Controllers/MealPlanController.cs
Normal file
@@ -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<IActionResult> 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<IActionResult> GetCurrent()
|
||||||
|
{
|
||||||
|
var plan = await mealPlanService.GetCurrentPlanAsync(UserId);
|
||||||
|
if (plan is null) return NotFound();
|
||||||
|
return Ok(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{weekStart}")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
backend/Controllers/RecipeController.cs
Normal file
74
backend/Controllers/RecipeController.cs
Normal file
@@ -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<IActionResult> GetOwn()
|
||||||
|
{
|
||||||
|
var recipes = await recipeService.GetOwnRecipesAsync(UserId);
|
||||||
|
return Ok(recipes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var recipe = await recipeService.GetByIdOrFetchAsync(id);
|
||||||
|
if (recipe is null) return NotFound();
|
||||||
|
return Ok(recipe);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
backend/Controllers/SettingsController.cs
Normal file
42
backend/Controllers/SettingsController.cs
Normal file
@@ -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<IActionResult> 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<IActionResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
backend/Controllers/ShoppingListController.cs
Normal file
45
backend/Controllers/ShoppingListController.cs
Normal file
@@ -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<IActionResult> 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<IActionResult> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
backend/Data/AppDbContext.cs
Normal file
71
backend/Data/AppDbContext.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MealPlanner.Models;
|
||||||
|
|
||||||
|
namespace MealPlanner.Data;
|
||||||
|
|
||||||
|
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||||
|
{
|
||||||
|
public DbSet<Recipe> Recipes => Set<Recipe>();
|
||||||
|
public DbSet<RecipeIngredient> RecipeIngredients => Set<RecipeIngredient>();
|
||||||
|
public DbSet<MealPlan> MealPlans => Set<MealPlan>();
|
||||||
|
public DbSet<MealPlanEntry> MealPlanEntries => Set<MealPlanEntry>();
|
||||||
|
public DbSet<UserSettings> UserSettings => Set<UserSettings>();
|
||||||
|
public DbSet<CheckedShoppingItem> CheckedShoppingItems => Set<CheckedShoppingItem>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
// Recipe
|
||||||
|
modelBuilder.Entity<Recipe>(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<RecipeIngredient>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(i => i.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// MealPlan
|
||||||
|
modelBuilder.Entity<MealPlan>(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<MealPlanEntry>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(en => en.Id);
|
||||||
|
e.HasOne(en => en.Recipe)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(en => en.RecipeId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
// UserSettings
|
||||||
|
modelBuilder.Entity<UserSettings>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(s => s.UserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// CheckedShoppingItem
|
||||||
|
modelBuilder.Entity<CheckedShoppingItem>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(c => c.Id);
|
||||||
|
e.HasIndex(c => new { c.MealPlanId, c.UserId, c.ItemName });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
225
backend/Migrations/20260414190645_InitialCreate.Designer.cs
generated
Normal file
225
backend/Migrations/20260414190645_InitialCreate.Designer.cs
generated
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("IsChecked")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("ItemName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("MealPlanId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MealPlanId", "UserId", "ItemName");
|
||||||
|
|
||||||
|
b.ToTable("CheckedShoppingItems");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MealPlanner.Models.MealPlan", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("WeekStart")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "WeekStart")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("MealPlans");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MealPlanner.Models.MealPlanEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("Date")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<Guid>("MealPlanId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("RecipeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MealPlanId");
|
||||||
|
|
||||||
|
b.HasIndex("RecipeId");
|
||||||
|
|
||||||
|
b.ToTable("MealPlanEntries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MealPlanner.Models.Recipe", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ImageUrl")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Instructions")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("Source")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ExternalId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Recipes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MealPlanner.Models.RecipeIngredient", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Amount")
|
||||||
|
.HasColumnType("numeric");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("RecipeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Unit")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RecipeId");
|
||||||
|
|
||||||
|
b.ToTable("RecipeIngredients");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MealPlanner.Models.UserSettings", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
180
backend/Migrations/20260414190645_InitialCreate.cs
Normal file
180
backend/Migrations/20260414190645_InitialCreate.cs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace backend.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialCreate : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CheckedShoppingItems",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
MealPlanId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ItemName = table.Column<string>(type: "text", nullable: false),
|
||||||
|
UserId = table.Column<string>(type: "text", nullable: false),
|
||||||
|
IsChecked = table.Column<bool>(type: "boolean", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CheckedShoppingItems", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "MealPlans",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
UserId = table.Column<string>(type: "text", nullable: false),
|
||||||
|
WeekStart = table.Column<DateOnly>(type: "date", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||||
|
UserId = table.Column<string>(type: "text", nullable: true),
|
||||||
|
ExternalId = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Title = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Instructions = table.Column<string>(type: "text", nullable: false),
|
||||||
|
ImageUrl = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Source = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(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<string>(type: "text", nullable: false),
|
||||||
|
HouseholdSize = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_UserSettings", x => x.UserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "MealPlanEntries",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
MealPlanId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Date = table.Column<DateOnly>(type: "date", nullable: false),
|
||||||
|
RecipeId = table.Column<Guid>(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<Guid>(type: "uuid", nullable: false),
|
||||||
|
RecipeId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Amount = table.Column<decimal>(type: "numeric", nullable: true),
|
||||||
|
Unit = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Category = table.Column<string>(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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
222
backend/Migrations/AppDbContextModelSnapshot.cs
Normal file
222
backend/Migrations/AppDbContextModelSnapshot.cs
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("IsChecked")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("ItemName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("MealPlanId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MealPlanId", "UserId", "ItemName");
|
||||||
|
|
||||||
|
b.ToTable("CheckedShoppingItems");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MealPlanner.Models.MealPlan", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("WeekStart")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "WeekStart")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("MealPlans");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MealPlanner.Models.MealPlanEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("Date")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<Guid>("MealPlanId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("RecipeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MealPlanId");
|
||||||
|
|
||||||
|
b.HasIndex("RecipeId");
|
||||||
|
|
||||||
|
b.ToTable("MealPlanEntries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MealPlanner.Models.Recipe", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ImageUrl")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Instructions")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("Source")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ExternalId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("Recipes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MealPlanner.Models.RecipeIngredient", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Amount")
|
||||||
|
.HasColumnType("numeric");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid>("RecipeId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Unit")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RecipeId");
|
||||||
|
|
||||||
|
b.ToTable("RecipeIngredients");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("MealPlanner.Models.UserSettings", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/Models/CheckedShoppingItem.cs
Normal file
10
backend/Models/CheckedShoppingItem.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
11
backend/Models/MealPlan.cs
Normal file
11
backend/Models/MealPlan.cs
Normal file
@@ -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<MealPlanEntry> Entries { get; set; } = [];
|
||||||
|
}
|
||||||
12
backend/Models/MealPlanEntry.cs
Normal file
12
backend/Models/MealPlanEntry.cs
Normal file
@@ -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!;
|
||||||
|
}
|
||||||
21
backend/Models/Recipe.cs
Normal file
21
backend/Models/Recipe.cs
Normal file
@@ -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<RecipeIngredient> Ingredients { get; set; } = [];
|
||||||
|
}
|
||||||
13
backend/Models/RecipeIngredient.cs
Normal file
13
backend/Models/RecipeIngredient.cs
Normal file
@@ -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!;
|
||||||
|
}
|
||||||
10
backend/Models/ShoppingItem.cs
Normal file
10
backend/Models/ShoppingItem.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
7
backend/Models/UserSettings.cs
Normal file
7
backend/Models/UserSettings.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace MealPlanner.Models;
|
||||||
|
|
||||||
|
public class UserSettings
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = "";
|
||||||
|
public int HouseholdSize { get; set; } = 2;
|
||||||
|
}
|
||||||
70
backend/Program.cs
Normal file
70
backend/Program.cs
Normal file
@@ -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<AppDbContext>(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<TheMealDbClient>();
|
||||||
|
builder.Services.AddScoped<RecipeService>();
|
||||||
|
builder.Services.AddScoped<MealPlanService>();
|
||||||
|
builder.Services.AddScoped<ShoppingListService>();
|
||||||
|
|
||||||
|
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<AppDbContext>();
|
||||||
|
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();
|
||||||
13
backend/Properties/launchSettings.json
Normal file
13
backend/Properties/launchSettings.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "http://localhost:8080",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
180
backend/Services/MealPlanService.cs
Normal file
180
backend/Services/MealPlanService.cs
Normal file
@@ -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<MealPlan> 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<Guid>();
|
||||||
|
var entries = new List<MealPlanEntry>();
|
||||||
|
|
||||||
|
// 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<MealPlan?> GetCurrentPlanAsync(string userId)
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
var weekStart = GetWeekStart(today);
|
||||||
|
return await GetPlanAsync(userId, weekStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MealPlan?> 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<MealPlanEntry?> 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<MealPlanEntry?> 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<MealPlan?> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
backend/Services/RecipeService.cs
Normal file
128
backend/Services/RecipeService.cs
Normal file
@@ -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<List<Recipe>> GetOwnRecipesAsync(string userId)
|
||||||
|
{
|
||||||
|
return await db.Recipes
|
||||||
|
.Include(r => r.Ingredients)
|
||||||
|
.Where(r => r.UserId == userId)
|
||||||
|
.OrderByDescending(r => r.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Recipe?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
return await db.Recipes
|
||||||
|
.Include(r => r.Ingredients)
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Recipe?> GetByIdOrFetchAsync(Guid id)
|
||||||
|
{
|
||||||
|
var recipe = await GetByIdAsync(id);
|
||||||
|
return recipe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Recipe>> 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<Recipe> 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<Recipe?> 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<bool> 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<Recipe> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
backend/Services/ShoppingListService.cs
Normal file
91
backend/Services/ShoppingListService.cs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MealPlanner.Data;
|
||||||
|
using MealPlanner.Models;
|
||||||
|
|
||||||
|
namespace MealPlanner.Services;
|
||||||
|
|
||||||
|
public class ShoppingListService(AppDbContext db)
|
||||||
|
{
|
||||||
|
public async Task<List<ShoppingItem>> 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<string, ShoppingItem>(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<CheckedShoppingItem> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
134
backend/Services/TheMealDbClient.cs
Normal file
134
backend/Services/TheMealDbClient.cs
Normal file
@@ -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<Recipe?> 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<List<Recipe>> 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<Recipe?> 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<Recipe> ParseMeals(string json)
|
||||||
|
{
|
||||||
|
var result = new List<Recipe>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
backend/appsettings.Development.json
Normal file
6
backend/appsettings.Development.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Host=localhost;Port=54320;Database=mealplanner;Username=mika;Password=changeme"
|
||||||
|
},
|
||||||
|
"AllowedOrigin": "http://localhost:5173"
|
||||||
|
}
|
||||||
16
backend/appsettings.json
Normal file
16
backend/appsettings.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/backend.csproj
Normal file
16
backend/backend.csproj
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.*" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.*">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.*" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -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
|
||||||
70
docs/superpowers/specs/2026-04-14-mealplanner-plan.md
Normal file
70
docs/superpowers/specs/2026-04-14-mealplanner-plan.md
Normal file
@@ -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)
|
||||||
315
frontend/bun.lock
Normal file
315
frontend/bun.lock
Normal file
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Mealplanner</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
234
frontend/src/App.vue
Normal file
234
frontend/src/App.vue
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Auth loading -->
|
||||||
|
<div v-if="isLoading" class="min-h-screen bg-zinc-950 flex items-center justify-center">
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<svg class="w-10 h-10 text-accent animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-zinc-400 text-sm">Wird geladen...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Not authenticated -->
|
||||||
|
<div v-else-if="!isAuthenticated" class="min-h-screen bg-zinc-950 flex items-center justify-center p-4">
|
||||||
|
<div class="w-full max-w-sm bg-zinc-900 border border-zinc-800 rounded-2xl p-8 flex flex-col items-center gap-6">
|
||||||
|
<!-- App icon -->
|
||||||
|
<div class="p-4 bg-accent-dim rounded-xl">
|
||||||
|
<svg class="w-10 h-10 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-2xl font-bold text-zinc-100">Mealplanner</h1>
|
||||||
|
<p class="text-zinc-500 text-sm mt-1">Plane deine Woche, stressfrei.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="authError" class="w-full px-4 py-3 bg-red-950 border border-error rounded-lg text-error text-sm text-center">
|
||||||
|
{{ authError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="login()"
|
||||||
|
class="w-full flex items-center justify-center gap-2 py-3 rounded-lg bg-accent hover:bg-accent-hover text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<!-- User icon -->
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
Anmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Authenticated app -->
|
||||||
|
<div v-else class="flex h-screen bg-zinc-950 overflow-hidden">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside
|
||||||
|
class="flex flex-col bg-zinc-900 border-r border-zinc-800 transition-all duration-300 z-20"
|
||||||
|
:class="sidebarOpen ? 'w-56' : 'w-14'"
|
||||||
|
>
|
||||||
|
<!-- Logo / toggle -->
|
||||||
|
<div class="flex items-center h-14 px-3 border-b border-zinc-800">
|
||||||
|
<button
|
||||||
|
@click="sidebarOpen = !sidebarOpen"
|
||||||
|
class="p-2 rounded-lg text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors flex-shrink-0"
|
||||||
|
:title="sidebarOpen ? 'Sidebar schließen' : 'Sidebar öffnen'"
|
||||||
|
>
|
||||||
|
<!-- Menu icon -->
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<Transition name="fade-text">
|
||||||
|
<span v-if="sidebarOpen" class="ml-3 font-bold text-zinc-100 text-sm whitespace-nowrap overflow-hidden">Mealplanner</span>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nav -->
|
||||||
|
<nav class="flex-1 py-4 space-y-1 px-2">
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
class="flex items-center gap-3 px-2 py-2.5 rounded-lg transition-colors group"
|
||||||
|
:class="isActiveRoute(item.to)
|
||||||
|
? 'bg-accent-dim text-accent'
|
||||||
|
: 'text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800'"
|
||||||
|
:title="!sidebarOpen ? item.label : undefined"
|
||||||
|
>
|
||||||
|
<span class="flex-shrink-0 w-5 h-5" v-html="item.icon" />
|
||||||
|
<Transition name="fade-text">
|
||||||
|
<span v-if="sidebarOpen" class="text-sm font-medium whitespace-nowrap overflow-hidden">{{ item.label }}</span>
|
||||||
|
</Transition>
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- User footer -->
|
||||||
|
<div class="border-t border-zinc-800 p-2">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 px-2 py-2.5 rounded-lg"
|
||||||
|
:class="sidebarOpen ? 'bg-zinc-800/50' : ''"
|
||||||
|
>
|
||||||
|
<div class="flex-shrink-0 w-7 h-7 rounded-full bg-accent-dim flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<Transition name="fade-text">
|
||||||
|
<div v-if="sidebarOpen" class="flex-1 min-w-0 overflow-hidden">
|
||||||
|
<p class="text-xs font-medium text-zinc-200 truncate">{{ currentUser?.name || 'Benutzer' }}</p>
|
||||||
|
<p v-if="currentUser?.email" class="text-xs text-zinc-500 truncate">{{ currentUser.email }}</p>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
<Transition name="fade-text">
|
||||||
|
<button
|
||||||
|
v-if="sidebarOpen"
|
||||||
|
@click="logout()"
|
||||||
|
class="flex-shrink-0 p-1.5 rounded text-zinc-500 hover:text-error hover:bg-zinc-800 transition-colors"
|
||||||
|
title="Abmelden"
|
||||||
|
>
|
||||||
|
<!-- Logout icon -->
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Mobile overlay -->
|
||||||
|
<div
|
||||||
|
v-if="sidebarOpen && isMobile"
|
||||||
|
class="fixed inset-0 z-10 bg-black/50"
|
||||||
|
@click="sidebarOpen = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="flex-1 overflow-y-auto">
|
||||||
|
<div class="p-6 max-w-7xl mx-auto">
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { isAuthenticated, isLoading, authError, currentUser, login, logout } from './auth'
|
||||||
|
|
||||||
|
const sidebarOpen = ref(true)
|
||||||
|
const windowWidth = ref(window.innerWidth)
|
||||||
|
|
||||||
|
function onResize(): void {
|
||||||
|
windowWidth.value = window.innerWidth
|
||||||
|
if (window.innerWidth < 768) {
|
||||||
|
sidebarOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
if (window.innerWidth < 768) sidebarOpen.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', onResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMobile = computed(() => windowWidth.value < 768)
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
function isActiveRoute(path: string): boolean {
|
||||||
|
if (path === '/') return route.path === '/'
|
||||||
|
return route.path.startsWith(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
to: '/',
|
||||||
|
label: 'Wochenplan',
|
||||||
|
icon: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/shopping',
|
||||||
|
label: 'Einkaufsliste',
|
||||||
|
icon: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/recipes',
|
||||||
|
label: 'Rezepte',
|
||||||
|
icon: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0118 18a8.966 8.966 0 00-6 2.292m0-14.25v14.25" />
|
||||||
|
</svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/settings',
|
||||||
|
label: 'Einstellungen',
|
||||||
|
icon: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-5 h-5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
background-color: #09090b;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-text-enter-active {
|
||||||
|
transition: opacity 0.15s ease 0.1s, max-width 0.2s ease;
|
||||||
|
}
|
||||||
|
.fade-text-leave-active {
|
||||||
|
transition: opacity 0.1s ease, max-width 0.2s ease;
|
||||||
|
}
|
||||||
|
.fade-text-enter-from,
|
||||||
|
.fade-text-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
max-width: 0;
|
||||||
|
}
|
||||||
|
.fade-text-enter-to,
|
||||||
|
.fade-text-leave-from {
|
||||||
|
opacity: 1;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
185
frontend/src/auth.ts
Normal file
185
frontend/src/auth.ts
Normal file
@@ -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<string | null>(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<string> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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)}`
|
||||||
|
}
|
||||||
82
frontend/src/components/MealCard.vue
Normal file
82
frontend/src/components/MealCard.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden shadow-md hover:border-zinc-700 transition-colors">
|
||||||
|
<!-- Day header -->
|
||||||
|
<div class="px-4 py-2 bg-zinc-800/60 border-b border-zinc-800">
|
||||||
|
<span class="text-sm font-semibold text-zinc-300 uppercase tracking-wide">{{ dayName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image -->
|
||||||
|
<div class="relative w-full h-40 bg-zinc-800 overflow-hidden">
|
||||||
|
<img
|
||||||
|
v-if="entry.recipe.imageUrl"
|
||||||
|
:src="entry.recipe.imageUrl"
|
||||||
|
:alt="entry.recipe.title"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
@error="imgError = true"
|
||||||
|
/>
|
||||||
|
<div v-if="!entry.recipe.imageUrl || imgError" class="w-full h-full flex items-center justify-center">
|
||||||
|
<svg class="w-14 h-14 text-zinc-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.966 8.966 0 00-6 2.292m0-14.25v14.25" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 px-4 py-3">
|
||||||
|
<h3 class="text-zinc-100 font-medium text-base leading-snug line-clamp-2">{{ entry.recipe.title }}</h3>
|
||||||
|
<p v-if="entry.recipe.ingredients?.length" class="mt-1 text-zinc-500 text-xs">
|
||||||
|
{{ entry.recipe.ingredients.length }} Zutaten
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="px-4 py-3 border-t border-zinc-800 flex gap-2">
|
||||||
|
<button
|
||||||
|
@click="$emit('swap', entry)"
|
||||||
|
class="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 hover:text-zinc-100 text-xs font-medium transition-colors"
|
||||||
|
title="Rezept austauschen"
|
||||||
|
>
|
||||||
|
<!-- Swap arrows icon -->
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||||
|
</svg>
|
||||||
|
Tauschen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="$emit('reroll', entry)"
|
||||||
|
class="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 hover:text-zinc-100 text-xs font-medium transition-colors"
|
||||||
|
title="Zufälliges Rezept"
|
||||||
|
>
|
||||||
|
<!-- Dice icon -->
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" stroke-width="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="15.5" cy="8.5" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="8.5" cy="15.5" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="15.5" cy="15.5" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
Würfeln
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { MealPlanEntry } from '../stores/mealPlan'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
entry: MealPlanEntry
|
||||||
|
dayName: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
swap: [entry: MealPlanEntry]
|
||||||
|
reroll: [entry: MealPlanEntry]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const imgError = ref(false)
|
||||||
|
</script>
|
||||||
78
frontend/src/components/RecipeDetail.vue
Normal file
78
frontend/src/components/RecipeDetail.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
|
||||||
|
@click.self="$emit('close')"
|
||||||
|
>
|
||||||
|
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl">
|
||||||
|
<!-- Close button -->
|
||||||
|
<button
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="absolute top-4 right-4 p-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors z-10"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Image -->
|
||||||
|
<div v-if="recipe.imageUrl && !imgError" class="w-full h-56 overflow-hidden rounded-t-2xl">
|
||||||
|
<img
|
||||||
|
:src="recipe.imageUrl"
|
||||||
|
:alt="recipe.title"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
@error="imgError = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="w-full h-40 bg-zinc-800 rounded-t-2xl flex items-center justify-center">
|
||||||
|
<svg class="w-16 h-16 text-zinc-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.966 8.966 0 00-6 2.292m0-14.25v14.25" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<h2 class="text-2xl font-bold text-zinc-100 mb-4">{{ recipe.title }}</h2>
|
||||||
|
|
||||||
|
<!-- Ingredients -->
|
||||||
|
<div v-if="recipe.ingredients?.length" class="mb-6">
|
||||||
|
<h3 class="text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-3">Zutaten</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(ing, idx) in recipe.ingredients"
|
||||||
|
:key="idx"
|
||||||
|
class="flex items-center gap-3 py-1.5 px-3 rounded-lg bg-zinc-800/50"
|
||||||
|
>
|
||||||
|
<span class="text-zinc-300 flex-1">{{ ing.name }}</span>
|
||||||
|
<span class="text-zinc-400 text-sm">{{ ing.amount }} {{ ing.unit }}</span>
|
||||||
|
<span v-if="ing.category" class="text-xs text-zinc-600 bg-zinc-800 px-2 py-0.5 rounded-full">{{ ing.category }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instructions -->
|
||||||
|
<div v-if="recipe.instructions">
|
||||||
|
<h3 class="text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-3">Zubereitung</h3>
|
||||||
|
<p class="text-zinc-300 leading-relaxed whitespace-pre-wrap">{{ recipe.instructions }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Recipe } from '../stores/mealPlan'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
recipe: Recipe
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const imgError = ref(false)
|
||||||
|
</script>
|
||||||
42
frontend/src/components/ShoppingItem.vue
Normal file
42
frontend/src/components/ShoppingItem.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 py-2 px-3 rounded-lg hover:bg-zinc-800/50 transition-colors group cursor-pointer"
|
||||||
|
@click="$emit('toggle', item.id)"
|
||||||
|
>
|
||||||
|
<!-- Checkbox -->
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-all"
|
||||||
|
:class="item.isChecked
|
||||||
|
? 'bg-accent border-accent'
|
||||||
|
: 'border-zinc-600 group-hover:border-zinc-400'"
|
||||||
|
>
|
||||||
|
<svg v-if="item.isChecked" class="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name -->
|
||||||
|
<span
|
||||||
|
class="flex-1 text-sm transition-colors"
|
||||||
|
:class="item.isChecked ? 'line-through text-zinc-600' : 'text-zinc-200'"
|
||||||
|
>{{ item.name }}</span>
|
||||||
|
|
||||||
|
<!-- Amount + unit -->
|
||||||
|
<span
|
||||||
|
class="text-sm tabular-nums transition-colors"
|
||||||
|
:class="item.isChecked ? 'text-zinc-700' : 'text-zinc-400'"
|
||||||
|
>{{ item.amount }} {{ item.unit }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ShoppingItem } from '../stores/mealPlan'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
item: ShoppingItem
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
toggle: [id: number]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
88
frontend/src/components/SwapModal.vue
Normal file
88
frontend/src/components/SwapModal.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
|
||||||
|
@click.self="$emit('close')"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
|
||||||
|
<h2 class="text-lg font-semibold text-zinc-100">Rezept austauschen</h2>
|
||||||
|
<button
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
type="text"
|
||||||
|
placeholder="Rezept suchen..."
|
||||||
|
class="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-4 py-2.5 text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-accent text-sm"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recipe list -->
|
||||||
|
<div class="px-6 pb-4 max-h-80 overflow-y-auto space-y-1">
|
||||||
|
<div v-if="!filtered.length" class="text-center py-8 text-zinc-500 text-sm">
|
||||||
|
Keine Rezepte gefunden.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="recipe in filtered"
|
||||||
|
:key="recipe.id"
|
||||||
|
@click="$emit('select', recipe.id)"
|
||||||
|
class="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-zinc-800 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-zinc-800 overflow-hidden flex-shrink-0">
|
||||||
|
<img
|
||||||
|
v-if="recipe.imageUrl"
|
||||||
|
:src="recipe.imageUrl"
|
||||||
|
:alt="recipe.title"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.966 8.966 0 00-6 2.292m0-14.25v14.25" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-zinc-100 text-sm font-medium truncate">{{ recipe.title }}</p>
|
||||||
|
<p v-if="recipe.ingredients?.length" class="text-zinc-500 text-xs">{{ recipe.ingredients.length }} Zutaten</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { Recipe } from '../stores/mealPlan'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
recipes: Recipe[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
close: []
|
||||||
|
select: [recipeId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const q = query.value.toLowerCase().trim()
|
||||||
|
if (!q) return props.recipes
|
||||||
|
return props.recipes.filter(r => r.title.toLowerCase().includes(q))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
24
frontend/src/composables/useApi.ts
Normal file
24
frontend/src/composables/useApi.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { getAccessToken, login } from '../auth'
|
||||||
|
|
||||||
|
const BASE = '/api'
|
||||||
|
|
||||||
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
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<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApi() {
|
||||||
|
return {
|
||||||
|
get: <T>(path: string) => request<T>(path),
|
||||||
|
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }),
|
||||||
|
put: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PUT', ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }),
|
||||||
|
del: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/src/env.d.ts
vendored
Normal file
10
frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_ZITADEL_ISSUER: string
|
||||||
|
readonly VITE_ZITADEL_CLIENT_ID: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
11
frontend/src/main.ts
Normal file
11
frontend/src/main.ts
Normal file
@@ -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'))
|
||||||
16
frontend/src/router.ts
Normal file
16
frontend/src/router.ts
Normal file
@@ -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: '/' },
|
||||||
|
],
|
||||||
|
})
|
||||||
232
frontend/src/stores/mealPlan.ts
Normal file
232
frontend/src/stores/mealPlan.ts
Normal file
@@ -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<MealPlan | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const generating = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function fetchCurrentPlan(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
currentPlan.value = await api.get<MealPlan>('/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<void> {
|
||||||
|
generating.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
currentPlan.value = await api.post<MealPlan>('/mealplans/generate')
|
||||||
|
} catch {
|
||||||
|
error.value = 'Fehler beim Generieren des Plans.'
|
||||||
|
} finally {
|
||||||
|
generating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function swapMeal(entryId: number, recipeId: number): Promise<void> {
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const updated = await api.put<MealPlanEntry>(`/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<void> {
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const updated = await api.post<MealPlanEntry>(`/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<ShoppingItem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function fetchItems(mealPlanId?: number | string): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const path = mealPlanId ? `/shopping/${mealPlanId}` : '/shopping/current'
|
||||||
|
items.value = await api.get<ShoppingItem[]>(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<void> {
|
||||||
|
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<Recipe[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function fetchRecipes(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
recipes.value = await api.get<Recipe[]>('/recipes')
|
||||||
|
} catch {
|
||||||
|
error.value = 'Fehler beim Laden der Rezepte.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRecipe(data: Omit<Recipe, 'id'>): Promise<Recipe> {
|
||||||
|
const created = await api.post<Recipe>('/recipes', data)
|
||||||
|
recipes.value.push(created)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateRecipe(id: number, data: Partial<Omit<Recipe, 'id'>>): Promise<void> {
|
||||||
|
const updated = await api.put<Recipe>(`/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<void> {
|
||||||
|
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<string | null>(null)
|
||||||
|
const saved = ref(false)
|
||||||
|
|
||||||
|
async function fetchSettings(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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 }
|
||||||
|
})
|
||||||
11
frontend/src/theme.css
Normal file
11
frontend/src/theme.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
369
frontend/src/views/RecipesView.vue
Normal file
369
frontend/src/views/RecipesView.vue
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-zinc-100">Meine Rezepte</h1>
|
||||||
|
<button
|
||||||
|
@click="openCreate"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<!-- Plus icon -->
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Neues Rezept
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-if="store.error" class="px-4 py-3 bg-red-950 border border-error rounded-lg text-error text-sm">
|
||||||
|
{{ store.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
<div v-for="i in 6" :key="i" class="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden animate-pulse">
|
||||||
|
<div class="h-40 bg-zinc-800" />
|
||||||
|
<div class="p-4 space-y-2">
|
||||||
|
<div class="h-4 bg-zinc-800 rounded w-3/4" />
|
||||||
|
<div class="h-3 bg-zinc-800 rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-else-if="!store.recipes.length" class="flex flex-col items-center justify-center py-24 gap-4">
|
||||||
|
<div class="p-6 bg-zinc-900 rounded-full border border-zinc-800">
|
||||||
|
<svg class="w-12 h-12 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.966 8.966 0 00-6 2.292m0-14.25v14.25" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-zinc-300 text-lg font-medium">Keine Rezepte vorhanden</p>
|
||||||
|
<p class="text-zinc-500 text-sm mt-1">Erstelle dein erstes Rezept.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recipe grid -->
|
||||||
|
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="recipe in store.recipes"
|
||||||
|
:key="recipe.id"
|
||||||
|
class="flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden shadow-md hover:border-zinc-700 cursor-pointer transition-colors"
|
||||||
|
@click="selectedRecipe = recipe"
|
||||||
|
>
|
||||||
|
<!-- Image -->
|
||||||
|
<div class="relative h-40 bg-zinc-800 overflow-hidden">
|
||||||
|
<img
|
||||||
|
v-if="recipe.imageUrl && !imgErrors.has(recipe.id)"
|
||||||
|
:src="recipe.imageUrl"
|
||||||
|
:alt="recipe.title"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
@error="imgErrors.add(recipe.id)"
|
||||||
|
/>
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center">
|
||||||
|
<svg class="w-12 h-12 text-zinc-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.966 8.966 0 00-6 2.292m0-14.25v14.25" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="flex-1 px-4 py-3">
|
||||||
|
<h3 class="text-zinc-100 font-medium text-sm leading-snug line-clamp-2">{{ recipe.title }}</h3>
|
||||||
|
<p v-if="recipe.ingredients?.length" class="mt-1 text-zinc-500 text-xs">
|
||||||
|
{{ recipe.ingredients.length }} Zutaten
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit/Delete -->
|
||||||
|
<div class="px-4 py-3 border-t border-zinc-800 flex gap-2" @click.stop>
|
||||||
|
<button
|
||||||
|
@click="openEdit(recipe)"
|
||||||
|
class="flex-1 flex items-center justify-center gap-1 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 hover:text-zinc-100 text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="handleDelete(recipe.id)"
|
||||||
|
class="flex items-center justify-center px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-red-950 text-zinc-500 hover:text-error transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recipe detail modal -->
|
||||||
|
<RecipeDetail
|
||||||
|
v-if="selectedRecipe"
|
||||||
|
:recipe="selectedRecipe"
|
||||||
|
@close="selectedRecipe = null"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Create/Edit modal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="showForm"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
|
||||||
|
@click.self="closeForm"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl">
|
||||||
|
<!-- Form header -->
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-zinc-800 sticky top-0 bg-zinc-900 z-10">
|
||||||
|
<h2 class="text-lg font-semibold text-zinc-100">
|
||||||
|
{{ editingId ? 'Rezept bearbeiten' : 'Neues Rezept' }}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
@click="closeForm"
|
||||||
|
class="p-1.5 rounded-lg text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="p-6 space-y-5">
|
||||||
|
<!-- Title -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-zinc-300 mb-1.5">Titel *</label>
|
||||||
|
<input
|
||||||
|
v-model="form.title"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
placeholder="Rezeptname"
|
||||||
|
class="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-4 py-2.5 text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-accent text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image URL -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-zinc-300 mb-1.5">Bild-URL</label>
|
||||||
|
<input
|
||||||
|
v-model="form.imageUrl"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://..."
|
||||||
|
class="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-4 py-2.5 text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-accent text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instructions -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-zinc-300 mb-1.5">Zubereitung</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.instructions"
|
||||||
|
rows="5"
|
||||||
|
placeholder="Zubereitungsschritte..."
|
||||||
|
class="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-4 py-2.5 text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-accent text-sm resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ingredients -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<label class="text-sm font-medium text-zinc-300">Zutaten</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="addIngredient"
|
||||||
|
class="flex items-center gap-1 text-xs text-accent hover:text-accent-hover transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Zutat hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(ing, idx) in form.ingredients"
|
||||||
|
:key="idx"
|
||||||
|
class="flex gap-2 items-start"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="ing.name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Name"
|
||||||
|
class="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-accent text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model.number="ing.amount"
|
||||||
|
type="number"
|
||||||
|
placeholder="Menge"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
class="w-20 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-accent text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="ing.unit"
|
||||||
|
type="text"
|
||||||
|
placeholder="Einheit"
|
||||||
|
class="w-20 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-accent text-sm"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
v-model="ing.category"
|
||||||
|
class="w-32 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 focus:outline-none focus:border-accent text-sm"
|
||||||
|
>
|
||||||
|
<option value="Gemüse">Gemüse</option>
|
||||||
|
<option value="Fleisch">Fleisch</option>
|
||||||
|
<option value="Milchprodukte">Milchprodukte</option>
|
||||||
|
<option value="Gewürze">Gewürze</option>
|
||||||
|
<option value="Sonstiges">Sonstiges</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="removeIngredient(idx)"
|
||||||
|
class="p-2 text-zinc-500 hover:text-error transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form error -->
|
||||||
|
<div v-if="formError" class="px-4 py-3 bg-red-950 border border-error rounded-lg text-error text-sm">
|
||||||
|
{{ formError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeForm"
|
||||||
|
class="flex-1 py-2.5 rounded-lg border border-zinc-700 text-zinc-300 hover:text-zinc-100 hover:bg-zinc-800 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="formSaving"
|
||||||
|
class="flex-1 py-2.5 rounded-lg bg-accent hover:bg-accent-hover disabled:opacity-60 text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{{ formSaving ? 'Speichern...' : 'Speichern' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import RecipeDetail from '../components/RecipeDetail.vue'
|
||||||
|
import { useRecipesStore } from '../stores/mealPlan'
|
||||||
|
import type { Recipe, Ingredient } from '../stores/mealPlan'
|
||||||
|
|
||||||
|
const store = useRecipesStore()
|
||||||
|
const selectedRecipe = ref<Recipe | null>(null)
|
||||||
|
const imgErrors = ref(new Set<number>())
|
||||||
|
|
||||||
|
const showForm = ref(false)
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
const formSaving = ref(false)
|
||||||
|
const formError = ref<string | null>(null)
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
title: string
|
||||||
|
imageUrl: string
|
||||||
|
instructions: string
|
||||||
|
ingredients: Array<{
|
||||||
|
name: string
|
||||||
|
amount: number
|
||||||
|
unit: string
|
||||||
|
category: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = reactive<FormState>({
|
||||||
|
title: '',
|
||||||
|
imageUrl: '',
|
||||||
|
instructions: '',
|
||||||
|
ingredients: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetForm(): void {
|
||||||
|
form.title = ''
|
||||||
|
form.imageUrl = ''
|
||||||
|
form.instructions = ''
|
||||||
|
form.ingredients = []
|
||||||
|
editingId.value = null
|
||||||
|
formError.value = null
|
||||||
|
formSaving.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate(): void {
|
||||||
|
resetForm()
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(recipe: Recipe): void {
|
||||||
|
resetForm()
|
||||||
|
editingId.value = recipe.id
|
||||||
|
form.title = recipe.title
|
||||||
|
form.imageUrl = recipe.imageUrl ?? ''
|
||||||
|
form.instructions = recipe.instructions ?? ''
|
||||||
|
form.ingredients = (recipe.ingredients ?? []).map(i => ({ ...i }))
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm(): void {
|
||||||
|
showForm.value = false
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addIngredient(): void {
|
||||||
|
form.ingredients.push({ name: '', amount: 1, unit: 'g', category: 'Sonstiges' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeIngredient(idx: number): void {
|
||||||
|
form.ingredients.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(): Promise<void> {
|
||||||
|
formSaving.value = true
|
||||||
|
formError.value = null
|
||||||
|
try {
|
||||||
|
const data: Omit<Recipe, 'id'> = {
|
||||||
|
title: form.title,
|
||||||
|
...(form.imageUrl ? { imageUrl: form.imageUrl } : {}),
|
||||||
|
...(form.instructions ? { instructions: form.instructions } : {}),
|
||||||
|
ingredients: form.ingredients.filter(i => i.name.trim()) as Ingredient[],
|
||||||
|
}
|
||||||
|
if (editingId.value !== null) {
|
||||||
|
await store.updateRecipe(editingId.value, data)
|
||||||
|
} else {
|
||||||
|
await store.createRecipe(data)
|
||||||
|
}
|
||||||
|
closeForm()
|
||||||
|
} catch {
|
||||||
|
formError.value = 'Fehler beim Speichern des Rezepts.'
|
||||||
|
} finally {
|
||||||
|
formSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number): Promise<void> {
|
||||||
|
if (!confirm('Rezept wirklich löschen?')) return
|
||||||
|
await store.deleteRecipe(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.fetchRecipes()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
103
frontend/src/views/SettingsView.vue
Normal file
103
frontend/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-6 max-w-lg">
|
||||||
|
<!-- Header -->
|
||||||
|
<h1 class="text-2xl font-bold text-zinc-100">Einstellungen</h1>
|
||||||
|
|
||||||
|
<!-- Card -->
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-sm font-semibold text-zinc-400 uppercase tracking-wide mb-4">Haushalt</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-zinc-300 mb-1.5">Haushaltsgröße</label>
|
||||||
|
<p class="text-xs text-zinc-500 mb-3">Anzahl der Personen, für die der Wochenplan geplant wird.</p>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="decrement"
|
||||||
|
:disabled="store.householdSize <= 1"
|
||||||
|
class="w-9 h-9 flex items-center justify-center rounded-lg bg-zinc-800 hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed text-zinc-300 transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
v-model.number="store.householdSize"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
class="w-20 text-center bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 focus:outline-none focus:border-accent text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="increment"
|
||||||
|
:disabled="store.householdSize >= 20"
|
||||||
|
class="w-9 h-9 flex items-center justify-center rounded-lg bg-zinc-800 hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed text-zinc-300 transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="text-zinc-500 text-sm">
|
||||||
|
{{ store.householdSize === 1 ? 'Person' : 'Personen' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-if="store.error" class="px-4 py-3 bg-red-950 border border-error rounded-lg text-error text-sm">
|
||||||
|
{{ store.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="store.saved" class="px-4 py-3 bg-accent-dim border border-accent rounded-lg text-success text-sm flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
Einstellungen gespeichert.
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="store.saveSettings"
|
||||||
|
:disabled="store.saving"
|
||||||
|
class="w-full py-2.5 rounded-lg bg-accent hover:bg-accent-hover disabled:opacity-60 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{{ store.saving ? 'Speichern...' : 'Speichern' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useSettingsStore } from '../stores/mealPlan'
|
||||||
|
|
||||||
|
const store = useSettingsStore()
|
||||||
|
|
||||||
|
function decrement(): void {
|
||||||
|
if (store.householdSize > 1) store.householdSize--
|
||||||
|
}
|
||||||
|
|
||||||
|
function increment(): void {
|
||||||
|
if (store.householdSize < 20) store.householdSize++
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.fetchSettings()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
147
frontend/src/views/ShoppingListView.vue
Normal file
147
frontend/src/views/ShoppingListView.vue
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h1 class="text-2xl font-bold text-zinc-100">Einkaufsliste</h1>
|
||||||
|
<span
|
||||||
|
v-if="unchecked > 0"
|
||||||
|
class="inline-flex items-center justify-center min-w-6 h-6 px-2 rounded-full bg-accent text-white text-xs font-bold"
|
||||||
|
>{{ unchecked }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-if="store.error" class="px-4 py-3 bg-red-950 border border-error rounded-lg text-error text-sm">
|
||||||
|
{{ store.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.loading" class="space-y-4">
|
||||||
|
<div v-for="i in 3" :key="i" class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 animate-pulse space-y-3">
|
||||||
|
<div class="h-4 bg-zinc-800 rounded w-1/4" />
|
||||||
|
<div v-for="j in 4" :key="j" class="h-3 bg-zinc-800/60 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-else-if="!store.items.length" class="flex flex-col items-center justify-center py-24 gap-4">
|
||||||
|
<div class="p-6 bg-zinc-900 rounded-full border border-zinc-800">
|
||||||
|
<svg class="w-12 h-12 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-zinc-300 text-lg font-medium">Keine Einkaufsliste vorhanden</p>
|
||||||
|
<p class="text-zinc-500 text-sm mt-1">Erstelle zuerst einen Wochenplan.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grouped items -->
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-3">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-sm text-zinc-400">Fortschritt</span>
|
||||||
|
<span class="text-sm text-zinc-400">{{ checked }} / {{ store.items.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-2 bg-zinc-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full bg-accent rounded-full transition-all duration-300"
|
||||||
|
:style="{ width: `${progress}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(groupItems, category) in grouped"
|
||||||
|
:key="category"
|
||||||
|
class="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- Category header -->
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center justify-between px-4 py-3 border-b border-zinc-800 hover:bg-zinc-800/40 transition-colors"
|
||||||
|
@click="toggleGroup(String(category))"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium text-zinc-200 text-sm">{{ category }}</span>
|
||||||
|
<span class="text-xs text-zinc-500">
|
||||||
|
({{ groupItems.filter(i => !i.isChecked).length }} verbleibend)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Chevron -->
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 text-zinc-500 transition-transform"
|
||||||
|
:class="{ 'rotate-180': !collapsed.has(String(category)) }"
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Items -->
|
||||||
|
<div v-if="!collapsed.has(String(category))" class="px-2 py-1">
|
||||||
|
<ShoppingItem
|
||||||
|
v-for="item in groupItems"
|
||||||
|
:key="item.id"
|
||||||
|
:item="item"
|
||||||
|
@toggle="store.toggleItem"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import ShoppingItem from '../components/ShoppingItem.vue'
|
||||||
|
import { useShoppingStore } from '../stores/mealPlan'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const store = useShoppingStore()
|
||||||
|
|
||||||
|
const collapsed = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
function toggleGroup(cat: string): void {
|
||||||
|
if (collapsed.value.has(cat)) {
|
||||||
|
collapsed.value.delete(cat)
|
||||||
|
} else {
|
||||||
|
collapsed.value.add(cat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_ORDER = ['Gemüse', 'Fleisch', 'Milchprodukte', 'Gewürze', 'Sonstiges']
|
||||||
|
|
||||||
|
const grouped = computed(() => {
|
||||||
|
const result: Record<string, typeof store.items> = {}
|
||||||
|
for (const item of store.items) {
|
||||||
|
const cat = item.category || 'Sonstiges'
|
||||||
|
if (!result[cat]) result[cat] = []
|
||||||
|
result[cat]!.push(item)
|
||||||
|
}
|
||||||
|
// Sort by defined order, unknown categories go last
|
||||||
|
const sorted: Record<string, typeof store.items> = {}
|
||||||
|
const allCats = Object.keys(result).sort((a, b) => {
|
||||||
|
const ai = CATEGORY_ORDER.indexOf(a)
|
||||||
|
const bi = CATEGORY_ORDER.indexOf(b)
|
||||||
|
if (ai === -1 && bi === -1) return a.localeCompare(b)
|
||||||
|
if (ai === -1) return 1
|
||||||
|
if (bi === -1) return -1
|
||||||
|
return ai - bi
|
||||||
|
})
|
||||||
|
for (const cat of allCats) {
|
||||||
|
sorted[cat] = result[cat]!
|
||||||
|
}
|
||||||
|
return sorted
|
||||||
|
})
|
||||||
|
|
||||||
|
const checked = computed(() => store.items.filter(i => i.isChecked).length)
|
||||||
|
const unchecked = computed(() => store.items.filter(i => !i.isChecked).length)
|
||||||
|
const progress = computed(() => store.items.length ? Math.round((checked.value / store.items.length) * 100) : 0)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const id = route.params['mealPlanId']
|
||||||
|
store.fetchItems(Array.isArray(id) ? id[0] : id)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
164
frontend/src/views/WeekPlanView.vue
Normal file
164
frontend/src/views/WeekPlanView.vue
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-zinc-100">Wochenplan</h1>
|
||||||
|
<p v-if="store.currentPlan" class="text-zinc-500 text-sm mt-0.5">
|
||||||
|
{{ formatWeek(store.currentPlan.weekStartDate) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="handleGenerate"
|
||||||
|
:disabled="store.generating"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-accent hover:bg-accent-hover disabled:opacity-60 disabled:cursor-not-allowed text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<svg v-if="store.generating" class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" stroke-width="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="15.5" cy="8.5" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="8.5" cy="15.5" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="15.5" cy="15.5" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
{{ store.generating ? 'Generiere...' : 'Neue Woche generieren' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-if="store.error" class="px-4 py-3 bg-red-950 border border-error rounded-lg text-error text-sm">
|
||||||
|
{{ store.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading skeleton -->
|
||||||
|
<div v-if="store.loading" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="i in 7"
|
||||||
|
:key="i"
|
||||||
|
class="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden animate-pulse"
|
||||||
|
>
|
||||||
|
<div class="h-8 bg-zinc-800" />
|
||||||
|
<div class="h-40 bg-zinc-800/60" />
|
||||||
|
<div class="p-4 space-y-2">
|
||||||
|
<div class="h-4 bg-zinc-800 rounded w-3/4" />
|
||||||
|
<div class="h-3 bg-zinc-800 rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-3 border-t border-zinc-800 flex gap-2">
|
||||||
|
<div class="flex-1 h-8 bg-zinc-800 rounded-lg" />
|
||||||
|
<div class="flex-1 h-8 bg-zinc-800 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meal grid -->
|
||||||
|
<div
|
||||||
|
v-else-if="store.currentPlan?.entries?.length"
|
||||||
|
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
|
||||||
|
>
|
||||||
|
<MealCard
|
||||||
|
v-for="entry in sortedEntries"
|
||||||
|
:key="entry.id"
|
||||||
|
:entry="entry"
|
||||||
|
:day-name="getDayName(entry.dayOfWeek)"
|
||||||
|
@swap="openSwapModal(entry)"
|
||||||
|
@reroll="handleReroll(entry)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-else-if="!store.loading" class="flex flex-col items-center justify-center py-24 gap-6">
|
||||||
|
<div class="p-6 bg-zinc-900 rounded-full border border-zinc-800">
|
||||||
|
<svg class="w-12 h-12 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-zinc-300 text-lg font-medium">Noch kein Plan erstellt</p>
|
||||||
|
<p class="text-zinc-500 text-sm mt-1">Generiere einen Wochenplan mit zufälligen Rezepten.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="handleGenerate"
|
||||||
|
:disabled="store.generating"
|
||||||
|
class="flex items-center gap-2 px-6 py-3 rounded-lg bg-accent hover:bg-accent-hover disabled:opacity-60 text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" stroke-width="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="15.5" cy="8.5" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="8.5" cy="15.5" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="15.5" cy="15.5" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
Jetzt generieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Swap modal -->
|
||||||
|
<SwapModal
|
||||||
|
v-if="swapEntry && recipesStore.recipes.length"
|
||||||
|
:recipes="recipesStore.recipes"
|
||||||
|
@close="swapEntry = null"
|
||||||
|
@select="handleSwapSelect"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import MealCard from '../components/MealCard.vue'
|
||||||
|
import SwapModal from '../components/SwapModal.vue'
|
||||||
|
import { useMealPlanStore, useRecipesStore } from '../stores/mealPlan'
|
||||||
|
import type { MealPlanEntry } from '../stores/mealPlan'
|
||||||
|
|
||||||
|
const store = useMealPlanStore()
|
||||||
|
const recipesStore = useRecipesStore()
|
||||||
|
|
||||||
|
const swapEntry = ref<MealPlanEntry | null>(null)
|
||||||
|
|
||||||
|
const DAY_NAMES = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']
|
||||||
|
|
||||||
|
function getDayName(dayOfWeek: number): string {
|
||||||
|
return DAY_NAMES[dayOfWeek] ?? `Tag ${dayOfWeek}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedEntries = computed(() => {
|
||||||
|
return [...(store.currentPlan?.entries ?? [])].sort((a, b) => a.dayOfWeek - b.dayOfWeek)
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatWeek(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
const end = new Date(d)
|
||||||
|
end.setDate(end.getDate() + 6)
|
||||||
|
const fmt = (dt: Date) => dt.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
|
||||||
|
return `${fmt(d)} – ${fmt(end)}.${end.getFullYear()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerate(): Promise<void> {
|
||||||
|
await store.generatePlan()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSwapModal(entry: MealPlanEntry): void {
|
||||||
|
swapEntry.value = entry
|
||||||
|
if (!recipesStore.recipes.length) {
|
||||||
|
recipesStore.fetchRecipes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSwapSelect(recipeId: number): Promise<void> {
|
||||||
|
if (!swapEntry.value) return
|
||||||
|
await store.swapMeal(swapEntry.value.id, recipeId)
|
||||||
|
swapEntry.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReroll(entry: MealPlanEntry): Promise<void> {
|
||||||
|
await store.rerollMeal(entry.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.fetchCurrentPlan()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
12
frontend/vite.config.ts
Normal file
12
frontend/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:5000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
30
nginx.conf
Normal file
30
nginx.conf
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /var/www/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# API requests → .NET backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:5000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://localhost:5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Swagger (dev only, but harmless)
|
||||||
|
location /swagger {
|
||||||
|
proxy_pass http://localhost:5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user