From 48d30df07b6c6ba70772444fc4e0e5b4728c11a8 Mon Sep 17 00:00:00 2001 From: Josh Rogers Date: Wed, 6 May 2026 19:32:39 -0500 Subject: [PATCH] Initial commit: YesChef family shopping list and recipe app Backend (.NET 10 minimal API): - Vertical slice architecture with feature folders - Postgres via EF Core with initial migration - JWT auth with family invite code registration - REST endpoints for stores, shopping lists, items, recipes - SignalR hub for real-time list collaboration (per-list groups and lists-overview group for live list creation/archival/progress) - Multi-stage Dockerfile Frontend (SvelteKit + Svelte 5 runes, Tailwind v4): - Mobile-first PWA with web manifest and service worker - Bottom-nav layout, login/register, lists overview, list detail, stores management, recipes (list/create/detail with add-to-list) - SignalR client with reference-counted connection - Real-time updates on both lists overview and list detail pages Infrastructure: - docker-compose.yml with postgres, backend, frontend services and Traefik labels for path-based routing (/api, /hubs to backend) - .env.example with required config End-to-end tests (Playwright): - test-e2e.mjs: single-user flow (auth, stores, lists, items, recipes) - test-e2e-multiuser.mjs: two-user real-time sync coverage Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 4 + .gitignore | 23 + docker-compose.yml | 60 + package-lock.json | 60 + package.json | 16 + src/backend/YesChef.Api/Auth/AuthEndpoints.cs | 60 + .../Auth/ClaimsPrincipalExtensions.cs | 12 + .../YesChef.Api/Auth/JwtTokenService.cs | 29 + src/backend/YesChef.Api/Data/YesChefDb.cs | 57 + src/backend/YesChef.Api/Dockerfile | 13 + src/backend/YesChef.Api/Entities/Recipe.cs | 16 + .../YesChef.Api/Entities/RecipeIngredient.cs | 11 + .../YesChef.Api/Entities/ShoppingList.cs | 15 + .../YesChef.Api/Entities/ShoppingListItem.cs | 16 + src/backend/YesChef.Api/Entities/Store.cs | 9 + src/backend/YesChef.Api/Entities/User.cs | 9 + .../Features/Recipes/RecipeEndpoints.cs | 117 + .../ShoppingLists/ShoppingListEndpoints.cs | 230 ++ .../Features/ShoppingLists/ShoppingListHub.cs | 20 + .../Features/Stores/StoreEndpoints.cs | 51 + .../20260506041045_InitialCreate.Designer.cs | 311 +++ .../20260506041045_InitialCreate.cs | 230 ++ .../Migrations/YesChefDbModelSnapshot.cs | 308 +++ src/backend/YesChef.Api/Program.cs | 69 + .../Properties/launchSettings.json | 23 + src/backend/YesChef.Api/YesChef.Api.csproj | 18 + src/backend/YesChef.Api/YesChef.Api.http | 6 + .../YesChef.Api/appsettings.Development.json | 8 + src/backend/YesChef.Api/appsettings.json | 15 + src/backend/YesChef.slnx | 3 + src/frontend/.gitignore | 23 + src/frontend/.npmrc | 1 + src/frontend/Dockerfile | 15 + src/frontend/README.md | 42 + src/frontend/package-lock.json | 2388 +++++++++++++++++ src/frontend/package.json | 29 + src/frontend/src/app.css | 23 + src/frontend/src/app.d.ts | 13 + src/frontend/src/app.html | 17 + src/frontend/src/lib/api.ts | 48 + src/frontend/src/lib/assets/favicon.svg | 1 + src/frontend/src/lib/auth.svelte.ts | 15 + src/frontend/src/lib/index.ts | 1 + src/frontend/src/lib/signalr.ts | 37 + src/frontend/src/routes/+layout.svelte | 67 + src/frontend/src/routes/+page.svelte | 13 + src/frontend/src/routes/lists/+page.svelte | 151 ++ .../src/routes/lists/[id]/+page.svelte | 207 ++ src/frontend/src/routes/login/+page.svelte | 104 + src/frontend/src/routes/recipes/+page.svelte | 72 + .../src/routes/recipes/[id]/+page.svelte | 148 + .../src/routes/recipes/new/+page.svelte | 128 + src/frontend/src/routes/stores/+page.svelte | 101 + src/frontend/src/service-worker.ts | 45 + src/frontend/static/favicon.svg | 4 + src/frontend/static/icons/icon-192.png | Bin 0 -> 2532 bytes src/frontend/static/icons/icon-512.png | Bin 0 -> 7239 bytes src/frontend/static/manifest.webmanifest | 12 + src/frontend/static/robots.txt | 3 + src/frontend/svelte.config.js | 16 + src/frontend/tsconfig.json | 20 + src/frontend/vite.config.ts | 16 + test-e2e-multiuser.mjs | 147 + test-e2e.mjs | 147 + 64 files changed, 5873 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/backend/YesChef.Api/Auth/AuthEndpoints.cs create mode 100644 src/backend/YesChef.Api/Auth/ClaimsPrincipalExtensions.cs create mode 100644 src/backend/YesChef.Api/Auth/JwtTokenService.cs create mode 100644 src/backend/YesChef.Api/Data/YesChefDb.cs create mode 100644 src/backend/YesChef.Api/Dockerfile create mode 100644 src/backend/YesChef.Api/Entities/Recipe.cs create mode 100644 src/backend/YesChef.Api/Entities/RecipeIngredient.cs create mode 100644 src/backend/YesChef.Api/Entities/ShoppingList.cs create mode 100644 src/backend/YesChef.Api/Entities/ShoppingListItem.cs create mode 100644 src/backend/YesChef.Api/Entities/Store.cs create mode 100644 src/backend/YesChef.Api/Entities/User.cs create mode 100644 src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs create mode 100644 src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs create mode 100644 src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListHub.cs create mode 100644 src/backend/YesChef.Api/Features/Stores/StoreEndpoints.cs create mode 100644 src/backend/YesChef.Api/Migrations/20260506041045_InitialCreate.Designer.cs create mode 100644 src/backend/YesChef.Api/Migrations/20260506041045_InitialCreate.cs create mode 100644 src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs create mode 100644 src/backend/YesChef.Api/Program.cs create mode 100644 src/backend/YesChef.Api/Properties/launchSettings.json create mode 100644 src/backend/YesChef.Api/YesChef.Api.csproj create mode 100644 src/backend/YesChef.Api/YesChef.Api.http create mode 100644 src/backend/YesChef.Api/appsettings.Development.json create mode 100644 src/backend/YesChef.Api/appsettings.json create mode 100644 src/backend/YesChef.slnx create mode 100644 src/frontend/.gitignore create mode 100644 src/frontend/.npmrc create mode 100644 src/frontend/Dockerfile create mode 100644 src/frontend/README.md create mode 100644 src/frontend/package-lock.json create mode 100644 src/frontend/package.json create mode 100644 src/frontend/src/app.css create mode 100644 src/frontend/src/app.d.ts create mode 100644 src/frontend/src/app.html create mode 100644 src/frontend/src/lib/api.ts create mode 100644 src/frontend/src/lib/assets/favicon.svg create mode 100644 src/frontend/src/lib/auth.svelte.ts create mode 100644 src/frontend/src/lib/index.ts create mode 100644 src/frontend/src/lib/signalr.ts create mode 100644 src/frontend/src/routes/+layout.svelte create mode 100644 src/frontend/src/routes/+page.svelte create mode 100644 src/frontend/src/routes/lists/+page.svelte create mode 100644 src/frontend/src/routes/lists/[id]/+page.svelte create mode 100644 src/frontend/src/routes/login/+page.svelte create mode 100644 src/frontend/src/routes/recipes/+page.svelte create mode 100644 src/frontend/src/routes/recipes/[id]/+page.svelte create mode 100644 src/frontend/src/routes/recipes/new/+page.svelte create mode 100644 src/frontend/src/routes/stores/+page.svelte create mode 100644 src/frontend/src/service-worker.ts create mode 100644 src/frontend/static/favicon.svg create mode 100644 src/frontend/static/icons/icon-192.png create mode 100644 src/frontend/static/icons/icon-512.png create mode 100644 src/frontend/static/manifest.webmanifest create mode 100644 src/frontend/static/robots.txt create mode 100644 src/frontend/svelte.config.js create mode 100644 src/frontend/tsconfig.json create mode 100644 src/frontend/vite.config.ts create mode 100644 test-e2e-multiuser.mjs create mode 100644 test-e2e.mjs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..330d377 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +POSTGRES_PASSWORD=change-me-strong-password +JWT_SECRET=change-me-generate-a-random-64-char-string +FAMILY_CODE=your-family-invite-phrase +DOMAIN=yeschef.yourdomain.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52c652a --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +## .NET +bin/ +obj/ +*.user +*.suo +*.cache + +## Node +node_modules/ +build/ +.svelte-kit/ + +## Environment +.env + +## IDE +.vs/ +.vscode/ +.idea/ + +## OS +Thumbs.db +.DS_Store diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d41ada2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +services: + postgres: + image: postgres:17 + environment: + POSTGRES_DB: yeschef + POSTGRES_USER: yeschef + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - yeschef-pgdata:/var/lib/postgresql/data + expose: + - "5432" + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U yeschef"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: + context: ./src/backend/YesChef.Api + dockerfile: Dockerfile + environment: + ConnectionStrings__DefaultConnection: "Host=postgres;Database=yeschef;Username=yeschef;Password=${POSTGRES_PASSWORD}" + Jwt__Secret: ${JWT_SECRET} + FamilyCode: ${FAMILY_CODE} + expose: + - "5000" + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.http.routers.yeschef-api.rule=Host(`${DOMAIN}`) && (PathPrefix(`/api`) || PathPrefix(`/hubs`) || Path(`/health`))" + - "traefik.http.routers.yeschef-api.entrypoints=websecure" + - "traefik.http.routers.yeschef-api.tls.certresolver=letsencrypt" + - "traefik.http.services.yeschef-api.loadbalancer.server.port=5000" + + frontend: + build: + context: ./src/frontend + dockerfile: Dockerfile + environment: + ORIGIN: https://${DOMAIN} + expose: + - "3000" + depends_on: + - backend + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.http.routers.yeschef-web.rule=Host(`${DOMAIN}`)" + - "traefik.http.routers.yeschef-web.entrypoints=websecure" + - "traefik.http.routers.yeschef-web.tls.certresolver=letsencrypt" + - "traefik.http.routers.yeschef-web.priority=1" + - "traefik.http.services.yeschef-web.loadbalancer.server.port=3000" + +volumes: + yeschef-pgdata: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5671210 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,60 @@ +{ + "name": "yeschef", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yeschef", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "playwright": "^1.59.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9450b13 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "yeschef", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "playwright": "^1.59.1" + } +} diff --git a/src/backend/YesChef.Api/Auth/AuthEndpoints.cs b/src/backend/YesChef.Api/Auth/AuthEndpoints.cs new file mode 100644 index 0000000..30ddde9 --- /dev/null +++ b/src/backend/YesChef.Api/Auth/AuthEndpoints.cs @@ -0,0 +1,60 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using YesChef.Api.Data; +using YesChef.Api.Entities; + +namespace YesChef.Api.Auth; + +public static class AuthEndpoints +{ + public record RegisterRequest(string Name, string Password, string FamilyCode); + public record LoginRequest(string Name, string Password); + public record AuthResponse(string Token, string Name); + + public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group) + { + var hasher = new PasswordHasher(); + + group.MapPost("/register", async (RegisterRequest request, YesChefDb db, JwtTokenService jwt, IConfiguration config) => + { + var familyCode = config["FamilyCode"]; + if (string.IsNullOrEmpty(familyCode) || request.FamilyCode != familyCode) + return Results.BadRequest(new { error = "Invalid family code." }); + + if (await db.Users.AnyAsync(u => u.Name == request.Name)) + return Results.Conflict(new { error = "Name already taken." }); + + var user = new User { Name = request.Name, PasswordHash = "" }; + user.PasswordHash = hasher.HashPassword(user, request.Password); + + db.Users.Add(user); + await db.SaveChangesAsync(); + + var token = jwt.GenerateToken(user); + return Results.Ok(new AuthResponse(token, user.Name)); + }); + + group.MapPost("/login", async (LoginRequest request, YesChefDb db, JwtTokenService jwt) => + { + var user = await db.Users.FirstOrDefaultAsync(u => u.Name == request.Name); + if (user is null) + return Results.Unauthorized(); + + var result = hasher.VerifyHashedPassword(user, user.PasswordHash, request.Password); + if (result == PasswordVerificationResult.Failed) + return Results.Unauthorized(); + + var token = jwt.GenerateToken(user); + return Results.Ok(new AuthResponse(token, user.Name)); + }); + + group.MapGet("/me", (HttpContext http) => + { + var userId = http.User.GetUserId(); + var name = http.User.GetUserName(); + return Results.Ok(new { id = userId, name }); + }).RequireAuthorization(); + + return group; + } +} diff --git a/src/backend/YesChef.Api/Auth/ClaimsPrincipalExtensions.cs b/src/backend/YesChef.Api/Auth/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000..0fbfdf7 --- /dev/null +++ b/src/backend/YesChef.Api/Auth/ClaimsPrincipalExtensions.cs @@ -0,0 +1,12 @@ +using System.Security.Claims; + +namespace YesChef.Api.Auth; + +public static class ClaimsPrincipalExtensions +{ + public static int GetUserId(this ClaimsPrincipal principal) => + int.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!); + + public static string GetUserName(this ClaimsPrincipal principal) => + principal.FindFirstValue(ClaimTypes.Name)!; +} diff --git a/src/backend/YesChef.Api/Auth/JwtTokenService.cs b/src/backend/YesChef.Api/Auth/JwtTokenService.cs new file mode 100644 index 0000000..17b0f32 --- /dev/null +++ b/src/backend/YesChef.Api/Auth/JwtTokenService.cs @@ -0,0 +1,29 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; +using YesChef.Api.Entities; + +namespace YesChef.Api.Auth; + +public class JwtTokenService(IConfiguration config) +{ + public string GenerateToken(User user) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Secret"]!)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Name) + }; + + var token = new JwtSecurityToken( + claims: claims, + expires: DateTime.UtcNow.AddDays(30), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/src/backend/YesChef.Api/Data/YesChefDb.cs b/src/backend/YesChef.Api/Data/YesChefDb.cs new file mode 100644 index 0000000..2af2214 --- /dev/null +++ b/src/backend/YesChef.Api/Data/YesChefDb.cs @@ -0,0 +1,57 @@ +using Microsoft.EntityFrameworkCore; +using YesChef.Api.Entities; + +namespace YesChef.Api.Data; + +public class YesChefDb(DbContextOptions options) : DbContext(options) +{ + public DbSet Users => Set(); + public DbSet Stores => Set(); + public DbSet ShoppingLists => Set(); + public DbSet ShoppingListItems => Set(); + public DbSet Recipes => Set(); + public DbSet RecipeIngredients => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.HasIndex(u => u.Name).IsUnique(); + e.Property(u => u.Name).HasMaxLength(100); + }); + + modelBuilder.Entity(e => + { + e.HasIndex(s => s.Name).IsUnique(); + e.Property(s => s.Name).HasMaxLength(100); + }); + + modelBuilder.Entity(e => + { + e.Property(l => l.Name).HasMaxLength(200); + e.HasOne(l => l.Store).WithMany().HasForeignKey(l => l.StoreId); + e.HasOne(l => l.CreatedByUser).WithMany().HasForeignKey(l => l.CreatedByUserId); + e.HasMany(l => l.Items).WithOne(i => i.ShoppingList).HasForeignKey(i => i.ShoppingListId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(e => + { + e.Property(i => i.Name).HasMaxLength(300); + e.HasOne(i => i.CheckedByUser).WithMany().HasForeignKey(i => i.CheckedByUserId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(i => i.Recipe).WithMany().HasForeignKey(i => i.RecipeId).OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity(e => + { + e.Property(r => r.Title).HasMaxLength(300); + e.HasOne(r => r.CreatedByUser).WithMany().HasForeignKey(r => r.CreatedByUserId); + e.HasMany(r => r.Ingredients).WithOne(i => i.Recipe).HasForeignKey(i => i.RecipeId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(e => + { + e.Property(i => i.Name).HasMaxLength(200); + e.Property(i => i.Quantity).HasMaxLength(50); + }); + } +} diff --git a/src/backend/YesChef.Api/Dockerfile b/src/backend/YesChef.Api/Dockerfile new file mode 100644 index 0000000..34e4949 --- /dev/null +++ b/src/backend/YesChef.Api/Dockerfile @@ -0,0 +1,13 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY YesChef.Api.csproj . +RUN dotnet restore +COPY . . +RUN dotnet publish -c Release -o /app + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app . +EXPOSE 5000 +ENV ASPNETCORE_URLS=http://+:5000 +ENTRYPOINT ["dotnet", "YesChef.Api.dll"] diff --git a/src/backend/YesChef.Api/Entities/Recipe.cs b/src/backend/YesChef.Api/Entities/Recipe.cs new file mode 100644 index 0000000..03091a9 --- /dev/null +++ b/src/backend/YesChef.Api/Entities/Recipe.cs @@ -0,0 +1,16 @@ +namespace YesChef.Api.Entities; + +public class Recipe +{ + public int Id { get; set; } + public required string Title { get; set; } + public string? Description { get; set; } + public string? Instructions { get; set; } + public int? Servings { get; set; } + public string? SourceUrl { get; set; } + public int CreatedByUserId { get; set; } + public User CreatedByUser { get; set; } = null!; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public List Ingredients { get; set; } = []; +} diff --git a/src/backend/YesChef.Api/Entities/RecipeIngredient.cs b/src/backend/YesChef.Api/Entities/RecipeIngredient.cs new file mode 100644 index 0000000..b523bd9 --- /dev/null +++ b/src/backend/YesChef.Api/Entities/RecipeIngredient.cs @@ -0,0 +1,11 @@ +namespace YesChef.Api.Entities; + +public class RecipeIngredient +{ + public int Id { get; set; } + public int RecipeId { get; set; } + public Recipe Recipe { get; set; } = null!; + public required string Name { get; set; } + public string? Quantity { get; set; } + public int SortOrder { get; set; } +} diff --git a/src/backend/YesChef.Api/Entities/ShoppingList.cs b/src/backend/YesChef.Api/Entities/ShoppingList.cs new file mode 100644 index 0000000..cbe9242 --- /dev/null +++ b/src/backend/YesChef.Api/Entities/ShoppingList.cs @@ -0,0 +1,15 @@ +namespace YesChef.Api.Entities; + +public class ShoppingList +{ + public int Id { get; set; } + public required string Name { get; set; } + public int StoreId { get; set; } + public Store Store { get; set; } = null!; + public bool IsArchived { get; set; } + public int CreatedByUserId { get; set; } + public User CreatedByUser { get; set; } = null!; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public List Items { get; set; } = []; +} diff --git a/src/backend/YesChef.Api/Entities/ShoppingListItem.cs b/src/backend/YesChef.Api/Entities/ShoppingListItem.cs new file mode 100644 index 0000000..bd0e69a --- /dev/null +++ b/src/backend/YesChef.Api/Entities/ShoppingListItem.cs @@ -0,0 +1,16 @@ +namespace YesChef.Api.Entities; + +public class ShoppingListItem +{ + public int Id { get; set; } + public int ShoppingListId { get; set; } + public ShoppingList ShoppingList { get; set; } = null!; + public required string Name { get; set; } + public bool IsChecked { get; set; } + public int? CheckedByUserId { get; set; } + public User? CheckedByUser { get; set; } + public int SortOrder { get; set; } + public int? RecipeId { get; set; } + public Recipe? Recipe { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/backend/YesChef.Api/Entities/Store.cs b/src/backend/YesChef.Api/Entities/Store.cs new file mode 100644 index 0000000..cd8e14e --- /dev/null +++ b/src/backend/YesChef.Api/Entities/Store.cs @@ -0,0 +1,9 @@ +namespace YesChef.Api.Entities; + +public class Store +{ + public int Id { get; set; } + public required string Name { get; set; } + public int SortOrder { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/backend/YesChef.Api/Entities/User.cs b/src/backend/YesChef.Api/Entities/User.cs new file mode 100644 index 0000000..49f4a9a --- /dev/null +++ b/src/backend/YesChef.Api/Entities/User.cs @@ -0,0 +1,9 @@ +namespace YesChef.Api.Entities; + +public class User +{ + public int Id { get; set; } + public required string Name { get; set; } + public required string PasswordHash { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs b/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs new file mode 100644 index 0000000..8ab4b80 --- /dev/null +++ b/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs @@ -0,0 +1,117 @@ +using Microsoft.EntityFrameworkCore; +using YesChef.Api.Auth; +using YesChef.Api.Data; +using YesChef.Api.Entities; + +namespace YesChef.Api.Features.Recipes; + +public static class RecipeEndpoints +{ + public record IngredientRequest(string Name, string? Quantity, int SortOrder); + public record CreateRecipeRequest(string Title, string? Description, string? Instructions, int? Servings, string? SourceUrl, List Ingredients); + public record UpdateRecipeRequest(string Title, string? Description, string? Instructions, int? Servings, string? SourceUrl, List Ingredients); + + public static RouteGroupBuilder MapRecipeEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/", async (YesChefDb db, string? q) => + { + var query = db.Recipes.AsQueryable(); + if (!string.IsNullOrWhiteSpace(q)) + query = query.Where(r => r.Title.Contains(q)); + + return await query.OrderByDescending(r => r.UpdatedAt) + .Select(r => new + { + r.Id, + r.Title, + r.Description, + r.Servings, + IngredientCount = r.Ingredients.Count, + r.UpdatedAt + }) + .ToListAsync(); + }); + + group.MapPost("/", async (CreateRecipeRequest request, YesChefDb db, HttpContext http) => + { + var recipe = new Recipe + { + Title = request.Title, + Description = request.Description, + Instructions = request.Instructions, + Servings = request.Servings, + SourceUrl = request.SourceUrl, + CreatedByUserId = http.User.GetUserId(), + Ingredients = request.Ingredients.Select(i => new RecipeIngredient + { + Name = i.Name, + Quantity = i.Quantity, + SortOrder = i.SortOrder + }).ToList() + }; + + db.Recipes.Add(recipe); + await db.SaveChangesAsync(); + return Results.Created($"/api/recipes/{recipe.Id}", new { recipe.Id, recipe.Title }); + }); + + group.MapGet("/{id:int}", async (int id, YesChefDb db) => + { + var recipe = await db.Recipes + .Include(r => r.Ingredients.OrderBy(i => i.SortOrder)) + .Include(r => r.CreatedByUser) + .FirstOrDefaultAsync(r => r.Id == id); + + if (recipe is null) return Results.NotFound(); + + return Results.Ok(new + { + recipe.Id, + recipe.Title, + recipe.Description, + recipe.Instructions, + recipe.Servings, + recipe.SourceUrl, + CreatedBy = recipe.CreatedByUser.Name, + recipe.UpdatedAt, + Ingredients = recipe.Ingredients.Select(i => new { i.Id, i.Name, i.Quantity, i.SortOrder }) + }); + }); + + group.MapPut("/{id:int}", async (int id, UpdateRecipeRequest request, YesChefDb db, HttpContext http) => + { + var recipe = await db.Recipes.Include(r => r.Ingredients).FirstOrDefaultAsync(r => r.Id == id); + if (recipe is null) return Results.NotFound(); + + recipe.Title = request.Title; + recipe.Description = request.Description; + recipe.Instructions = request.Instructions; + recipe.Servings = request.Servings; + recipe.SourceUrl = request.SourceUrl; + recipe.UpdatedAt = DateTime.UtcNow; + + db.RecipeIngredients.RemoveRange(recipe.Ingredients); + recipe.Ingredients = request.Ingredients.Select(i => new RecipeIngredient + { + Name = i.Name, + Quantity = i.Quantity, + SortOrder = i.SortOrder + }).ToList(); + + await db.SaveChangesAsync(); + return Results.Ok(new { recipe.Id, recipe.Title }); + }); + + group.MapDelete("/{id:int}", async (int id, YesChefDb db) => + { + var recipe = await db.Recipes.FindAsync(id); + if (recipe is null) return Results.NotFound(); + + db.Recipes.Remove(recipe); + await db.SaveChangesAsync(); + return Results.NoContent(); + }); + + return group; + } +} diff --git a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs new file mode 100644 index 0000000..e964375 --- /dev/null +++ b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs @@ -0,0 +1,230 @@ +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using YesChef.Api.Auth; +using YesChef.Api.Data; +using YesChef.Api.Entities; + +namespace YesChef.Api.Features.ShoppingLists; + +public static class ShoppingListEndpoints +{ + public record CreateListRequest(string Name, int StoreId); + public record UpdateListRequest(string Name, int StoreId); + public record AddItemRequest(string Name, int SortOrder = 0); + + private static async Task BroadcastListSummary(IHubContext hub, YesChefDb db, int listId) + { + var summary = await db.ShoppingLists + .Where(l => l.Id == listId) + .Select(l => new + { + l.Id, + l.Name, + Store = new { l.Store.Id, l.Store.Name }, + ItemCount = l.Items.Count, + CheckedCount = l.Items.Count(i => i.IsChecked), + l.UpdatedAt + }) + .FirstAsync(); + + await hub.Clients.Group("lists-overview").SendAsync("ListSummaryUpdated", summary); + } + + public static RouteGroupBuilder MapShoppingListEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/", async (YesChefDb db, int? storeId) => + { + var query = db.ShoppingLists + .Where(l => !l.IsArchived) + .Include(l => l.Store) + .Include(l => l.Items) + .AsQueryable(); + + if (storeId.HasValue) + query = query.Where(l => l.StoreId == storeId); + + return await query.OrderByDescending(l => l.UpdatedAt) + .Select(l => new + { + l.Id, + l.Name, + Store = new { l.Store.Id, l.Store.Name }, + ItemCount = l.Items.Count, + CheckedCount = l.Items.Count(i => i.IsChecked), + l.UpdatedAt + }) + .ToListAsync(); + }); + + group.MapPost("/", async (CreateListRequest request, YesChefDb db, HttpContext http, IHubContext hub) => + { + var list = new ShoppingList + { + Name = request.Name, + StoreId = request.StoreId, + CreatedByUserId = http.User.GetUserId() + }; + db.ShoppingLists.Add(list); + await db.SaveChangesAsync(); + + var store = await db.Stores.FindAsync(list.StoreId); + await hub.Clients.Group("lists-overview").SendAsync("ListCreated", new + { + list.Id, + list.Name, + Store = new { store!.Id, store.Name }, + ItemCount = 0, + CheckedCount = 0, + list.UpdatedAt + }); + + return Results.Created($"/api/lists/{list.Id}", new { list.Id, list.Name, list.StoreId }); + }); + + group.MapGet("/{id:int}", async (int id, YesChefDb db) => + { + var list = await db.ShoppingLists + .Include(l => l.Store) + .Include(l => l.Items.OrderBy(i => i.SortOrder)) + .ThenInclude(i => i.CheckedByUser) + .Include(l => l.Items) + .ThenInclude(i => i.Recipe) + .FirstOrDefaultAsync(l => l.Id == id); + + if (list is null) return Results.NotFound(); + + return Results.Ok(new + { + list.Id, + list.Name, + Store = new { list.Store.Id, list.Store.Name }, + list.IsArchived, + list.UpdatedAt, + Items = list.Items.Select(i => new + { + i.Id, + i.Name, + i.IsChecked, + CheckedByUserName = i.CheckedByUser?.Name, + i.SortOrder, + RecipeTitle = i.Recipe?.Title + }) + }); + }); + + group.MapPut("/{id:int}", async (int id, UpdateListRequest request, YesChefDb db, IHubContext hub) => + { + var list = await db.ShoppingLists.FindAsync(id); + if (list is null) return Results.NotFound(); + + list.Name = request.Name; + list.StoreId = request.StoreId; + list.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + + await hub.Clients.Group($"list-{id}").SendAsync("ListUpdated", new { list.Id, list.Name, list.StoreId }); + return Results.Ok(); + }); + + group.MapDelete("/{id:int}", async (int id, YesChefDb db, IHubContext hub) => + { + var list = await db.ShoppingLists.FindAsync(id); + if (list is null) return Results.NotFound(); + + list.IsArchived = true; + list.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + + await hub.Clients.Group("lists-overview").SendAsync("ListArchived", new { list.Id }); + + return Results.NoContent(); + }); + + group.MapPost("/{listId:int}/items", async (int listId, AddItemRequest request, YesChefDb db, IHubContext hub) => + { + if (!await db.ShoppingLists.AnyAsync(l => l.Id == listId)) return Results.NotFound(); + + var item = new ShoppingListItem + { + ShoppingListId = listId, + Name = request.Name, + SortOrder = request.SortOrder + }; + db.ShoppingListItems.Add(item); + + var list = await db.ShoppingLists.FindAsync(listId); + list!.UpdatedAt = DateTime.UtcNow; + + await db.SaveChangesAsync(); + + await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder }); + await BroadcastListSummary(hub, db, listId); + return Results.Created($"/api/lists/{listId}/items/{item.Id}", new { item.Id, item.Name, item.SortOrder }); + }); + + group.MapPatch("/{listId:int}/items/{itemId:int}/check", async (int listId, int itemId, YesChefDb db, IHubContext hub, HttpContext http) => + { + var item = await db.ShoppingListItems.FirstOrDefaultAsync(i => i.Id == itemId && i.ShoppingListId == listId); + if (item is null) return Results.NotFound(); + + var userId = http.User.GetUserId(); + item.IsChecked = !item.IsChecked; + item.CheckedByUserId = item.IsChecked ? userId : null; + await db.SaveChangesAsync(); + + string? checkedByName = null; + if (item.IsChecked) + checkedByName = await db.Users.Where(u => u.Id == userId).Select(u => u.Name).FirstAsync(); + + await hub.Clients.Group($"list-{listId}").SendAsync("ItemChecked", new { item.Id, item.IsChecked, CheckedByUserName = checkedByName }); + await BroadcastListSummary(hub, db, listId); + return Results.Ok(); + }); + + group.MapDelete("/{listId:int}/items/{itemId:int}", async (int listId, int itemId, YesChefDb db, IHubContext hub) => + { + var item = await db.ShoppingListItems.FirstOrDefaultAsync(i => i.Id == itemId && i.ShoppingListId == listId); + if (item is null) return Results.NotFound(); + + db.ShoppingListItems.Remove(item); + await db.SaveChangesAsync(); + + await hub.Clients.Group($"list-{listId}").SendAsync("ItemRemoved", new { item.Id }); + await BroadcastListSummary(hub, db, listId); + return Results.NoContent(); + }); + + group.MapPost("/{listId:int}/add-recipe/{recipeId:int}", async (int listId, int recipeId, YesChefDb db, IHubContext hub) => + { + var list = await db.ShoppingLists.FindAsync(listId); + if (list is null) return Results.NotFound(); + + var recipe = await db.Recipes.Include(r => r.Ingredients).FirstOrDefaultAsync(r => r.Id == recipeId); + if (recipe is null) return Results.NotFound(); + + var maxSort = await db.ShoppingListItems.Where(i => i.ShoppingListId == listId).MaxAsync(i => (int?)i.SortOrder) ?? 0; + + var newItems = recipe.Ingredients.Select((ing, idx) => new ShoppingListItem + { + ShoppingListId = listId, + Name = string.IsNullOrEmpty(ing.Quantity) ? ing.Name : $"{ing.Quantity} {ing.Name}", + SortOrder = maxSort + idx + 1, + RecipeId = recipeId + }).ToList(); + + db.ShoppingListItems.AddRange(newItems); + list.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + + foreach (var item in newItems) + { + await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, RecipeTitle = recipe.Title }); + } + + await BroadcastListSummary(hub, db, listId); + return Results.Ok(new { added = newItems.Count }); + }); + + return group; + } +} diff --git a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListHub.cs b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListHub.cs new file mode 100644 index 0000000..e3b2c58 --- /dev/null +++ b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListHub.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace YesChef.Api.Features.ShoppingLists; + +[Authorize] +public class ShoppingListHub : Hub +{ + public async Task JoinList(int listId) => + await Groups.AddToGroupAsync(Context.ConnectionId, $"list-{listId}"); + + public async Task LeaveList(int listId) => + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"list-{listId}"); + + public async Task JoinListsOverview() => + await Groups.AddToGroupAsync(Context.ConnectionId, "lists-overview"); + + public async Task LeaveListsOverview() => + await Groups.RemoveFromGroupAsync(Context.ConnectionId, "lists-overview"); +} diff --git a/src/backend/YesChef.Api/Features/Stores/StoreEndpoints.cs b/src/backend/YesChef.Api/Features/Stores/StoreEndpoints.cs new file mode 100644 index 0000000..75b81eb --- /dev/null +++ b/src/backend/YesChef.Api/Features/Stores/StoreEndpoints.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; +using YesChef.Api.Data; +using YesChef.Api.Entities; + +namespace YesChef.Api.Features.Stores; + +public static class StoreEndpoints +{ + public record CreateStoreRequest(string Name, int SortOrder = 0); + public record UpdateStoreRequest(string Name, int SortOrder); + + public static RouteGroupBuilder MapStoreEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/", async (YesChefDb db) => + await db.Stores.OrderBy(s => s.SortOrder).ThenBy(s => s.Name).ToListAsync()); + + group.MapPost("/", async (CreateStoreRequest request, YesChefDb db) => + { + var store = new Store { Name = request.Name, SortOrder = request.SortOrder }; + db.Stores.Add(store); + await db.SaveChangesAsync(); + return Results.Created($"/api/stores/{store.Id}", store); + }); + + group.MapPut("/{id:int}", async (int id, UpdateStoreRequest request, YesChefDb db) => + { + var store = await db.Stores.FindAsync(id); + if (store is null) return Results.NotFound(); + + store.Name = request.Name; + store.SortOrder = request.SortOrder; + await db.SaveChangesAsync(); + return Results.Ok(store); + }); + + group.MapDelete("/{id:int}", async (int id, YesChefDb db) => + { + var hasLists = await db.ShoppingLists.AnyAsync(l => l.StoreId == id); + if (hasLists) return Results.BadRequest(new { error = "Store has shopping lists. Remove them first." }); + + var store = await db.Stores.FindAsync(id); + if (store is null) return Results.NotFound(); + + db.Stores.Remove(store); + await db.SaveChangesAsync(); + return Results.NoContent(); + }); + + return group; + } +} diff --git a/src/backend/YesChef.Api/Migrations/20260506041045_InitialCreate.Designer.cs b/src/backend/YesChef.Api/Migrations/20260506041045_InitialCreate.Designer.cs new file mode 100644 index 0000000..fd158d9 --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260506041045_InitialCreate.Designer.cs @@ -0,0 +1,311 @@ +ο»Ώ// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using YesChef.Api.Data; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + [DbContext(typeof(YesChefDb))] + [Migration("20260506041045_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Instructions") + .HasColumnType("text"); + + b.Property("Servings") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("Recipes"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Quantity") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RecipeId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("StoreId"); + + b.ToTable("ShoppingLists"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckedByUserId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsChecked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("ShoppingListId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckedByUserId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("ShoppingListId"); + + b.ToTable("ShoppingListItems"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany("Ingredients") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Recipe"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.HasOne("YesChef.Api.Entities.User", "CheckedByUser") + .WithMany() + .HasForeignKey("CheckedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany() + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList") + .WithMany("Items") + .HasForeignKey("ShoppingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CheckedByUser"); + + b.Navigation("Recipe"); + + b.Navigation("ShoppingList"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Navigation("Ingredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/20260506041045_InitialCreate.cs b/src/backend/YesChef.Api/Migrations/20260506041045_InitialCreate.cs new file mode 100644 index 0000000..c56ee12 --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260506041045_InitialCreate.cs @@ -0,0 +1,230 @@ +ο»Ώusing System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Stores", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Stores", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + PasswordHash = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Recipes", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Title = table.Column(type: "character varying(300)", maxLength: 300, nullable: false), + Description = table.Column(type: "text", nullable: true), + Instructions = table.Column(type: "text", nullable: true), + Servings = table.Column(type: "integer", nullable: true), + SourceUrl = table.Column(type: "text", nullable: true), + CreatedByUserId = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Recipes", x => x.Id); + table.ForeignKey( + name: "FK_Recipes_Users_CreatedByUserId", + column: x => x.CreatedByUserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ShoppingLists", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + StoreId = table.Column(type: "integer", nullable: false), + IsArchived = table.Column(type: "boolean", nullable: false), + CreatedByUserId = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ShoppingLists", x => x.Id); + table.ForeignKey( + name: "FK_ShoppingLists_Stores_StoreId", + column: x => x.StoreId, + principalTable: "Stores", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ShoppingLists_Users_CreatedByUserId", + column: x => x.CreatedByUserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "RecipeIngredients", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RecipeId = table.Column(type: "integer", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Quantity = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + SortOrder = table.Column(type: "integer", nullable: false) + }, + 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.CreateTable( + name: "ShoppingListItems", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ShoppingListId = table.Column(type: "integer", nullable: false), + Name = table.Column(type: "character varying(300)", maxLength: 300, nullable: false), + IsChecked = table.Column(type: "boolean", nullable: false), + CheckedByUserId = table.Column(type: "integer", nullable: true), + SortOrder = table.Column(type: "integer", nullable: false), + RecipeId = table.Column(type: "integer", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ShoppingListItems", x => x.Id); + table.ForeignKey( + name: "FK_ShoppingListItems_Recipes_RecipeId", + column: x => x.RecipeId, + principalTable: "Recipes", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_ShoppingListItems_ShoppingLists_ShoppingListId", + column: x => x.ShoppingListId, + principalTable: "ShoppingLists", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ShoppingListItems_Users_CheckedByUserId", + column: x => x.CheckedByUserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_RecipeIngredients_RecipeId", + table: "RecipeIngredients", + column: "RecipeId"); + + migrationBuilder.CreateIndex( + name: "IX_Recipes_CreatedByUserId", + table: "Recipes", + column: "CreatedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_ShoppingListItems_CheckedByUserId", + table: "ShoppingListItems", + column: "CheckedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_ShoppingListItems_RecipeId", + table: "ShoppingListItems", + column: "RecipeId"); + + migrationBuilder.CreateIndex( + name: "IX_ShoppingListItems_ShoppingListId", + table: "ShoppingListItems", + column: "ShoppingListId"); + + migrationBuilder.CreateIndex( + name: "IX_ShoppingLists_CreatedByUserId", + table: "ShoppingLists", + column: "CreatedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_ShoppingLists_StoreId", + table: "ShoppingLists", + column: "StoreId"); + + migrationBuilder.CreateIndex( + name: "IX_Stores_Name", + table: "Stores", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_Name", + table: "Users", + column: "Name", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RecipeIngredients"); + + migrationBuilder.DropTable( + name: "ShoppingListItems"); + + migrationBuilder.DropTable( + name: "Recipes"); + + migrationBuilder.DropTable( + name: "ShoppingLists"); + + migrationBuilder.DropTable( + name: "Stores"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs new file mode 100644 index 0000000..77a545b --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs @@ -0,0 +1,308 @@ +ο»Ώ// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using YesChef.Api.Data; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + [DbContext(typeof(YesChefDb))] + partial class YesChefDbModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Instructions") + .HasColumnType("text"); + + b.Property("Servings") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("Recipes"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Quantity") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RecipeId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("StoreId"); + + b.ToTable("ShoppingLists"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckedByUserId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsChecked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("ShoppingListId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckedByUserId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("ShoppingListId"); + + b.ToTable("ShoppingListItems"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany("Ingredients") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Recipe"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.HasOne("YesChef.Api.Entities.User", "CheckedByUser") + .WithMany() + .HasForeignKey("CheckedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany() + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList") + .WithMany("Items") + .HasForeignKey("ShoppingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CheckedByUser"); + + b.Navigation("Recipe"); + + b.Navigation("ShoppingList"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Navigation("Ingredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/YesChef.Api/Program.cs b/src/backend/YesChef.Api/Program.cs new file mode 100644 index 0000000..2111916 --- /dev/null +++ b/src/backend/YesChef.Api/Program.cs @@ -0,0 +1,69 @@ +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using YesChef.Api.Auth; +using YesChef.Api.Data; +using YesChef.Api.Features.Recipes; +using YesChef.Api.Features.ShoppingLists; +using YesChef.Api.Features.Stores; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); + +builder.Services.AddSingleton(); + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!)) + }; + + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/hubs")) + context.Token = accessToken; + return Task.CompletedTask; + } + }; + }); + +builder.Services.AddAuthorization(); +builder.Services.AddSignalR(); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); +} + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapGet("/health", async (YesChefDb db) => +{ + await db.Database.CanConnectAsync(); + return Results.Ok(new { status = "healthy" }); +}); + +app.MapGroup("/api/auth").MapAuthEndpoints(); +app.MapGroup("/api/stores").MapStoreEndpoints().RequireAuthorization(); +app.MapGroup("/api/lists").MapShoppingListEndpoints().RequireAuthorization(); +app.MapGroup("/api/recipes").MapRecipeEndpoints().RequireAuthorization(); +app.MapHub("/hubs/shopping-list"); + +app.Run(); diff --git a/src/backend/YesChef.Api/Properties/launchSettings.json b/src/backend/YesChef.Api/Properties/launchSettings.json new file mode 100644 index 0000000..4402bd7 --- /dev/null +++ b/src/backend/YesChef.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +ο»Ώ{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5291", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7264;http://localhost:5291", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/backend/YesChef.Api/YesChef.Api.csproj b/src/backend/YesChef.Api/YesChef.Api.csproj new file mode 100644 index 0000000..3ba3061 --- /dev/null +++ b/src/backend/YesChef.Api/YesChef.Api.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/src/backend/YesChef.Api/YesChef.Api.http b/src/backend/YesChef.Api/YesChef.Api.http new file mode 100644 index 0000000..c0ff6fd --- /dev/null +++ b/src/backend/YesChef.Api/YesChef.Api.http @@ -0,0 +1,6 @@ +@YesChef.Api_HostAddress = http://localhost:5291 + +GET {{YesChef.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/backend/YesChef.Api/appsettings.Development.json b/src/backend/YesChef.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/backend/YesChef.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/backend/YesChef.Api/appsettings.json b/src/backend/YesChef.Api/appsettings.json new file mode 100644 index 0000000..308daa1 --- /dev/null +++ b/src/backend/YesChef.Api/appsettings.json @@ -0,0 +1,15 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=yeschef;Username=yeschef;Password=yeschef" + }, + "Jwt": { + "Secret": "dev-secret-key-change-in-production-must-be-at-least-32-chars!" + }, + "FamilyCode": "dev-family-code", + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/backend/YesChef.slnx b/src/backend/YesChef.slnx new file mode 100644 index 0000000..05d7e88 --- /dev/null +++ b/src/backend/YesChef.slnx @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/.gitignore b/src/frontend/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/src/frontend/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/src/frontend/.npmrc b/src/frontend/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/src/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/src/frontend/Dockerfile b/src/frontend/Dockerfile new file mode 100644 index 0000000..758bff5 --- /dev/null +++ b/src/frontend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-slim AS build +WORKDIR /app +COPY package*.json . +RUN npm ci +COPY . . +RUN npm run build + +FROM node:22-slim +WORKDIR /app +COPY --from=build /app/build . +COPY --from=build /app/package.json . +RUN npm ci --omit=dev +EXPOSE 3000 +ENV PORT=3000 +CMD ["node", "index.js"] diff --git a/src/frontend/README.md b/src/frontend/README.md new file mode 100644 index 0000000..d45b80f --- /dev/null +++ b/src/frontend/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.15.2 create --template minimal --types ts --no-install src/frontend +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json new file mode 100644 index 0000000..07d891e --- /dev/null +++ b/src/frontend/package-lock.json @@ -0,0 +1,2388 @@ +{ + "name": "frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.1", + "dependencies": { + "@microsoft/signalr": "^10.0.0", + "@sveltejs/adapter-node": "^5.5.4", + "@tailwindcss/vite": "^4.2.4", + "tailwindcss": "^4.2.4" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "typescript": "^6.0.2", + "vite": "^8.0.7" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@microsoft/signalr": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz", + "integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.2.tgz", + "integrity": "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.4.tgz", + "integrity": "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ==", + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.59.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.59.1.tgz", + "integrity": "sha512-d8OON70AphLdDesuTIl//M2O6fRTIicX8aYv8vhCiYEhTTI2OboKqey0Hu1A4VFhqwgqtq0vKDmPFGkw8kKmgw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.1.tgz", + "integrity": "sha512-FOJdbE5pxae68DoTBJ49t1dIA7TSmMHR6CsuJhX90cO/UfrEMHA7KJNUj3WdZuUDJPu4ujqpJ2Tgqd2gTWr6Xg==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "license": "MIT", + "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.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "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" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.0.tgz", + "integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==", + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.6.tgz", + "integrity": "sha512-WN0clHt0a4mzC780UBVVBpsj4vSSjOFNRd2WjYtduB9HeKxm1sjHMNUwLEHVjI3FdCQD/Hurgz9ftbKEzP79Ow==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, + "node_modules/fetch-cookie/node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "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" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.55.5", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz", + "integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.4", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.8.tgz", + "integrity": "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + } + } +} diff --git a/src/frontend/package.json b/src/frontend/package.json new file mode 100644 index 0000000..bf264f9 --- /dev/null +++ b/src/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "typescript": "^6.0.2", + "vite": "^8.0.7" + }, + "dependencies": { + "@microsoft/signalr": "^10.0.0", + "@sveltejs/adapter-node": "^5.5.4", + "@tailwindcss/vite": "^4.2.4", + "tailwindcss": "^4.2.4" + } +} diff --git a/src/frontend/src/app.css b/src/frontend/src/app.css new file mode 100644 index 0000000..3590607 --- /dev/null +++ b/src/frontend/src/app.css @@ -0,0 +1,23 @@ +@import 'tailwindcss'; + +@theme { + --color-primary: #16a34a; + --color-primary-dark: #15803d; + --color-primary-light: #22c55e; + --color-danger: #dc2626; + --color-danger-dark: #b91c1c; +} + +html { + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; +} + +body { + @apply bg-gray-50 text-gray-900; + -webkit-tap-highlight-color: transparent; +} + +button { + @apply cursor-pointer; +} diff --git a/src/frontend/src/app.d.ts b/src/frontend/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/src/frontend/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/src/frontend/src/app.html b/src/frontend/src/app.html new file mode 100644 index 0000000..b30d01b --- /dev/null +++ b/src/frontend/src/app.html @@ -0,0 +1,17 @@ + + + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/frontend/src/lib/api.ts b/src/frontend/src/lib/api.ts new file mode 100644 index 0000000..9850733 --- /dev/null +++ b/src/frontend/src/lib/api.ts @@ -0,0 +1,48 @@ +import { goto } from '$app/navigation'; +import { setLoggedIn } from '$lib/auth.svelte'; + +let token: string | null = null; + +export function getToken(): string | null { + if (token === null && typeof localStorage !== 'undefined') { + token = localStorage.getItem('token'); + } + return token; +} + +export function setToken(t: string) { + token = t; + localStorage.setItem('token', t); + setLoggedIn(true); +} + +export function clearToken() { + token = null; + localStorage.removeItem('token'); + setLoggedIn(false); +} + +export async function api(path: string, options: RequestInit = {}): Promise { + const t = getToken(); + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record) + }; + if (t) headers['Authorization'] = `Bearer ${t}`; + + const res = await fetch(path, { ...options, headers }); + + if (res.status === 401) { + clearToken(); + goto('/login'); + throw new Error('Unauthorized'); + } + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Request failed: ${res.status}`); + } + + if (res.status === 204) return undefined as T; + return res.json(); +} diff --git a/src/frontend/src/lib/assets/favicon.svg b/src/frontend/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/src/frontend/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/src/frontend/src/lib/auth.svelte.ts b/src/frontend/src/lib/auth.svelte.ts new file mode 100644 index 0000000..6c8f61b --- /dev/null +++ b/src/frontend/src/lib/auth.svelte.ts @@ -0,0 +1,15 @@ +let loggedIn = $state(false); + +export function initAuth() { + if (typeof localStorage !== 'undefined') { + loggedIn = !!localStorage.getItem('token'); + } +} + +export function setLoggedIn(value: boolean) { + loggedIn = value; +} + +export function isLoggedIn() { + return loggedIn; +} diff --git a/src/frontend/src/lib/index.ts b/src/frontend/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/src/frontend/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/src/frontend/src/lib/signalr.ts b/src/frontend/src/lib/signalr.ts new file mode 100644 index 0000000..a2047df --- /dev/null +++ b/src/frontend/src/lib/signalr.ts @@ -0,0 +1,37 @@ +import { HubConnectionBuilder, HubConnectionState, type HubConnection } from '@microsoft/signalr'; +import { getToken } from './api'; + +let connection: HubConnection | null = null; +let refCount = 0; + +function getConnection(): HubConnection { + if (connection && connection.state !== HubConnectionState.Disconnected) { + return connection; + } + + connection = new HubConnectionBuilder() + .withUrl('/hubs/shopping-list', { + accessTokenFactory: () => getToken() ?? '' + }) + .withAutomaticReconnect([0, 1000, 5000, 10000, 30000]) + .build(); + + return connection; +} + +export async function startConnection(): Promise { + const conn = getConnection(); + if (conn.state === HubConnectionState.Disconnected) { + await conn.start(); + } + refCount++; + return conn; +} + +export async function stopConnection() { + refCount--; + if (refCount <= 0 && connection && connection.state !== HubConnectionState.Disconnected) { + refCount = 0; + await connection.stop(); + } +} diff --git a/src/frontend/src/routes/+layout.svelte b/src/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..f9a2973 --- /dev/null +++ b/src/frontend/src/routes/+layout.svelte @@ -0,0 +1,67 @@ + + +{#if loggedIn && currentPath !== '/login'} +
+
+
+

