Add per-store sections to group list items by walk order
Each store gets a StoreSection catalog (Produce, Dairy, etc.). Default sections are seeded on store creation; admins can rename, reorder, add, or delete. ShoppingListItem.SectionId is a nullable FK that sets to null when the section is deleted, so items survive section churn. The list detail view groups items by section in walk order, with Uncategorized appended last. Section dropdowns on each row (and the add-item form) let users assign or reassign on the fly. SignalR broadcasts include sectionId on adds and a new ItemSectionChanged event for live re-grouping. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,245 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using YesChef.Api.Entities;
|
||||||
|
using YesChef.Api.Features.Stores;
|
||||||
|
using YesChef.Api.IntegrationTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace YesChef.Api.IntegrationTests.Features;
|
||||||
|
|
||||||
|
public class StoreSectionEndpointsTests : AuthenticatedIntegrationTest
|
||||||
|
{
|
||||||
|
public Store Store { get; private set; } = null!;
|
||||||
|
|
||||||
|
[Before(Test)]
|
||||||
|
public async Task SetUpStore()
|
||||||
|
{
|
||||||
|
Store = await Data.CreateStoreAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Create_store_seeds_default_sections()
|
||||||
|
{
|
||||||
|
var response = await Client.PostAsJsonAsync("/api/stores",
|
||||||
|
new StoreEndpoints.CreateStoreRequest("Whole Foods"));
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||||
|
|
||||||
|
var newStore = await UseDbAsync(db => db.Stores.SingleAsync(s => s.Name == "Whole Foods"));
|
||||||
|
var seeded = await UseDbAsync(db =>
|
||||||
|
db.StoreSections.Where(s => s.StoreId == newStore.Id).OrderBy(s => s.SortOrder).Select(s => s.Name).ToListAsync());
|
||||||
|
|
||||||
|
await Assert.That(seeded).IsEquivalentTo(StoreSectionDefaults.Names);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task List_returns_sections_in_sort_order()
|
||||||
|
{
|
||||||
|
await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
db.StoreSections.AddRange(
|
||||||
|
new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Bravo", SortOrder = 2 },
|
||||||
|
new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Alpha", SortOrder = 1 },
|
||||||
|
new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Charlie", SortOrder = 3 });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
var sections = await Client.GetFromJsonAsync<List<JsonElement>>($"/api/stores/{Store.Id}/sections");
|
||||||
|
|
||||||
|
var names = sections!.Select(s => s.GetProperty("name").GetString()!).ToArray();
|
||||||
|
await Assert.That(names).IsEquivalentTo(new[] { "Alpha", "Bravo", "Charlie" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task List_returns_404_for_unknown_store()
|
||||||
|
{
|
||||||
|
var response = await Client.GetAsync("/api/stores/99999/sections");
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Create_persists_section()
|
||||||
|
{
|
||||||
|
var response = await Client.PostAsJsonAsync($"/api/stores/{Store.Id}/sections",
|
||||||
|
new StoreSectionEndpoints.CreateSectionRequest("Produce", 0));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||||
|
var section = await UseDbAsync(db => db.StoreSections.SingleAsync(s => s.StoreId == Store.Id));
|
||||||
|
await Assert.That(section.Name).IsEqualTo("Produce");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Create_returns_409_for_duplicate_name()
|
||||||
|
{
|
||||||
|
await Client.PostAsJsonAsync($"/api/stores/{Store.Id}/sections",
|
||||||
|
new StoreSectionEndpoints.CreateSectionRequest("Produce"));
|
||||||
|
|
||||||
|
var response = await Client.PostAsJsonAsync($"/api/stores/{Store.Id}/sections",
|
||||||
|
new StoreSectionEndpoints.CreateSectionRequest("Produce"));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Update_changes_name_and_sort_order()
|
||||||
|
{
|
||||||
|
var created = await Client.PostAsJsonAsync($"/api/stores/{Store.Id}/sections",
|
||||||
|
new StoreSectionEndpoints.CreateSectionRequest("Produce"));
|
||||||
|
var section = await created.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var sectionId = section.GetProperty("id").GetInt32();
|
||||||
|
|
||||||
|
var response = await Client.PutAsJsonAsync($"/api/stores/{Store.Id}/sections/{sectionId}",
|
||||||
|
new StoreSectionEndpoints.UpdateSectionRequest("Fresh", 5));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
|
||||||
|
var refreshed = await UseDbAsync(db => db.StoreSections.SingleAsync(s => s.Id == sectionId));
|
||||||
|
await Assert.That(refreshed.Name).IsEqualTo("Fresh");
|
||||||
|
await Assert.That(refreshed.SortOrder).IsEqualTo(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Delete_removes_section_and_keeps_items_with_null_section()
|
||||||
|
{
|
||||||
|
var sectionId = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var section = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce" };
|
||||||
|
db.StoreSections.Add(section);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return section.Id;
|
||||||
|
});
|
||||||
|
|
||||||
|
var list = await Data.CreateListAsync(b => b.ForStore(Store).CreatedBy(User).WithItem("Bananas"));
|
||||||
|
await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var item = await db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id);
|
||||||
|
item.SectionId = sectionId;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await Client.DeleteAsync($"/api/stores/{Store.Id}/sections/{sectionId}");
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
|
||||||
|
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id));
|
||||||
|
await Assert.That(item.SectionId).IsNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Sections_are_isolated_per_family()
|
||||||
|
{
|
||||||
|
await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
db.StoreSections.Add(new StoreSection
|
||||||
|
{
|
||||||
|
FamilyId = Store.FamilyId,
|
||||||
|
StoreId = Store.Id,
|
||||||
|
Name = "Mine",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
var otherFamily = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var family = new Family { Name = "Other", InviteCode = $"other-{Guid.NewGuid():N}" };
|
||||||
|
db.Families.Add(family);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return family;
|
||||||
|
});
|
||||||
|
var otherStore = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var store = new Store { FamilyId = otherFamily.Id, Name = "Other Store" };
|
||||||
|
db.Stores.Add(store);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return store;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The default user can't see another family's store.
|
||||||
|
var response = await Client.GetAsync($"/api/stores/{otherStore.Id}/sections");
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Add_item_with_section_persists_section_id()
|
||||||
|
{
|
||||||
|
var sectionId = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var section = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce" };
|
||||||
|
db.StoreSections.Add(section);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return section.Id;
|
||||||
|
});
|
||||||
|
var list = await Data.CreateListAsync(b => b.ForStore(Store).CreatedBy(User));
|
||||||
|
|
||||||
|
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
|
||||||
|
new YesChef.Api.Features.ShoppingLists.ShoppingListEndpoints.AddItemRequest("Bananas", 0, sectionId));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||||
|
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id));
|
||||||
|
await Assert.That(item.SectionId).IsEqualTo(sectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Add_item_rejects_section_from_other_store()
|
||||||
|
{
|
||||||
|
var otherStore = await Data.CreateStoreAsync(b => b.Named("Other"));
|
||||||
|
var alienSectionId = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var section = new StoreSection { FamilyId = otherStore.FamilyId, StoreId = otherStore.Id, Name = "Alien" };
|
||||||
|
db.StoreSections.Add(section);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return section.Id;
|
||||||
|
});
|
||||||
|
var list = await Data.CreateListAsync(b => b.ForStore(Store).CreatedBy(User));
|
||||||
|
|
||||||
|
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
|
||||||
|
new YesChef.Api.Features.ShoppingLists.ShoppingListEndpoints.AddItemRequest("Bananas", 0, alienSectionId));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Set_item_section_updates_existing_item()
|
||||||
|
{
|
||||||
|
var sectionId = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var section = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce" };
|
||||||
|
db.StoreSections.Add(section);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return section.Id;
|
||||||
|
});
|
||||||
|
var list = await Data.CreateListAsync(b => b.ForStore(Store).CreatedBy(User).WithItem("Bananas"));
|
||||||
|
var itemId = list.Items[0].Id;
|
||||||
|
|
||||||
|
var response = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section",
|
||||||
|
new YesChef.Api.Features.ShoppingLists.ShoppingListEndpoints.SetItemSectionRequest(sectionId));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
|
||||||
|
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.Id == itemId));
|
||||||
|
await Assert.That(item.SectionId).IsEqualTo(sectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Set_item_section_clears_when_null()
|
||||||
|
{
|
||||||
|
var sectionId = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var section = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce" };
|
||||||
|
db.StoreSections.Add(section);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return section.Id;
|
||||||
|
});
|
||||||
|
var list = await Data.CreateListAsync(b => b.ForStore(Store).CreatedBy(User).WithItem("Bananas"));
|
||||||
|
var itemId = list.Items[0].Id;
|
||||||
|
await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var item = await db.ShoppingListItems.SingleAsync(i => i.Id == itemId);
|
||||||
|
item.SectionId = sectionId;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section",
|
||||||
|
new YesChef.Api.Features.ShoppingLists.ShoppingListEndpoints.SetItemSectionRequest(null));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
|
||||||
|
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.Id == itemId));
|
||||||
|
await Assert.That(item.SectionId).IsNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
|||||||
public DbSet<FamilyMembership> FamilyMemberships => Set<FamilyMembership>();
|
public DbSet<FamilyMembership> FamilyMemberships => Set<FamilyMembership>();
|
||||||
public DbSet<User> Users => Set<User>();
|
public DbSet<User> Users => Set<User>();
|
||||||
public DbSet<Store> Stores => Set<Store>();
|
public DbSet<Store> Stores => Set<Store>();
|
||||||
|
public DbSet<StoreSection> StoreSections => Set<StoreSection>();
|
||||||
public DbSet<ShoppingList> ShoppingLists => Set<ShoppingList>();
|
public DbSet<ShoppingList> ShoppingLists => Set<ShoppingList>();
|
||||||
public DbSet<ShoppingListItem> ShoppingListItems => Set<ShoppingListItem>();
|
public DbSet<ShoppingListItem> ShoppingListItems => Set<ShoppingListItem>();
|
||||||
public DbSet<Recipe> Recipes => Set<Recipe>();
|
public DbSet<Recipe> Recipes => Set<Recipe>();
|
||||||
@@ -44,6 +45,14 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
|||||||
e.Property(s => s.Name).HasMaxLength(100);
|
e.Property(s => s.Name).HasMaxLength(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<StoreSection>(e =>
|
||||||
|
{
|
||||||
|
e.HasOne(s => s.Family).WithMany().HasForeignKey(s => s.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
e.HasOne(s => s.Store).WithMany().HasForeignKey(s => s.StoreId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
e.HasIndex(s => new { s.StoreId, s.Name }).IsUnique();
|
||||||
|
e.Property(s => s.Name).HasMaxLength(100);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<ShoppingList>(e =>
|
modelBuilder.Entity<ShoppingList>(e =>
|
||||||
{
|
{
|
||||||
e.Property(l => l.Name).HasMaxLength(200);
|
e.Property(l => l.Name).HasMaxLength(200);
|
||||||
@@ -61,6 +70,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
|||||||
e.HasOne(i => i.CheckedByUser).WithMany().HasForeignKey(i => i.CheckedByUserId).OnDelete(DeleteBehavior.SetNull);
|
e.HasOne(i => i.CheckedByUser).WithMany().HasForeignKey(i => i.CheckedByUserId).OnDelete(DeleteBehavior.SetNull);
|
||||||
e.HasOne(i => i.RemovedByUser).WithMany().HasForeignKey(i => i.RemovedByUserId).OnDelete(DeleteBehavior.SetNull);
|
e.HasOne(i => i.RemovedByUser).WithMany().HasForeignKey(i => i.RemovedByUserId).OnDelete(DeleteBehavior.SetNull);
|
||||||
e.HasOne(i => i.Recipe).WithMany().HasForeignKey(i => i.RecipeId).OnDelete(DeleteBehavior.SetNull);
|
e.HasOne(i => i.Recipe).WithMany().HasForeignKey(i => i.RecipeId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
e.HasOne(i => i.Section).WithMany().HasForeignKey(i => i.SectionId).OnDelete(DeleteBehavior.SetNull);
|
||||||
e.HasIndex(i => i.FamilyId);
|
e.HasIndex(i => i.FamilyId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ public class ShoppingListItem
|
|||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public int? RecipeId { get; set; }
|
public int? RecipeId { get; set; }
|
||||||
public Recipe? Recipe { get; set; }
|
public Recipe? Recipe { get; set; }
|
||||||
|
public int? SectionId { get; set; }
|
||||||
|
public StoreSection? Section { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime? RemovedAt { get; set; }
|
public DateTime? RemovedAt { get; set; }
|
||||||
public int? RemovedByUserId { get; set; }
|
public int? RemovedByUserId { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace YesChef.Api.Entities;
|
||||||
|
|
||||||
|
public class StoreSection
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int FamilyId { get; set; }
|
||||||
|
public Family Family { get; set; } = null!;
|
||||||
|
public int StoreId { get; set; }
|
||||||
|
public Store Store { get; set; } = null!;
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ public static class ShoppingListEndpoints
|
|||||||
{
|
{
|
||||||
public record CreateListRequest(string Name, int StoreId);
|
public record CreateListRequest(string Name, int StoreId);
|
||||||
public record UpdateListRequest(string Name, int StoreId);
|
public record UpdateListRequest(string Name, int StoreId);
|
||||||
public record AddItemRequest(string Name, int SortOrder = 0);
|
public record AddItemRequest(string Name, int SortOrder = 0, int? SectionId = null);
|
||||||
|
public record SetItemSectionRequest(int? SectionId);
|
||||||
|
|
||||||
private static string OverviewGroup(int familyId) => $"lists-overview-{familyId}";
|
private static string OverviewGroup(int familyId) => $"lists-overview-{familyId}";
|
||||||
|
|
||||||
@@ -104,6 +105,12 @@ public static class ShoppingListEndpoints
|
|||||||
|
|
||||||
if (list is null) return Results.NotFound();
|
if (list is null) return Results.NotFound();
|
||||||
|
|
||||||
|
var sections = await db.StoreSections
|
||||||
|
.Where(s => s.StoreId == list.StoreId && s.FamilyId == familyId)
|
||||||
|
.OrderBy(s => s.SortOrder).ThenBy(s => s.Name)
|
||||||
|
.Select(s => new { s.Id, s.Name, s.SortOrder })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
return Results.Ok(new
|
return Results.Ok(new
|
||||||
{
|
{
|
||||||
list.Id,
|
list.Id,
|
||||||
@@ -111,6 +118,7 @@ public static class ShoppingListEndpoints
|
|||||||
Store = new { list.Store.Id, list.Store.Name },
|
Store = new { list.Store.Id, list.Store.Name },
|
||||||
list.IsArchived,
|
list.IsArchived,
|
||||||
list.UpdatedAt,
|
list.UpdatedAt,
|
||||||
|
Sections = sections,
|
||||||
Items = list.Items.Select(i => new
|
Items = list.Items.Select(i => new
|
||||||
{
|
{
|
||||||
i.Id,
|
i.Id,
|
||||||
@@ -118,6 +126,7 @@ public static class ShoppingListEndpoints
|
|||||||
i.IsChecked,
|
i.IsChecked,
|
||||||
CheckedByUserName = i.CheckedByUser?.Name,
|
CheckedByUserName = i.CheckedByUser?.Name,
|
||||||
i.SortOrder,
|
i.SortOrder,
|
||||||
|
i.SectionId,
|
||||||
RecipeTitle = i.Recipe?.Title
|
RecipeTitle = i.Recipe?.Title
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -164,20 +173,45 @@ public static class ShoppingListEndpoints
|
|||||||
var list = await db.ShoppingLists.FirstOrDefaultAsync(l => l.Id == listId && l.FamilyId == familyId);
|
var list = await db.ShoppingLists.FirstOrDefaultAsync(l => l.Id == listId && l.FamilyId == familyId);
|
||||||
if (list is null) return Results.NotFound();
|
if (list is null) return Results.NotFound();
|
||||||
|
|
||||||
|
// Reject section IDs that don't belong to the list's store/family.
|
||||||
|
if (request.SectionId is int sectionId &&
|
||||||
|
!await db.StoreSections.AnyAsync(s => s.Id == sectionId && s.StoreId == list.StoreId && s.FamilyId == familyId))
|
||||||
|
return Results.BadRequest(new { error = "Unknown section." });
|
||||||
|
|
||||||
var item = new ShoppingListItem
|
var item = new ShoppingListItem
|
||||||
{
|
{
|
||||||
FamilyId = familyId,
|
FamilyId = familyId,
|
||||||
ShoppingListId = listId,
|
ShoppingListId = listId,
|
||||||
Name = request.Name,
|
Name = request.Name,
|
||||||
SortOrder = request.SortOrder
|
SortOrder = request.SortOrder,
|
||||||
|
SectionId = request.SectionId,
|
||||||
};
|
};
|
||||||
db.ShoppingListItems.Add(item);
|
db.ShoppingListItems.Add(item);
|
||||||
list.UpdatedAt = DateTime.UtcNow;
|
list.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder });
|
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId });
|
||||||
await BroadcastListSummary(hub, db, listId, familyId);
|
await BroadcastListSummary(hub, db, listId, familyId);
|
||||||
return Results.Created($"/api/lists/{listId}/items/{item.Id}", new { item.Id, item.Name, item.SortOrder });
|
return Results.Created($"/api/lists/{listId}/items/{item.Id}", new { item.Id, item.Name, item.SortOrder, item.SectionId });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPatch("/{listId:int}/items/{itemId:int}/section", async (int listId, int itemId, SetItemSectionRequest request, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) =>
|
||||||
|
{
|
||||||
|
var familyId = http.User.GetFamilyId();
|
||||||
|
var item = await db.ShoppingListItems
|
||||||
|
.Include(i => i.ShoppingList)
|
||||||
|
.FirstOrDefaultAsync(i => i.Id == itemId && i.ShoppingListId == listId && i.FamilyId == familyId && i.RemovedAt == null);
|
||||||
|
if (item is null) return Results.NotFound();
|
||||||
|
|
||||||
|
if (request.SectionId is int sectionId &&
|
||||||
|
!await db.StoreSections.AnyAsync(s => s.Id == sectionId && s.StoreId == item.ShoppingList.StoreId && s.FamilyId == familyId))
|
||||||
|
return Results.BadRequest(new { error = "Unknown section." });
|
||||||
|
|
||||||
|
item.SectionId = request.SectionId;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await hub.Clients.Group($"list-{listId}").SendAsync("ItemSectionChanged", new { item.Id, item.SectionId });
|
||||||
|
return Results.Ok();
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPatch("/{listId:int}/items/{itemId:int}/check", async (int listId, int itemId, YesChefDb db, IHubContext<ShoppingListHub> hub, HttpContext http) =>
|
group.MapPatch("/{listId:int}/items/{itemId:int}/check", async (int listId, int itemId, YesChefDb db, IHubContext<ShoppingListHub> hub, HttpContext http) =>
|
||||||
@@ -237,6 +271,7 @@ public static class ShoppingListEndpoints
|
|||||||
item.IsChecked,
|
item.IsChecked,
|
||||||
CheckedByUserName = item.CheckedByUser?.Name,
|
CheckedByUserName = item.CheckedByUser?.Name,
|
||||||
item.SortOrder,
|
item.SortOrder,
|
||||||
|
item.SectionId,
|
||||||
RecipeTitle = item.Recipe?.Title
|
RecipeTitle = item.Recipe?.Title
|
||||||
});
|
});
|
||||||
await BroadcastListSummary(hub, db, listId, familyId);
|
await BroadcastListSummary(hub, db, listId, familyId);
|
||||||
@@ -272,7 +307,7 @@ public static class ShoppingListEndpoints
|
|||||||
|
|
||||||
foreach (var item in newItems)
|
foreach (var item in newItems)
|
||||||
{
|
{
|
||||||
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, RecipeTitle = recipe.Title });
|
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId, RecipeTitle = recipe.Title });
|
||||||
}
|
}
|
||||||
|
|
||||||
await BroadcastListSummary(hub, db, listId, familyId);
|
await BroadcastListSummary(hub, db, listId, familyId);
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ public static class StoreEndpoints
|
|||||||
};
|
};
|
||||||
db.Stores.Add(store);
|
db.Stores.Add(store);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Seed default sections so a brand-new store has a usable walk order out of the box.
|
||||||
|
db.StoreSections.AddRange(StoreSectionDefaults.Names.Select((name, idx) => new StoreSection
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
StoreId = store.Id,
|
||||||
|
Name = name,
|
||||||
|
SortOrder = idx,
|
||||||
|
}));
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Created($"/api/stores/{store.Id}", store);
|
return Results.Created($"/api/stores/{store.Id}", store);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace YesChef.Api.Features.Stores;
|
||||||
|
|
||||||
|
public static class StoreSectionDefaults
|
||||||
|
{
|
||||||
|
public static readonly string[] Names =
|
||||||
|
[
|
||||||
|
"Produce",
|
||||||
|
"Meat & Seafood",
|
||||||
|
"Dairy",
|
||||||
|
"Bakery",
|
||||||
|
"Frozen",
|
||||||
|
"Pantry",
|
||||||
|
"Condiments",
|
||||||
|
"Beverages",
|
||||||
|
"Other",
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using YesChef.Api.Auth;
|
||||||
|
using YesChef.Api.Data;
|
||||||
|
using YesChef.Api.Entities;
|
||||||
|
|
||||||
|
namespace YesChef.Api.Features.Stores;
|
||||||
|
|
||||||
|
public static class StoreSectionEndpoints
|
||||||
|
{
|
||||||
|
public record CreateSectionRequest(string Name, int SortOrder = 0);
|
||||||
|
public record UpdateSectionRequest(string Name, int SortOrder);
|
||||||
|
|
||||||
|
public static RouteGroupBuilder MapStoreSectionEndpoints(this RouteGroupBuilder group)
|
||||||
|
{
|
||||||
|
group.MapGet("/", async (int storeId, YesChefDb db, HttpContext http) =>
|
||||||
|
{
|
||||||
|
var familyId = http.User.GetFamilyId();
|
||||||
|
if (!await db.Stores.AnyAsync(s => s.Id == storeId && s.FamilyId == familyId))
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
var sections = await db.StoreSections
|
||||||
|
.Where(s => s.StoreId == storeId && s.FamilyId == familyId)
|
||||||
|
.OrderBy(s => s.SortOrder).ThenBy(s => s.Name)
|
||||||
|
.Select(s => new { s.Id, s.Name, s.SortOrder })
|
||||||
|
.ToListAsync();
|
||||||
|
return Results.Ok(sections);
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/", async (int storeId, CreateSectionRequest request, YesChefDb db, HttpContext http) =>
|
||||||
|
{
|
||||||
|
var familyId = http.User.GetFamilyId();
|
||||||
|
if (!await db.Stores.AnyAsync(s => s.Id == storeId && s.FamilyId == familyId))
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
if (await db.StoreSections.AnyAsync(s => s.StoreId == storeId && s.Name == request.Name))
|
||||||
|
return Results.Conflict(new { error = $"A section named \"{request.Name}\" already exists." });
|
||||||
|
|
||||||
|
var section = new StoreSection
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
StoreId = storeId,
|
||||||
|
Name = request.Name,
|
||||||
|
SortOrder = request.SortOrder,
|
||||||
|
};
|
||||||
|
db.StoreSections.Add(section);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.Created($"/api/stores/{storeId}/sections/{section.Id}",
|
||||||
|
new { section.Id, section.Name, section.SortOrder });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPut("/{sectionId:int}", async (int storeId, int sectionId, UpdateSectionRequest request, YesChefDb db, HttpContext http) =>
|
||||||
|
{
|
||||||
|
var familyId = http.User.GetFamilyId();
|
||||||
|
var section = await db.StoreSections
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == sectionId && s.StoreId == storeId && s.FamilyId == familyId);
|
||||||
|
if (section is null) return Results.NotFound();
|
||||||
|
|
||||||
|
if (request.Name != section.Name &&
|
||||||
|
await db.StoreSections.AnyAsync(s => s.StoreId == storeId && s.Name == request.Name && s.Id != sectionId))
|
||||||
|
return Results.Conflict(new { error = $"A section named \"{request.Name}\" already exists." });
|
||||||
|
|
||||||
|
section.Name = request.Name;
|
||||||
|
section.SortOrder = request.SortOrder;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.Ok(new { section.Id, section.Name, section.SortOrder });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapDelete("/{sectionId:int}", async (int storeId, int sectionId, YesChefDb db, HttpContext http) =>
|
||||||
|
{
|
||||||
|
var familyId = http.User.GetFamilyId();
|
||||||
|
var section = await db.StoreSections
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == sectionId && s.StoreId == storeId && s.FamilyId == familyId);
|
||||||
|
if (section is null) return Results.NotFound();
|
||||||
|
|
||||||
|
db.StoreSections.Remove(section);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
}
|
||||||
+524
@@ -0,0 +1,524 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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("20260509025211_AddStoreSections")]
|
||||||
|
partial class AddStoreSections
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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.Family", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("InviteCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("InviteCode")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Families");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("UserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("FamilyId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("JoinedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "FamilyId");
|
||||||
|
|
||||||
|
b.HasIndex("FamilyId");
|
||||||
|
|
||||||
|
b.ToTable("FamilyMemberships");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("CreatedByUserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("FamilyId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Instructions")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int?>("Servings")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("SourceUrl")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("character varying(300)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedByUserId");
|
||||||
|
|
||||||
|
b.HasIndex("FamilyId");
|
||||||
|
|
||||||
|
b.ToTable("Recipes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("FamilyId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Quantity")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<int>("RecipeId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FamilyId");
|
||||||
|
|
||||||
|
b.HasIndex("RecipeId");
|
||||||
|
|
||||||
|
b.ToTable("RecipeIngredients");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("CreatedByUserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("FamilyId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("IsArchived")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int>("StoreId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedByUserId");
|
||||||
|
|
||||||
|
b.HasIndex("FamilyId");
|
||||||
|
|
||||||
|
b.HasIndex("StoreId");
|
||||||
|
|
||||||
|
b.ToTable("ShoppingLists");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int?>("CheckedByUserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("FamilyId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("IsChecked")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("character varying(300)");
|
||||||
|
|
||||||
|
b.Property<int?>("RecipeId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RemovedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("RemovedByUserId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("SectionId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ShoppingListId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CheckedByUserId");
|
||||||
|
|
||||||
|
b.HasIndex("FamilyId");
|
||||||
|
|
||||||
|
b.HasIndex("RecipeId");
|
||||||
|
|
||||||
|
b.HasIndex("RemovedByUserId");
|
||||||
|
|
||||||
|
b.HasIndex("SectionId");
|
||||||
|
|
||||||
|
b.HasIndex("ShoppingListId");
|
||||||
|
|
||||||
|
b.ToTable("ShoppingListItems");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("YesChef.Api.Entities.Store", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("FamilyId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FamilyId", "Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Stores");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("FamilyId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("StoreId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FamilyId");
|
||||||
|
|
||||||
|
b.HasIndex("StoreId", "Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("StoreSections");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("YesChef.Api.Entities.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("YesChef.Api.Entities.Family", "Family")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FamilyId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("YesChef.Api.Entities.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Family");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("YesChef.Api.Entities.User", "CreatedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CreatedByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("YesChef.Api.Entities.Family", "Family")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FamilyId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CreatedByUser");
|
||||||
|
|
||||||
|
b.Navigation("Family");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("YesChef.Api.Entities.Family", "Family")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FamilyId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("YesChef.Api.Entities.Recipe", "Recipe")
|
||||||
|
.WithMany("Ingredients")
|
||||||
|
.HasForeignKey("RecipeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Family");
|
||||||
|
|
||||||
|
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.Family", "Family")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FamilyId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("YesChef.Api.Entities.Store", "Store")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StoreId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CreatedByUser");
|
||||||
|
|
||||||
|
b.Navigation("Family");
|
||||||
|
|
||||||
|
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.Family", "Family")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FamilyId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("YesChef.Api.Entities.Recipe", "Recipe")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RecipeId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("YesChef.Api.Entities.User", "RemovedByUser")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RemovedByUserId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("YesChef.Api.Entities.StoreSection", "Section")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SectionId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList")
|
||||||
|
.WithMany("Items")
|
||||||
|
.HasForeignKey("ShoppingListId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("CheckedByUser");
|
||||||
|
|
||||||
|
b.Navigation("Family");
|
||||||
|
|
||||||
|
b.Navigation("Recipe");
|
||||||
|
|
||||||
|
b.Navigation("RemovedByUser");
|
||||||
|
|
||||||
|
b.Navigation("Section");
|
||||||
|
|
||||||
|
b.Navigation("ShoppingList");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("YesChef.Api.Entities.Store", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("YesChef.Api.Entities.Family", "Family")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FamilyId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Family");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("YesChef.Api.Entities.Family", "Family")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FamilyId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("YesChef.Api.Entities.Store", "Store")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StoreId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Family");
|
||||||
|
|
||||||
|
b.Navigation("Store");
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace YesChef.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddStoreSections : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SectionId",
|
||||||
|
table: "ShoppingListItems",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "StoreSections",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
FamilyId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
StoreId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_StoreSections", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_StoreSections_Families_FamilyId",
|
||||||
|
column: x => x.FamilyId,
|
||||||
|
principalTable: "Families",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_StoreSections_Stores_StoreId",
|
||||||
|
column: x => x.StoreId,
|
||||||
|
principalTable: "Stores",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ShoppingListItems_SectionId",
|
||||||
|
table: "ShoppingListItems",
|
||||||
|
column: "SectionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_StoreSections_FamilyId",
|
||||||
|
table: "StoreSections",
|
||||||
|
column: "FamilyId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_StoreSections_StoreId_Name",
|
||||||
|
table: "StoreSections",
|
||||||
|
columns: new[] { "StoreId", "Name" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_ShoppingListItems_StoreSections_SectionId",
|
||||||
|
table: "ShoppingListItems",
|
||||||
|
column: "SectionId",
|
||||||
|
principalTable: "StoreSections",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_ShoppingListItems_StoreSections_SectionId",
|
||||||
|
table: "ShoppingListItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "StoreSections");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ShoppingListItems_SectionId",
|
||||||
|
table: "ShoppingListItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SectionId",
|
||||||
|
table: "ShoppingListItems");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -229,6 +229,9 @@ namespace YesChef.Api.Migrations
|
|||||||
b.Property<int?>("RemovedByUserId")
|
b.Property<int?>("RemovedByUserId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("SectionId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int>("ShoppingListId")
|
b.Property<int>("ShoppingListId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -245,6 +248,8 @@ namespace YesChef.Api.Migrations
|
|||||||
|
|
||||||
b.HasIndex("RemovedByUserId");
|
b.HasIndex("RemovedByUserId");
|
||||||
|
|
||||||
|
b.HasIndex("SectionId");
|
||||||
|
|
||||||
b.HasIndex("ShoppingListId");
|
b.HasIndex("ShoppingListId");
|
||||||
|
|
||||||
b.ToTable("ShoppingListItems");
|
b.ToTable("ShoppingListItems");
|
||||||
@@ -280,6 +285,38 @@ namespace YesChef.Api.Migrations
|
|||||||
b.ToTable("Stores");
|
b.ToTable("Stores");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("FamilyId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("StoreId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FamilyId");
|
||||||
|
|
||||||
|
b.HasIndex("StoreId", "Name")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("StoreSections");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("YesChef.Api.Entities.User", b =>
|
modelBuilder.Entity("YesChef.Api.Entities.User", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -415,6 +452,11 @@ namespace YesChef.Api.Migrations
|
|||||||
.HasForeignKey("RemovedByUserId")
|
.HasForeignKey("RemovedByUserId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("YesChef.Api.Entities.StoreSection", "Section")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SectionId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList")
|
b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList")
|
||||||
.WithMany("Items")
|
.WithMany("Items")
|
||||||
.HasForeignKey("ShoppingListId")
|
.HasForeignKey("ShoppingListId")
|
||||||
@@ -429,6 +471,8 @@ namespace YesChef.Api.Migrations
|
|||||||
|
|
||||||
b.Navigation("RemovedByUser");
|
b.Navigation("RemovedByUser");
|
||||||
|
|
||||||
|
b.Navigation("Section");
|
||||||
|
|
||||||
b.Navigation("ShoppingList");
|
b.Navigation("ShoppingList");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -443,6 +487,25 @@ namespace YesChef.Api.Migrations
|
|||||||
b.Navigation("Family");
|
b.Navigation("Family");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("YesChef.Api.Entities.Family", "Family")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FamilyId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("YesChef.Api.Entities.Store", "Store")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StoreId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Family");
|
||||||
|
|
||||||
|
b.Navigation("Store");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
|
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Ingredients");
|
b.Navigation("Ingredients");
|
||||||
|
|||||||
@@ -123,7 +123,8 @@ app.MapGet("/health", async (YesChefDb db) =>
|
|||||||
|
|
||||||
app.MapGroup("/api/auth").MapAuthEndpoints();
|
app.MapGroup("/api/auth").MapAuthEndpoints();
|
||||||
app.MapGroup("/api/family").MapFamilyEndpoints().RequireAuthorization();
|
app.MapGroup("/api/family").MapFamilyEndpoints().RequireAuthorization();
|
||||||
app.MapGroup("/api/stores").MapStoreEndpoints().RequireAuthorization();
|
var storesGroup = app.MapGroup("/api/stores").MapStoreEndpoints().RequireAuthorization();
|
||||||
|
storesGroup.MapGroup("/{storeId:int}/sections").MapStoreSectionEndpoints();
|
||||||
app.MapGroup("/api/lists").MapShoppingListEndpoints().RequireAuthorization();
|
app.MapGroup("/api/lists").MapShoppingListEndpoints().RequireAuthorization();
|
||||||
app.MapGroup("/api/recipes").MapRecipeEndpoints().RequireAuthorization();
|
app.MapGroup("/api/recipes").MapRecipeEndpoints().RequireAuthorization();
|
||||||
app.MapHub<ShoppingListHub>("/hubs/shopping-list");
|
app.MapHub<ShoppingListHub>("/hubs/shopping-list");
|
||||||
|
|||||||
@@ -13,20 +13,32 @@
|
|||||||
isChecked: boolean;
|
isChecked: boolean;
|
||||||
checkedByUserName: string | null;
|
checkedByUserName: string | null;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
sectionId: number | null;
|
||||||
recipeTitle: string | null;
|
recipeTitle: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Section {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ShoppingListDetail {
|
interface ShoppingListDetail {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
store: { id: number; name: string };
|
store: { id: number; name: string };
|
||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
|
sections: Section[];
|
||||||
items: ListItem[];
|
items: ListItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UNCATEGORIZED_KEY = -1;
|
||||||
|
|
||||||
let list = $state<ShoppingListDetail | null>(null);
|
let list = $state<ShoppingListDetail | null>(null);
|
||||||
let items = $state<ListItem[]>([]);
|
let items = $state<ListItem[]>([]);
|
||||||
|
let sections = $state<Section[]>([]);
|
||||||
let newItemName = $state('');
|
let newItemName = $state('');
|
||||||
|
let newItemSectionId = $state<number | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let connection: HubConnection | null = null;
|
let connection: HubConnection | null = null;
|
||||||
|
|
||||||
@@ -34,16 +46,46 @@
|
|||||||
const uncheckedItems = $derived(items.filter((i) => !i.isChecked));
|
const uncheckedItems = $derived(items.filter((i) => !i.isChecked));
|
||||||
const checkedItems = $derived(items.filter((i) => i.isChecked));
|
const checkedItems = $derived(items.filter((i) => i.isChecked));
|
||||||
|
|
||||||
|
// Order: sections by SortOrder, with uncategorized bucket appended last.
|
||||||
|
const orderedSectionKeys = $derived([
|
||||||
|
...[...sections].sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)).map((s) => s.id),
|
||||||
|
UNCATEGORIZED_KEY,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const uncheckedGroups = $derived(groupBySection(uncheckedItems));
|
||||||
|
const checkedGroups = $derived(groupBySection(checkedItems));
|
||||||
|
|
||||||
|
function groupBySection(source: ListItem[]) {
|
||||||
|
const buckets = new Map<number, ListItem[]>();
|
||||||
|
for (const item of source) {
|
||||||
|
const key = item.sectionId ?? UNCATEGORIZED_KEY;
|
||||||
|
const bucket = buckets.get(key);
|
||||||
|
if (bucket) bucket.push(item);
|
||||||
|
else buckets.set(key, [item]);
|
||||||
|
}
|
||||||
|
return orderedSectionKeys
|
||||||
|
.map((key) => ({
|
||||||
|
key,
|
||||||
|
name:
|
||||||
|
key === UNCATEGORIZED_KEY
|
||||||
|
? 'Uncategorized'
|
||||||
|
: sections.find((s) => s.id === key)?.name ?? 'Uncategorized',
|
||||||
|
items: (buckets.get(key) ?? []).sort((a, b) => a.sortOrder - b.sortOrder),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.items.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const data = await api<ShoppingListDetail>(`/api/lists/${listId}`);
|
const data = await api<ShoppingListDetail>(`/api/lists/${listId}`);
|
||||||
list = data;
|
list = data;
|
||||||
items = data.items;
|
items = data.items;
|
||||||
|
sections = data.sections;
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
||||||
connection = await startConnection();
|
connection = await startConnection();
|
||||||
await connection.invoke('JoinList', listId);
|
await connection.invoke('JoinList', listId);
|
||||||
|
|
||||||
connection.on('ItemAdded', (data: { id: number; name: string; sortOrder: number; recipeTitle?: string }) => {
|
connection.on('ItemAdded', (data: { id: number; name: string; sortOrder: number; sectionId: number | null; recipeTitle?: string }) => {
|
||||||
if (!items.find((i) => i.id === data.id)) {
|
if (!items.find((i) => i.id === data.id)) {
|
||||||
items = [
|
items = [
|
||||||
...items,
|
...items,
|
||||||
@@ -53,6 +95,7 @@
|
|||||||
isChecked: false,
|
isChecked: false,
|
||||||
checkedByUserName: null,
|
checkedByUserName: null,
|
||||||
sortOrder: data.sortOrder,
|
sortOrder: data.sortOrder,
|
||||||
|
sectionId: data.sectionId ?? null,
|
||||||
recipeTitle: data.recipeTitle ?? null
|
recipeTitle: data.recipeTitle ?? null
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -67,20 +110,17 @@
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
connection.on('ItemSectionChanged', (data: { id: number; sectionId: number | null }) => {
|
||||||
|
items = items.map((i) => (i.id === data.id ? { ...i, sectionId: data.sectionId } : i));
|
||||||
|
});
|
||||||
|
|
||||||
connection.on('ItemRemoved', (data: { id: number }) => {
|
connection.on('ItemRemoved', (data: { id: number }) => {
|
||||||
items = items.filter((i) => i.id !== data.id);
|
items = items.filter((i) => i.id !== data.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
connection.on(
|
connection.on(
|
||||||
'ItemRestored',
|
'ItemRestored',
|
||||||
(data: {
|
(data: ListItem) => {
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
isChecked: boolean;
|
|
||||||
checkedByUserName: string | null;
|
|
||||||
sortOrder: number;
|
|
||||||
recipeTitle: string | null;
|
|
||||||
}) => {
|
|
||||||
if (!items.find((i) => i.id === data.id)) {
|
if (!items.find((i) => i.id === data.id)) {
|
||||||
items = [...items, data];
|
items = [...items, data];
|
||||||
}
|
}
|
||||||
@@ -106,7 +146,11 @@
|
|||||||
const maxSort = items.length > 0 ? Math.max(...items.map((i) => i.sortOrder)) : 0;
|
const maxSort = items.length > 0 ? Math.max(...items.map((i) => i.sortOrder)) : 0;
|
||||||
await api('/api/lists/' + listId + '/items', {
|
await api('/api/lists/' + listId + '/items', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name: newItemName, sortOrder: maxSort + 1 })
|
body: JSON.stringify({
|
||||||
|
name: newItemName,
|
||||||
|
sortOrder: maxSort + 1,
|
||||||
|
sectionId: newItemSectionId
|
||||||
|
})
|
||||||
});
|
});
|
||||||
newItemName = '';
|
newItemName = '';
|
||||||
}
|
}
|
||||||
@@ -115,6 +159,19 @@
|
|||||||
await api(`/api/lists/${listId}/items/${itemId}/check`, { method: 'PATCH' });
|
await api(`/api/lists/${listId}/items/${itemId}/check`, { method: 'PATCH' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setItemSection(itemId: number, sectionId: number | null) {
|
||||||
|
// Optimistic — the SignalR ItemSectionChanged echo will reconcile.
|
||||||
|
items = items.map((i) => (i.id === itemId ? { ...i, sectionId } : i));
|
||||||
|
try {
|
||||||
|
await api(`/api/lists/${listId}/items/${itemId}/section`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ sectionId })
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to update section');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeItem(itemId: number, itemName: string) {
|
async function removeItem(itemId: number, itemName: string) {
|
||||||
await api(`/api/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
await api(`/api/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
||||||
toast.info(`Removed "${itemName}"`, {
|
toast.info(`Removed "${itemName}"`, {
|
||||||
@@ -152,13 +209,25 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 flex gap-2">
|
<form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 flex flex-wrap gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newItemName}
|
bind:value={newItemName}
|
||||||
placeholder="Add an item..."
|
placeholder="Add an item..."
|
||||||
class="flex-1 rounded-lg border border-gray-300 px-3 py-2.5 text-base focus:border-primary focus:outline-none"
|
class="min-w-0 flex-1 rounded-lg border border-gray-300 px-3 py-2.5 text-base focus:border-primary focus:outline-none"
|
||||||
/>
|
/>
|
||||||
|
{#if sections.length > 0}
|
||||||
|
<select
|
||||||
|
bind:value={newItemSectionId}
|
||||||
|
class="rounded-lg border border-gray-300 bg-white px-2 py-2.5 text-sm focus:border-primary focus:outline-none"
|
||||||
|
aria-label="Section"
|
||||||
|
>
|
||||||
|
<option value={null}>Uncategorized</option>
|
||||||
|
{#each sections as section (section.id)}
|
||||||
|
<option value={section.id}>{section.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded-lg bg-primary px-4 py-2.5 font-semibold text-white"
|
class="rounded-lg bg-primary px-4 py-2.5 font-semibold text-white"
|
||||||
@@ -167,31 +236,53 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{#if uncheckedItems.length > 0}
|
{#if uncheckedGroups.length > 0}
|
||||||
<ul class="space-y-1">
|
<div class="space-y-4">
|
||||||
{#each uncheckedItems as item (item.id)}
|
{#each uncheckedGroups as group (group.key)}
|
||||||
<li class="flex items-center gap-3 rounded-lg bg-white px-3 py-3 shadow-sm">
|
<section>
|
||||||
<button
|
<h3 class="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||||
onclick={() => toggleItem(item.id)}
|
{group.name}
|
||||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2 border-gray-300"
|
</h3>
|
||||||
aria-label="Check {item.name}"
|
<ul class="space-y-1">
|
||||||
></button>
|
{#each group.items as item (item.id)}
|
||||||
<div class="min-w-0 flex-1">
|
<li class="flex items-center gap-3 rounded-lg bg-white px-3 py-3 shadow-sm">
|
||||||
<span class="text-base">{item.name}</span>
|
<button
|
||||||
{#if item.recipeTitle}
|
onclick={() => toggleItem(item.id)}
|
||||||
<span class="ml-1 text-xs text-gray-400">from {item.recipeTitle}</span>
|
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2 border-gray-300"
|
||||||
{/if}
|
aria-label="Check {item.name}"
|
||||||
</div>
|
></button>
|
||||||
<button
|
<div class="min-w-0 flex-1">
|
||||||
onclick={() => removeItem(item.id, item.name)}
|
<span class="text-base">{item.name}</span>
|
||||||
class="shrink-0 p-1 text-gray-300 active:text-danger"
|
{#if item.recipeTitle}
|
||||||
aria-label="Remove {item.name}"
|
<span class="ml-1 text-xs text-gray-400">from {item.recipeTitle}</span>
|
||||||
>
|
{/if}
|
||||||
✕
|
</div>
|
||||||
</button>
|
{#if sections.length > 0}
|
||||||
</li>
|
<select
|
||||||
|
value={item.sectionId ?? ''}
|
||||||
|
onchange={(e) => setItemSection(item.id, (e.currentTarget.value === '' ? null : Number(e.currentTarget.value)))}
|
||||||
|
class="shrink-0 rounded border border-gray-200 bg-white px-1 py-0.5 text-xs text-gray-500 focus:border-primary focus:outline-none"
|
||||||
|
aria-label="Section for {item.name}"
|
||||||
|
>
|
||||||
|
<option value="">Uncategorized</option>
|
||||||
|
{#each sections as section (section.id)}
|
||||||
|
<option value={section.id}>{section.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={() => removeItem(item.id, item.name)}
|
||||||
|
class="shrink-0 p-1 text-gray-300 active:text-danger"
|
||||||
|
aria-label="Remove {item.name}"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</div>
|
||||||
{:else if checkedItems.length === 0}
|
{:else if checkedItems.length === 0}
|
||||||
<p class="py-8 text-center text-gray-400">No items yet — add some above</p>
|
<p class="py-8 text-center text-gray-400">No items yet — add some above</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -201,32 +292,41 @@
|
|||||||
<h3 class="mb-2 text-sm font-medium text-gray-400">
|
<h3 class="mb-2 text-sm font-medium text-gray-400">
|
||||||
Checked ({checkedItems.length})
|
Checked ({checkedItems.length})
|
||||||
</h3>
|
</h3>
|
||||||
<ul class="space-y-1">
|
<div class="space-y-3">
|
||||||
{#each checkedItems as item (item.id)}
|
{#each checkedGroups as group (group.key)}
|
||||||
<li class="flex items-center gap-3 rounded-lg bg-white/60 px-3 py-3 shadow-sm">
|
<section>
|
||||||
<button
|
<h4 class="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-gray-300">
|
||||||
onclick={() => toggleItem(item.id)}
|
{group.name}
|
||||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2 border-primary bg-primary text-white"
|
</h4>
|
||||||
aria-label="Uncheck {item.name}"
|
<ul class="space-y-1">
|
||||||
>
|
{#each group.items as item (item.id)}
|
||||||
✓
|
<li class="flex items-center gap-3 rounded-lg bg-white/60 px-3 py-3 shadow-sm">
|
||||||
</button>
|
<button
|
||||||
<div class="min-w-0 flex-1">
|
onclick={() => toggleItem(item.id)}
|
||||||
<span class="text-base text-gray-400 line-through">{item.name}</span>
|
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2 border-primary bg-primary text-white"
|
||||||
{#if item.checkedByUserName}
|
aria-label="Uncheck {item.name}"
|
||||||
<span class="ml-1 text-xs text-gray-300">{item.checkedByUserName}</span>
|
>
|
||||||
{/if}
|
✓
|
||||||
</div>
|
</button>
|
||||||
<button
|
<div class="min-w-0 flex-1">
|
||||||
onclick={() => removeItem(item.id, item.name)}
|
<span class="text-base text-gray-400 line-through">{item.name}</span>
|
||||||
class="shrink-0 p-1 text-gray-300 active:text-danger"
|
{#if item.checkedByUserName}
|
||||||
aria-label="Remove {item.name}"
|
<span class="ml-1 text-xs text-gray-300">{item.checkedByUserName}</span>
|
||||||
>
|
{/if}
|
||||||
✕
|
</div>
|
||||||
</button>
|
<button
|
||||||
</li>
|
onclick={() => removeItem(item.id, item.name)}
|
||||||
|
class="shrink-0 p-1 text-gray-300 active:text-danger"
|
||||||
|
aria-label="Remove {item.name}"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,12 @@
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Section {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
let stores = $state<Store[]>([]);
|
let stores = $state<Store[]>([]);
|
||||||
let newName = $state('');
|
let newName = $state('');
|
||||||
let editingId = $state<number | null>(null);
|
let editingId = $state<number | null>(null);
|
||||||
@@ -16,6 +22,13 @@
|
|||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let pendingDelete = $state<Store | null>(null);
|
let pendingDelete = $state<Store | null>(null);
|
||||||
|
|
||||||
|
let expandedStoreId = $state<number | null>(null);
|
||||||
|
let sectionsByStore = $state<Record<number, Section[]>>({});
|
||||||
|
let sectionsLoading = $state<number | null>(null);
|
||||||
|
let newSectionName = $state('');
|
||||||
|
let editingSectionId = $state<number | null>(null);
|
||||||
|
let editSectionName = $state('');
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
stores = await api<Store[]>('/api/stores');
|
stores = await api<Store[]>('/api/stores');
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -70,6 +83,72 @@
|
|||||||
toast.error(e instanceof Error ? e.message : 'Failed to delete store');
|
toast.error(e instanceof Error ? e.message : 'Failed to delete store');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleSections(storeId: number) {
|
||||||
|
if (expandedStoreId === storeId) {
|
||||||
|
expandedStoreId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expandedStoreId = storeId;
|
||||||
|
editingSectionId = null;
|
||||||
|
newSectionName = '';
|
||||||
|
if (!sectionsByStore[storeId]) {
|
||||||
|
sectionsLoading = storeId;
|
||||||
|
try {
|
||||||
|
sectionsByStore[storeId] = await api<Section[]>(`/api/stores/${storeId}/sections`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to load sections');
|
||||||
|
} finally {
|
||||||
|
sectionsLoading = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSection(storeId: number) {
|
||||||
|
if (!newSectionName.trim()) return;
|
||||||
|
const existing = sectionsByStore[storeId] ?? [];
|
||||||
|
try {
|
||||||
|
const created = await api<Section>(`/api/stores/${storeId}/sections`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name: newSectionName, sortOrder: existing.length })
|
||||||
|
});
|
||||||
|
sectionsByStore[storeId] = [...existing, created];
|
||||||
|
newSectionName = '';
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to add section');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditSection(section: Section) {
|
||||||
|
editingSectionId = section.id;
|
||||||
|
editSectionName = section.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSectionEdit(storeId: number) {
|
||||||
|
if (!editSectionName.trim() || !editingSectionId) return;
|
||||||
|
const section = sectionsByStore[storeId].find((s) => s.id === editingSectionId)!;
|
||||||
|
try {
|
||||||
|
const updated = await api<Section>(`/api/stores/${storeId}/sections/${editingSectionId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ name: editSectionName, sortOrder: section.sortOrder })
|
||||||
|
});
|
||||||
|
sectionsByStore[storeId] = sectionsByStore[storeId].map((s) =>
|
||||||
|
s.id === updated.id ? updated : s
|
||||||
|
);
|
||||||
|
editingSectionId = null;
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to update section');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSection(storeId: number, sectionId: number) {
|
||||||
|
try {
|
||||||
|
await api(`/api/stores/${storeId}/sections/${sectionId}`, { method: 'DELETE' });
|
||||||
|
sectionsByStore[storeId] = sectionsByStore[storeId].filter((s) => s.id !== sectionId);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to delete section');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -95,21 +174,79 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
{#each stores as store (store.id)}
|
{#each stores as store (store.id)}
|
||||||
<li class="flex items-center gap-3 rounded-lg bg-white px-4 py-3 shadow-sm">
|
<li class="rounded-lg bg-white shadow-sm">
|
||||||
{#if editingId === store.id}
|
<div class="flex items-center gap-3 px-4 py-3">
|
||||||
<form onsubmit={e => { e.preventDefault(); saveEdit(); }} class="flex flex-1 gap-2">
|
{#if editingId === store.id}
|
||||||
<input
|
<form onsubmit={e => { e.preventDefault(); saveEdit(); }} class="flex flex-1 gap-2">
|
||||||
type="text"
|
<input
|
||||||
bind:value={editName}
|
type="text"
|
||||||
class="flex-1 rounded border border-gray-300 px-2 py-1 focus:border-primary focus:outline-none"
|
bind:value={editName}
|
||||||
/>
|
class="flex-1 rounded border border-gray-300 px-2 py-1 focus:border-primary focus:outline-none"
|
||||||
<button type="submit" class="text-sm font-medium text-primary">Save</button>
|
/>
|
||||||
<button type="button" onclick={() => (editingId = null)} class="text-sm text-gray-400">Cancel</button>
|
<button type="submit" class="text-sm font-medium text-primary">Save</button>
|
||||||
</form>
|
<button type="button" onclick={() => (editingId = null)} class="text-sm text-gray-400">Cancel</button>
|
||||||
{:else}
|
</form>
|
||||||
<span class="flex-1 font-medium">{store.name}</span>
|
{:else}
|
||||||
<button onclick={() => startEdit(store)} class="text-sm text-gray-400">Edit</button>
|
<button
|
||||||
<button onclick={() => requestDelete(store)} class="text-sm text-danger">Delete</button>
|
type="button"
|
||||||
|
onclick={() => toggleSections(store.id)}
|
||||||
|
class="flex-1 text-left font-medium"
|
||||||
|
aria-expanded={expandedStoreId === store.id}
|
||||||
|
>
|
||||||
|
<span class="mr-2 inline-block w-3 text-gray-400">{expandedStoreId === store.id ? '▾' : '▸'}</span>
|
||||||
|
{store.name}
|
||||||
|
</button>
|
||||||
|
<button onclick={() => startEdit(store)} class="text-sm text-gray-400">Edit</button>
|
||||||
|
<button onclick={() => requestDelete(store)} class="text-sm text-danger">Delete</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if expandedStoreId === store.id}
|
||||||
|
<div class="border-t border-gray-100 px-4 py-3">
|
||||||
|
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Sections</h3>
|
||||||
|
|
||||||
|
{#if sectionsLoading === store.id}
|
||||||
|
<p class="py-2 text-sm text-gray-400">Loading...</p>
|
||||||
|
{:else}
|
||||||
|
{#if (sectionsByStore[store.id] ?? []).length === 0}
|
||||||
|
<p class="mb-3 text-sm text-gray-400">No sections — add one below</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="mb-3 space-y-1">
|
||||||
|
{#each sectionsByStore[store.id] as section (section.id)}
|
||||||
|
<li class="flex items-center gap-2 rounded bg-gray-50 px-3 py-2 text-sm">
|
||||||
|
{#if editingSectionId === section.id}
|
||||||
|
<form onsubmit={e => { e.preventDefault(); saveSectionEdit(store.id); }} class="flex flex-1 gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={editSectionName}
|
||||||
|
class="flex-1 rounded border border-gray-300 px-2 py-1 focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="font-medium text-primary">Save</button>
|
||||||
|
<button type="button" onclick={() => (editingSectionId = null)} class="text-gray-400">Cancel</button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<span class="flex-1">{section.name}</span>
|
||||||
|
<button onclick={() => startEditSection(section)} class="text-gray-400">Edit</button>
|
||||||
|
<button onclick={() => deleteSection(store.id, section.id)} class="text-danger">Delete</button>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form onsubmit={e => { e.preventDefault(); addSection(store.id); }} class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newSectionName}
|
||||||
|
placeholder="New section"
|
||||||
|
class="flex-1 rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="rounded bg-primary px-3 py-1.5 text-sm font-semibold text-white">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
Reference in New Issue
Block a user