YesChef

+ +
+
+ +
+ {@render children()} +
+ + +
+{:else} + {@render children()} +{/if} + + diff --git a/src/frontend/src/routes/+page.svelte b/src/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..00492cd --- /dev/null +++ b/src/frontend/src/routes/+page.svelte @@ -0,0 +1,13 @@ + diff --git a/src/frontend/src/routes/lists/+page.svelte b/src/frontend/src/routes/lists/+page.svelte new file mode 100644 index 0000000..f1228de --- /dev/null +++ b/src/frontend/src/routes/lists/+page.svelte @@ -0,0 +1,151 @@ + + +
+
+

Shopping Lists

+ +
+ + {#if showCreate} +
{ e.preventDefault(); createList(); }} class="mb-4 rounded-lg border border-gray-200 bg-white p-4 shadow-sm"> + + + +
+ {/if} + + {#if loading} +

Loading...

+ {:else if lists.length === 0} +
+

No shopping lists yet

+

Create one to get started

+
+ {:else} + + {/if} +
diff --git a/src/frontend/src/routes/lists/[id]/+page.svelte b/src/frontend/src/routes/lists/[id]/+page.svelte new file mode 100644 index 0000000..2afc52f --- /dev/null +++ b/src/frontend/src/routes/lists/[id]/+page.svelte @@ -0,0 +1,207 @@ + + +{#if loading} +

Loading...

+{:else if list} +
+
+
+ +

{list.name}

+

{list.store.name}

+
+ +
+ +
{ e.preventDefault(); addItem(); }} class="mb-4 flex gap-2"> + + +
+ + {#if uncheckedItems.length > 0} +
    + {#each uncheckedItems as item (item.id)} +
  • + +
    + {item.name} + {#if item.recipeTitle} + from {item.recipeTitle} + {/if} +
    + +
  • + {/each} +
+ {:else if checkedItems.length === 0} +

No items yet β€” add some above

+ {/if} + + {#if checkedItems.length > 0} +
+

+ Checked ({checkedItems.length}) +

+
    + {#each checkedItems as item (item.id)} +
  • + +
    + {item.name} + {#if item.checkedByUserName} + {item.checkedByUserName} + {/if} +
    + +
  • + {/each} +
+
+ {/if} +
+{/if} diff --git a/src/frontend/src/routes/login/+page.svelte b/src/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..6c83ded --- /dev/null +++ b/src/frontend/src/routes/login/+page.svelte @@ -0,0 +1,104 @@ + + +
+
+
+

YesChef

+

Family shopping & recipes

+
+ +
{ e.preventDefault(); handleSubmit(); }} class="space-y-4"> +
+ +
+ +
+ +
+ + {#if mode === 'register'} +
+ +
+ {/if} + + {#if error} +

{error}

+ {/if} + + +
+ +

+ {#if mode === 'login'} + New here? + + {:else} + Already have an account? + + {/if} +

+
+
diff --git a/src/frontend/src/routes/recipes/+page.svelte b/src/frontend/src/routes/recipes/+page.svelte new file mode 100644 index 0000000..4a89a93 --- /dev/null +++ b/src/frontend/src/routes/recipes/+page.svelte @@ -0,0 +1,72 @@ + + +
+
+

Recipes

+ + + New recipe + +
+ + + + {#if loading} +

Loading...

+ {:else if filtered.length === 0} +

+ {search ? 'No recipes match your search' : 'No recipes yet β€” add one above'} +

+ {:else} + + {/if} +
diff --git a/src/frontend/src/routes/recipes/[id]/+page.svelte b/src/frontend/src/routes/recipes/[id]/+page.svelte new file mode 100644 index 0000000..4f23747 --- /dev/null +++ b/src/frontend/src/routes/recipes/[id]/+page.svelte @@ -0,0 +1,148 @@ + + +{#if loading} +

Loading...

+{:else if recipe} +
+ + +
+
+

{recipe.title}

+ {#if recipe.description} +

{recipe.description}

+ {/if} +
+ {#if recipe.servings} + Serves {recipe.servings} + {/if} + By {recipe.createdBy} +
+
+
+ +
+ + +
+ + {#if showAddToList} +
+

Choose a list:

+ {#if lists.length === 0} +

No active lists. Create one first.

+ {:else} +
+ {#each lists as list} + + {/each} +
+ {/if} +
+ {/if} + + {#if recipe.ingredients.length > 0} +
+

Ingredients

+
    + {#each recipe.ingredients as ingredient} +
  • + {#if ingredient.quantity} + {ingredient.quantity} + {/if} + {ingredient.name} +
  • + {/each} +
+
+ {/if} + + {#if recipe.instructions} +
+

Instructions

+
+ {recipe.instructions} +
+
+ {/if} +
+{/if} diff --git a/src/frontend/src/routes/recipes/new/+page.svelte b/src/frontend/src/routes/recipes/new/+page.svelte new file mode 100644 index 0000000..2f96e8c --- /dev/null +++ b/src/frontend/src/routes/recipes/new/+page.svelte @@ -0,0 +1,128 @@ + + +
+ +

New Recipe

+ +
{ e.preventDefault(); save(); }} class="space-y-4"> + + + + +
+ +
+ +
+ Ingredients + {#each ingredients as ingredient, idx} +
+ + + {#if ingredients.length > 1} + + {/if} +
+ {/each} + +
+ + + + +
+
diff --git a/src/frontend/src/routes/stores/+page.svelte b/src/frontend/src/routes/stores/+page.svelte new file mode 100644 index 0000000..fbbeb6e --- /dev/null +++ b/src/frontend/src/routes/stores/+page.svelte @@ -0,0 +1,101 @@ + + +
+

Stores

+ +
{ e.preventDefault(); addStore(); }} class="mb-6 flex gap-2"> + + +
+ + {#if loading} +

Loading...

+ {:else if stores.length === 0} +

No stores yet β€” add one above

+ {:else} +
    + {#each stores as store (store.id)} +
  • + {#if editingId === store.id} +
    { e.preventDefault(); saveEdit(); }} class="flex flex-1 gap-2"> + + + +
    + {:else} + {store.name} + + + {/if} +
  • + {/each} +
+ {/if} +
diff --git a/src/frontend/src/service-worker.ts b/src/frontend/src/service-worker.ts new file mode 100644 index 0000000..d601404 --- /dev/null +++ b/src/frontend/src/service-worker.ts @@ -0,0 +1,45 @@ +/// +/// +/// +/// + +import { build, files, version } from '$service-worker'; + +const sw = self as unknown as ServiceWorkerGlobalScope; +const CACHE = `cache-${version}`; +const ASSETS = [...build, ...files]; + +sw.addEventListener('install', (event) => { + event.waitUntil( + caches + .open(CACHE) + .then((cache) => cache.addAll(ASSETS)) + .then(() => sw.skipWaiting()) + ); +}); + +sw.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then(async (keys) => { + for (const key of keys) { + if (key !== CACHE) await caches.delete(key); + } + await sw.clients.claim(); + }) + ); +}); + +sw.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') return; + + const url = new URL(event.request.url); + + // Don't cache API or SignalR requests + if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/hubs/')) return; + + event.respondWith( + caches.match(event.request).then((cached) => { + return cached || fetch(event.request); + }) + ); +}); diff --git a/src/frontend/static/favicon.svg b/src/frontend/static/favicon.svg new file mode 100644 index 0000000..f3d6b7c --- /dev/null +++ b/src/frontend/static/favicon.svg @@ -0,0 +1,4 @@ + + + Y + diff --git a/src/frontend/static/icons/icon-192.png b/src/frontend/static/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..ba890220808184ac3b06dd4c3bf1f6302b848ce5 GIT binary patch literal 2532 zcmcJR`#%%U>Le|o>}+uNPoD61+90N7}C z%Hpi}ty?=X>%{k2&LwF8*1xr~Fm=3<&Kn>+cEWCN`Z?wGyAsT%)iS9;@-ETK`6ZG0 zEa%xX^%A|db-Nqx9C95+7$QfCnh*I23;eP+D!FAl?=hH6dP<{1Z}HbbZ!O6RcfVWW zV9)i98EQzWD4{63{QES@N*W-+cc0)#j)f23)s%zqRW3>pu25(hs zF3UQRU?@d$l(k5VQn4gCZZyxuAcr)Vw_z>P4h#2A!=TGZSvXqGObu=^q(L_QcxqNc zGiH}HtSrSIhzpM_{Vgn**^q`;jz!H$@Bp5tmC-+|yjHYdNgzuge`;tg^^moIl1UcI zqh$So+fa1xMQ~K4LDnl{`-m+pynOXNuZWB6hp~ZaK_J!He@!0fap{f+2YFTCs^BD* z{I*;L;?r*k`SP6<=0f|h25Dj?PT8MzOas_xfP-yvpd0QQN*xnxst{s;B*2Oh}dxAdko{~gVDqGUQR8tr4(TIC0*0{ zor;O6*;*_FtI43vBdgRR{AcK2{jX1OfGdfDY^DZ z@X7FpxG{;VUlz`(U=E**u5-tFYnDuZ$nJ5})ELV#_N}T3&>8U7sU4g}jW0id&OolN z)I_H(1Yj1cn<@kSz}8n$U@JE@(k9cF=p=O z^K>H%}^089$ z9qwgn;^rG6x!B1T485sIK$icK%~9{F_LW$`*uXSM8h$X8>~ z<{;28iklk0KjXDAakD{?dWm#=a95uFOuS9nb40ty#TV5lSBxKw?GPzjO1}K<%9N+B zoJo{zG)&~(Ys(ka>vlP!=Bvz$**NDiueoM?nwuc5L!{&+`BLmpLzasw(6c^8??Zyw zj}*((!(U*-1l!5wO?J+B2RK5?Y(NHi;!#HaV|3#YCJ9MPPVI~EtxisRSFw+gUUlt> zN>EPV>df`bWYTYhZSI6F2;?Gih<`TX^73Ghba%nW`0<#xcd+M+XjKm}35f9gANmO; z4}c!GlgXf8fctmeH@x1A+l<+58akxfw=5!;i?rlhQn_h2=he3>5K$Gc%@N97#`=9( zZlP0Sj*lJG5!KT(X~U<-7Vve9!_b#emsiqo1ZQ{;$GRwaX$QsHp5wcj|A%x!<5o-x zt9-MxU|`}A&|KzL-!1;xL10RUo3*RWMOCXWM{|Nd+=Mpug8}Oy_90WPpD~VV zO#L@zZhz&iM!Y&ietxMCBfQ&P&D0)O0C0}&>o>hMBSCF3ekArT@5In4fp=JED(%93 zok5xbf2#NaSeT`E4Yn*!7fcFWlH)bg$AYgwSGPd8KUP)}4vS0(jub{vCXdG) z{u-MS-g6_289F`mo;S((P$`2NWtS@cwmVJg7P4X+N%I*xW?#H1`T<3e+or>$4 zUIeC-Ktr>wA^*U$!VJ78C#&rEN@J$prw;{Nls9a7O&xOb#<`-D+wS#*C*<7&yXq3a zk}z4aC#iLA?zbb8D4;k}D88&WLy*Hm!{tXRc^l+k996xJxwan)dmIsjndK`5 zv4rr6Bi~zdXrx({!bO@8UZrD6qbpACZMOtaZbsuZ2X&sYh<|HAdwU2rs$FfOW3mgy zE;{kfZ3lsH4xDK0lR*+W-C6uRbiu6{`Hu%1e}BkdtY5x&S8z-MFRA9kuZGO85Mw=r zotJ{SW^<;(rvM+t7$!#IwbpAn^;Gl=T(VT2qE4u$Qcl%%uIwHLi; z5(pO|CJiKTr#0AaRG>PC2xzSo;T z(ezV_`IP($3DoI9LAT7m3o7A1f``9Wx*ce)m-!ER(LWuz5unre0z7%oH2$?}*Csgt zJf-WNb?Y;J^)%W9a!lWGzq|WO5(BkA%I5LHt2XyLlx`iLh%MIL zX^PxZpBj9JdO*_@39C=PfN1_uAqmODNB4~wnQvN`-IaB^i-Ot-j$StSwE%SIe#j z0EnBJ9y8I|g0YS4$bb zRK&TpTat!-Ys3))A!*&HC)=c%k$`qo)&Qd~CZxXu_=ROi(p28o-WLM}m^#sM737;H zG0;R?Lg+qKoaNt<1c?Tila6nk^wx4lv;5{=aL^7B!&Dk31D#^1Kgb@b4Fy2_iqvhiG-cjRVxTf4NAD- z6|aqe{t5$V_w@4*B2snAuFxTd;0Ytr4n-Q31PkLa-N=>3M6BKK20ei*58(QATymZ7RGkw%Jb`?OcIG+@WL3i<1lqet6#^Mj6*zy+(e5y}4ZX$N1+ zB1rZLfz4KJZ;w}O%yB{+o$OuazN5DjCdIctuG#l}$MHDU{Rp2E8u`5F?PV8_3jxn` z2@`~JY0F3I{7UUVD)F<9ueVQTHOBSVb ztOg<-{qy|l%N5j%pAc&n@B6XEscNUTQp`bW9Q~^F)yCVBnb+zXWFS(`FwkDM{3mUy zX=%K~DuX-r$<(~PZK~{<60|x8zqxQ2b*5AKlG4d<1^!VdOM?HbOKx{~?{hAVH8x64 zoC&1A+5Q_#l)k!9S0SCl58v;QA?f=w%6Va;a{p12bamf~b1~_hcW9#7SU)cl!ZV@h zJbWmgs-tmVURbWCIVErYa^m-|W~bZfVNQ>$XF?t*Sdx=*o-7K@0SU@*)6aX`#Dt@f z**AvihZERA1Fz8Sy;$v+rqdHQT+>WaLSCDCkBcKn#)l>n$r(ATp$Er4@F{OB-_c8) zy(G58MD_7bACIGpYNwFZC}YLYh6*8&cthHHDUVd(Lp^8j)p*x=A<*Ax;38S`dhGY^ z_=ztK3^S*iDdv>_vjQO?J47+@v1)MQGTzanHy=KXE~zts!h;$142y~6$?E-__4-t% za2UZ|1)8DrEAd8KtmMFjTtesNY}cb9XQ=Gx?u(4=WjR}w)q`fkp#cJ+f>U9_D8zYb zlkkT;e`Gq0@hi_7F>mL5oHjLb5(Ao_7oz)OqN?dr&I>tXAS&tn=tT`)1u?ikjrpyM zPu?CzU}=p16RTb<7<&1GT_AIp27Fe=SK$)x#l=8?8x?U%?;O# zz1kS+E6Y6EAO+k@>H1S1C2nbMWT#VU1g*VS-#_8D(~NSYUn;c5mvfKBbg!kp9{S-o z9=7oEO*SZ<>n-l8CeGDO3`!qDdu*wXEo7?$*-IG=70vN6PWR*y2_!&|lzI`dOdr}2=G6JhP2 z_O_S!a?e$rIPHcer{HHMa{ae$LXe_PaZRcyisK1mWYtrGpkv>h%~qP8`z02XCLSK$ zT$7~aIN$C!5!U5au%>bq@EfKhg}&Tu#4FdzI%1? zve%`+xaq)a*4S}kHM(>})V@WC7SxG~?F@@m7H{N`UwSUOlkFO0s$Ey0eJ@DeBT>lg zQLP(4nO}Ml%+E#+gniz{Y@&#|Wgp2$`hJx9yUAqPKeK?d|ANpVes`TlqtKk`IlL zrX)`pln;OSQq~58Os@YkY3K*m+qQHMeyFeT84ctK@qCYLZpBiUCSmy1l)q$xlQ=t2 z5z0Ld6zgqjThAq(W%Agbo{GvW+zu=`!?(zRmCo)=-jqq+!aTYU3AP1u*z;|nqWQ|U z{JY+UDevUK3t-Jarg=SMb8+*=T;BU<`)9H*XADcdW zhjI6${JydJ((#0blCC>#Lg4MIk)o=0sM#);_j&T<$*QQ{qVrtcoSBnZSWeS)gf{je z?`q2~cnHv|!0n&OlYEV4;$V;S0=1l**_L?JSUShaBz^bZZ>%N#3G?{ml(O9&_WLQ-*ap~Xtj^6d* z)%Q#7+cp>^RnL4_)3O(DDAaKaU`z+9d7C`2U2mb@nz|Z~`oW8Pi#=a6{@Tl_c+Va6 zgP}QXC|_64%R-Q13QZ90W1mhwAO~fba$Ap@WE5QCe~|7@d0L}kv8DR>EhEE*Eld;= zv_>PNRBlQa_KN7GSNzqlcD!_^aR0=E-eAq3{sKpWF=eh*4|++r7=>}~9E`7oAv)@* zrmDC@p!|`xbv2kh0fWN~b7hb=cJE7T77H%bOcc?d&XT zBvM(okzoYOedD5W5cO?3df;i)>mPqPPtL|z1biUKU{Cm?uluWB_i;Kuo9DOqScF?! zECk%q!pna&Pf&Juw9oF6(+;$sv>#3)gpB3-ea<6(vfq%10VF<7+WKc6i5{}lV)gaK z3%*)*ggj2RD&$L|OnN5$|a?t6=+;+ULyM@80iNxa-8JFNj_^bne z>dXZH;4wSly_0+0NllNm4RZ8+zAh}HVz>M#b86mvE&GccU-IxD2{1>TMxW{0*z)%QeR0{sQ(x@FBb=ORlFV2Ds^zpeV?pp_ z<{Dm^{-S!zzi@GBF(c2|`|rGOYs}*_UY10uv-a)wg9i@p!R||V7lky?=Q-IsFm^?5 zH8>a~r4#sF$AgFJa)rx30Z3R1=Ywclyqa>!Mn~_;b5{NFuXiskeP}P1KfdfuYl#-D zTM1wklKk6!+sQN4uN!48&tDp1&RGncO=F2_FE=pX?{{!PGfNn`Lv4>Xr8L=Y&`ky) z#ILq*gF{zq+-zmuqUojZsE_tHBDdvK_$)U~WGR0>*|n94^4O-y()$Jcqq6-{(BM2O_b9;Y&Tx7v9I}KW8akPzvh~$=9_r_g5|^Q z<_=CdsGTIf8hfd zK2MQx%@J)C1GH!!H*R2)&g!fvz`lNT@B~UEqkvIpHHoN|lTo9nh75gFaCs@b90|ad zD5QMILB36`VcudmbF_;!6JBd|g*78c*H6NX46VZf(1Cl*wT$sM@Z?x6E-h5U;_?K! zpMG?2{ELFa8jg#N)7^9g5#BBl{Gy8%RR?hxYP^SAxa(<013?yB@>HNVtjF1Yy@()z{!g{dKyvFhJ<=JPw z!G3r6SpPHD;u}AXJctk0>tCqnLY4fd(EhV^-UQ;}@|0IiuZXXOLsZY~S$?t=w?D}K z=$-3J(~dVBd`-c~D)6w|Z8RlGK9F@US4&i4^K5p3aFo360d(QJ89RffFe}4H5=ppI zarU)8#UPgZK*7hGIL3mXK5972+KauxD!pMuxDEQNfOV}-1+S>F_9@+SAihTCRe$2s z^hMvRa#3_6Knf)U^d9f7s6XfM zOKakW12Jo%<)~8dPV?sWe0}X^t#MJ$bT`~>q&E&my7sizq4v9P>Pqrvv>fHRc*P~UVS$}mF*6T1Q zZjCu{?VrLzAS*#Mg|HJ{JoxnGJ^L`is4Np@p$IVI$v6(V?HBH|rNw+gpi;#VU`>S5 zJKV;|iSmTeq7LcKa-ver$5n!un3zp4BN7N2QOKEW5$**Tc{T3=pv8Ki$6F~S&bSe` zGH-;!`w#2Ojz0L=YdiUZL@y@mICeeZZN^B#8+7*tsr?jMaJJ#5AB6*e`f5mh;v5c} zL{F2QU5S<@E$Vx@HY6-uypah)QlIOd{lK%H0Xc+NC`BElo ztFKJ~HI706+2MHHg+8{EnUz7sAq%*FvM{)XzYIW}kQWYah4sNIB+xwBxtueGIPt1b zmU*KD8Y>2aRVqD_L~kbby{UsBIS4xAuZ{WqMz1rK?0IS%Z3~2C(F1Dj7M)_#Z5Q(S zzB7rB(`6n;K#J@CJP9S>`Tp0PKqIxfO&6Ck;ST&P{(o}tH{1F@?1K1I&rm2L-B1WT zwCd#i!w~Nju*w3e_+O1<;s7fwMB0p<`t@&QuYelfE(#=(NQ|39dDp-A-W8H|98r4% z09pdJXXqcn%wjOO835fT<%NKMV|;1KBA^9w-EQZxRx1>-sy6s43`nj)Wmw2z1LO*| zd`=uhAwX(^ZAaaTNaeL40QT)D?NuxN{?#k}EtKApe^ho%uc)NwDOL9fi0h6Q;Ll9} z&|hZiQyTQZ5db>Oeeoqb1Qh%^M6DX^AN36?7I4m4T`T=3o$=T!0vqV3LE#zzT$p8| zi-Dq;RebVC&IJ2g}= zErB^LBLNco>O9dPv5*94_215NBwwkRz>sct4kb&x6aW$_g`mEzvrxV>3(gCRq|W4kEpwydU0N4dPbJ_u-si$S+Nv@NgHzhrp5l(Ua09=Q^j)sUfCc*by#9-MKkuTw~ ztWM8TFHwQHIAEM!_Omzddw=Ddq@(FUZHar)qU{_`k)sQRw8K&os) zUAZIPF%IUhdN};h32zq);NLEzt^5Z^h`-|3ivV2*W(HMd&D?4s1nKkhIcgH1iNh6_ zxGyR12F!>1fVEX3p=A{a*(n8{Jo~-~f38X1Mv_=7+4kHi UxzLmfV;nF$Zh0*K$e*GA0X60tVgLXD literal 0 HcmV?d00001 diff --git a/src/frontend/static/manifest.webmanifest b/src/frontend/static/manifest.webmanifest new file mode 100644 index 0000000..765b0b9 --- /dev/null +++ b/src/frontend/static/manifest.webmanifest @@ -0,0 +1,12 @@ +{ + "name": "YesChef", + "short_name": "YesChef", + "start_url": "/lists", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#16a34a", + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/src/frontend/static/robots.txt b/src/frontend/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/src/frontend/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/src/frontend/svelte.config.js b/src/frontend/svelte.config.js new file mode 100644 index 0000000..ba669c3 --- /dev/null +++ b/src/frontend/svelte.config.js @@ -0,0 +1,16 @@ +import adapter from '@sveltejs/adapter-node'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + compilerOptions: { + runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true) + }, + kit: { + adapter: adapter({ out: 'build' }), + alias: { + $lib: 'src/lib' + } + } +}; + +export default config; diff --git a/src/frontend/tsconfig.json b/src/frontend/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/src/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts new file mode 100644 index 0000000..fc89ad2 --- /dev/null +++ b/src/frontend/vite.config.ts @@ -0,0 +1,16 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + proxy: { + '/api': 'http://localhost:5291', + '/hubs': { + target: 'http://localhost:5291', + ws: true + } + } + } +}); diff --git a/test-e2e-multiuser.mjs b/test-e2e-multiuser.mjs new file mode 100644 index 0000000..4507d5b --- /dev/null +++ b/test-e2e-multiuser.mjs @@ -0,0 +1,147 @@ +import { chromium } from 'playwright'; + +const browser = await chromium.launch({ channel: 'chrome', headless: false, slowMo: 200 }); + +const contextA = await browser.newContext({ viewport: { width: 390, height: 844 } }); +const contextB = await browser.newContext({ viewport: { width: 390, height: 844 } }); +const pageA = await contextA.newPage(); +const pageB = await contextB.newPage(); + +const BASE = 'http://localhost:5173'; + +console.log('⏳ Arrange the two browser windows side by side. Starting in 8 seconds...'); +await new Promise(r => setTimeout(r, 8000)); + +async function step(name, fn) { + process.stdout.write(`β–Ά ${name}... `); + try { + await fn(); + console.log('βœ…'); + } catch (e) { + console.log(`❌ ${e.message}`); + throw e; + } +} + +async function register(page, name) { + await page.goto(BASE); + await page.waitForURL('**/login', { timeout: 5000 }); + await page.click('text=Create account'); + await page.fill('input[placeholder="Name"]', name); + await page.fill('input[placeholder="Password"]', 'password123'); + await page.fill('input[placeholder="Family code"]', 'dev-family-code'); + await page.click('button[type="submit"]'); + await page.waitForURL('**/lists', { timeout: 5000 }); +} + +try { + await step('Register User A (Mom)', async () => { + await register(pageA, 'Mom'); + }); + + await step('Register User B (Dad)', async () => { + await register(pageB, 'Dad'); + }); + + // Mom creates a store + await step('Mom creates store "Kroger"', async () => { + await pageA.click('a:has-text("Stores")'); + await pageA.waitForURL('**/stores'); + await pageA.fill('input[placeholder="New store name"]', 'Kroger'); + await pageA.click('button:has-text("Add")'); + await pageA.waitForSelector('text=Kroger'); + }); + + // Both on lists overview β€” Mom creates a list, Dad should see it appear via SignalR + await step('Mom navigates to Lists', async () => { + await pageA.click('a:has-text("Lists")'); + await pageA.waitForURL('**/lists'); + }); + + await step('Mom creates list β†’ Dad sees it appear on overview', async () => { + await pageA.click('text=+ New list'); + await pageA.fill('input[placeholder="List name"]', 'Weekly Groceries'); + await pageA.selectOption('select', { label: 'Kroger' }); + await pageA.click('button:has-text("Create")'); + await pageA.waitForSelector('text=Weekly Groceries'); + // Dad should see it appear without navigating away + await pageB.waitForSelector('text=Weekly Groceries', { timeout: 5000 }); + }); + + // Both open the list + await step('Mom opens the list', async () => { + await pageA.click('text=Weekly Groceries'); + await pageA.waitForURL(/\/lists\/\d+/); + }); + + await step('Dad opens the same list', async () => { + await pageB.click('text=Weekly Groceries'); + await pageB.waitForURL(/\/lists\/\d+/); + }); + + // Mom adds items β€” Dad should see them appear in real-time + await step('Mom adds "Milk" β†’ Dad sees it appear', async () => { + await pageA.fill('input[placeholder="Add an item..."]', 'Milk'); + await pageA.click('button:has-text("Add")'); + await pageA.waitForSelector('text=Milk'); + await pageB.waitForSelector('text=Milk', { timeout: 5000 }); + }); + + await step('Mom adds "Eggs" β†’ Dad sees it appear', async () => { + await pageA.fill('input[placeholder="Add an item..."]', 'Eggs'); + await pageA.click('button:has-text("Add")'); + await pageA.waitForSelector('text=Eggs'); + await pageB.waitForSelector('text=Eggs', { timeout: 5000 }); + }); + + await step('Mom adds "Bread" β†’ Dad sees it appear', async () => { + await pageA.fill('input[placeholder="Add an item..."]', 'Bread'); + await pageA.click('button:has-text("Add")'); + await pageA.waitForSelector('text=Bread'); + await pageB.waitForSelector('text=Bread', { timeout: 5000 }); + }); + + // Dad checks off Milk β€” Mom should see it checked + await step('Dad checks "Milk" β†’ Mom sees it checked', async () => { + await pageB.click('button[aria-label="Check Milk"]'); + await pageB.waitForSelector('text=Checked (1)'); + await pageA.waitForSelector('text=Checked (1)', { timeout: 5000 }); + }); + + // Mom checks off Eggs β€” Dad should see it + await step('Mom checks "Eggs" β†’ Dad sees it checked', async () => { + await pageA.click('button[aria-label="Check Eggs"]'); + await pageA.waitForSelector('text=Checked (2)'); + await pageB.waitForSelector('text=Checked (2)', { timeout: 5000 }); + }); + + // Dad unchecks Milk β€” Mom should see it unchecked + await step('Dad unchecks "Milk" β†’ Mom sees it unchecked', async () => { + await pageB.click('button[aria-label="Uncheck Milk"]'); + await pageB.waitForSelector('text=Checked (1)'); + await pageA.waitForSelector('text=Checked (1)', { timeout: 5000 }); + }); + + // Dad adds an item β€” Mom should see it + await step('Dad adds "Butter" β†’ Mom sees it appear', async () => { + await pageB.fill('input[placeholder="Add an item..."]', 'Butter'); + await pageB.click('button:has-text("Add")'); + await pageB.waitForSelector('text=Butter'); + await pageA.waitForSelector('text=Butter', { timeout: 5000 }); + }); + + // Mom removes Bread β€” Dad should see it disappear + await step('Mom removes "Bread" β†’ Dad sees it disappear', async () => { + await pageA.click('button[aria-label="Remove Bread"]'); + await pageA.waitForFunction(() => !document.body.textContent.includes('Bread'), { timeout: 3000 }); + await pageB.waitForFunction(() => !document.body.textContent.includes('Bread'), { timeout: 5000 }); + }); + + console.log('\nπŸŽ‰ All multi-user tests passed! Real-time sync works.'); +} catch (e) { + console.error(`\nπŸ’₯ Test failed: ${e.message}`); + process.exitCode = 1; +} finally { + await new Promise(r => setTimeout(r, 2000)); + await browser.close(); +} diff --git a/test-e2e.mjs b/test-e2e.mjs new file mode 100644 index 0000000..0f90e46 --- /dev/null +++ b/test-e2e.mjs @@ -0,0 +1,147 @@ +import { chromium } from 'playwright'; + +const browser = await chromium.launch({ channel: 'chrome', headless: false, slowMo: 300 }); +const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); +const page = await context.newPage(); + +const BASE = 'http://localhost:5173'; +let failed = false; + +async function step(name, fn) { + process.stdout.write(`β–Ά ${name}... `); + try { + await fn(); + console.log('βœ…'); + } catch (e) { + console.log(`❌ ${e.message}`); + failed = true; + throw e; + } +} + +try { + await step('Navigate to app β†’ should redirect to /login', async () => { + await page.goto(BASE); + await page.waitForURL('**/login', { timeout: 5000 }); + }); + + await step('Register a new account', async () => { + await page.click('text=Create account'); + await page.fill('input[placeholder="Name"]', 'TestChef'); + await page.fill('input[placeholder="Password"]', 'password123'); + await page.fill('input[placeholder="Family code"]', 'dev-family-code'); + await page.click('button[type="submit"]'); + await page.waitForURL('**/lists', { timeout: 5000 }); + }); + + await step('Create store "Kroger"', async () => { + await page.click('a:has-text("Stores")'); + await page.waitForURL('**/stores'); + await page.fill('input[placeholder="New store name"]', 'Kroger'); + await page.click('button:has-text("Add")'); + await page.waitForSelector('text=Kroger'); + }); + + await step('Create store "Costco"', async () => { + await page.fill('input[placeholder="New store name"]', 'Costco'); + await page.click('button:has-text("Add")'); + await page.waitForSelector('text=Costco'); + }); + + await step('Create shopping list "Weekly Groceries"', async () => { + await page.click('a:has-text("Lists")'); + await page.waitForURL('**/lists'); + await page.click('text=+ New list'); + await page.fill('input[placeholder="List name"]', 'Weekly Groceries'); + await page.selectOption('select', { label: 'Kroger' }); + await page.click('button:has-text("Create")'); + await page.waitForSelector('text=Weekly Groceries'); + }); + + await step('Open list and add 5 items', async () => { + await page.click('text=Weekly Groceries'); + await page.waitForURL(/\/lists\/\d+/); + + for (const item of ['Milk', 'Eggs', 'Bread', 'Chicken breast', 'Broccoli']) { + await page.fill('input[placeholder="Add an item..."]', item); + await page.click('button:has-text("Add")'); + await page.waitForSelector(`text=${item}`); + } + }); + + await step('Check off Milk and Eggs', async () => { + await page.click('button[aria-label="Check Milk"]'); + await page.waitForSelector('text=Checked (1)'); + await page.click('button[aria-label="Check Eggs"]'); + await page.waitForSelector('text=Checked (2)'); + }); + + await step('Uncheck Milk', async () => { + await page.click('button[aria-label="Uncheck Milk"]'); + await page.waitForSelector('text=Checked (1)'); + }); + + await step('Remove Broccoli', async () => { + await page.click('button[aria-label="Remove Broccoli"]'); + await page.waitForFunction(() => !document.body.textContent.includes('Broccoli'), { timeout: 3000 }); + }); + + await step('Create recipe "Chicken Stir Fry"', async () => { + await page.click('a:has-text("Recipes")'); + await page.waitForURL('**/recipes'); + await page.click('text=+ New recipe'); + await page.waitForURL('**/recipes/new'); + await page.fill('input[placeholder="Recipe title"]', 'Chicken Stir Fry'); + await page.fill('textarea[placeholder*="description"]', 'Quick weeknight dinner'); + + const qtyInputs = page.locator('input[placeholder="Qty"]'); + const nameInputs = page.locator('input[placeholder="Ingredient name"]'); + await qtyInputs.nth(0).fill('2 lbs'); + await nameInputs.nth(0).fill('chicken breast'); + + await page.click('text=+ Add ingredient'); + await qtyInputs.nth(1).fill('1 head'); + await nameInputs.nth(1).fill('broccoli'); + + await page.click('text=+ Add ingredient'); + await qtyInputs.nth(2).fill('3 tbsp'); + await nameInputs.nth(2).fill('soy sauce'); + + await page.fill('textarea[placeholder*="Instructions"]', '1. Cut chicken\n2. Stir fry\n3. Add sauce'); + await page.click('button:has-text("Save Recipe")'); + await page.waitForURL(/\/recipes\/\d+/); + }); + + await step('Add recipe ingredients to shopping list', async () => { + await page.click('button:has-text("Add to list")'); + await page.waitForSelector('text=Choose a list'); + await page.click('text=Weekly Groceries'); + await page.waitForURL(/\/lists\/\d+/); + await page.waitForSelector('text=chicken breast'); + }); + + await step('Verify list has recipe items', async () => { + const content = await page.textContent('body'); + for (const item of ['2 lbs chicken breast', '1 head broccoli', '3 tbsp soy sauce']) { + if (!content.includes(item)) throw new Error(`Missing recipe item: ${item}`); + } + }); + + await step('Navigate back to lists overview', async () => { + await page.click('text=← Back'); + await page.waitForURL('**/lists'); + }); + + await step('Sign out', async () => { + await page.click('text=Sign out'); + await page.waitForURL('**/login'); + }); + + console.log('\nπŸŽ‰ All tests passed!'); +} catch (e) { + console.error(`\nπŸ’₯ Test suite stopped at failure: ${e.message}`); + process.exitCode = 1; +} finally { + await new Promise(r => setTimeout(r, 2000)); + await browser.close(); +}