Compare commits

...

10 Commits

Author SHA1 Message Date
Josh Rogers b7e4ebc15a Add permanent list delete; move stores out of main nav
- Add DELETE /api/lists/{id}/permanent endpoint for hard-delete alongside existing soft-delete (archive)
- Add Delete button with confirmation on list detail page next to Archive
- Handle ListDeleted SignalR event on lists overview for real-time removal
- Remove Stores from bottom nav; add Manage stores link at bottom of Lists page
- Add back-to-lists navigation on the Stores page

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-14 21:38:53 -05:00
Josh Rogers bf01063c3a Replace ad-hoc Tailwind strings with design tokens
Define .field, .field-lg, .select, .select-lg, .btn-primary,
.btn-secondary, and .btn-danger-link in app.css as @layer components.
The .select tokens use appearance-none + an inline SVG chevron so
all dropdowns look identical to text inputs (no OS-level gray gradient).

Apply tokens across every in-app form: QuantityInput, shopping list
add form and item rows, recipe new/edit pages, recipe detail, stores,
and lists create form. Drop the STYLE_GUIDE.md doc in favour of the
tokens themselves as the source of truth, and update CLAUDE.md to
document the token names and usage rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:25:57 -05:00
Josh Rogers a398f8cf44 Fix form control consistency; add frontend style guide
- QuantityInput unit select: add bg-white (browser defaults to gray
  without it) and capitalize default option to "Unit"
- Shopping list section dropdowns: rename "Uncategorized" → "No section"
  for consistent title-case phrasing across all default options
- src/frontend/STYLE_GUIDE.md: documents form control classes, button
  variants, text casing rules, icon usage, color tokens, and spacing
  rhythm so all future UI work stays consistent
- CLAUDE.md: link to the style guide so it is always consulted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:13:36 -05:00
Josh Rogers 32ce4d1a6b Align add-item form controls to consistent height
Section dropdown and Add button were py-2.5 while QuantityInput uses
py-2, causing a height mismatch. Normalized all controls in the second
row to py-2 and capped the section dropdown width at max-w-36.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:10:40 -05:00
Josh Rogers 09003b963d Replace x delete buttons with trash can icon
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:08:28 -05:00
Josh Rogers bd540e506f Backlog: structured multi-step recipe instructions
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 20:03:59 -05:00
Josh Rogers 0d20e446e0 Backlog: document auto-assign section from product feature
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:57:37 -05:00
Josh Rogers 7b7e871827 Give shopping list item name field full-width row
The name field was crammed into a single flex row with qty/unit inputs,
section dropdown, and Add button, leaving it too narrow to use. Moved
it above the controls row so it spans full width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:56:19 -05:00
Josh Rogers b31ff77548 Fix unit/product kind enums serializing as integers
Two bugs found during exploratory testing:

1. UnitKind and ProductKind enums were serialized as 0/1 instead of
   "Global"/"Family" because no global JSON converter was registered.
   Added JsonStringEnumConverter to ConfigureHttpJsonOptions so all
   enum responses (kind, category) serialize as strings. This also
   fixes UnitCategory coming back as a number.

2. units.svelte.ts triggered a Svelte 5 state_unsafe_mutation error
   because the `all` getter called load() (which mutates $state) from
   inside a $derived expression in QuantityInput. Wrapped the load()
   call in untrack() so the side-effect runs outside the reactive
   tracking context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:37:16 -05:00
Josh Rogers 68292c2906 Harden recipe edit page and cover allowedUnitCategories projection
Edit page now surfaces load and save errors instead of failing silently,
round-trips sourceUrl through PUT, warns before discarding unsaved
changes, and offers an explicit Cancel button. Delete moved off the
detail page's primary action row into a less-prominent footer link so a
mis-tap on Edit can no longer destroy the recipe. Added integration
tests covering AllowedUnitCategories in the recipe GET projection for
all four product-link shapes (global no-override, global with override,
family product, unlinked).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:01:06 -05:00
16 changed files with 393 additions and 95 deletions
+32
View File
@@ -162,6 +162,38 @@ The base feature is shipped (entity, default seed on store create, list view gro
- **Recipes → sections:** when pulling recipe ingredients into a list, map them to the list's store's sections (only meaningful once the per-store ingredient memory or product catalog lands).
- **Section drag-to-reorder** in the store edit UI — section walk order matters, but reordering today only works by editing `SortOrder` numbers manually.
### Auto-assign section from product
When a user picks a product from the typeahead on the shopping list add form, pre-populate the section dropdown with that product's known section for the current store — rather than leaving it as "Uncategorized".
This is the "per-store ingredient memory" item above, stated from the user's perspective: choosing "Spaghetti" should already know it belongs in Pasta/Dry Goods at this store.
**Data model:** `ProductStoreSection` is already sketched in the product catalog section above — `(FamilyId, ProductId | FamilyProductId, StoreId, StoreSectionId)`. That table is the source of truth.
**Write path:** when a user manually changes the section on a list item whose product is linked, offer to remember that choice (or auto-remember silently after N uses). Write to `ProductStoreSection`.
**Read path:** on product selection in the add-item form, query the effective section for `(productId, storeId)` and pre-select it in the section dropdown. User can still override.
**Dependencies:** requires the product catalog entity to be in place (`ProductId` on `ShoppingListItem`, product typeahead already wired). The `ProductStoreSection` table and its endpoints are net-new work.
**Scope note:** the section pre-fill should be scoped to the family's own memory (`ProductStoreSection` rows they've created), not a global default — different families organize differently.
## Recipes
### Structured multi-step instructions
Replace the single free-form instructions textarea with an ordered list of discrete steps, each in its own text box.
**UX:** steps are numbered automatically. Users can add a step, remove a step, and reorder steps (drag or up/down arrows). Each step is a short textarea (23 rows) rather than a single-line input, to accommodate steps with sub-detail.
**Data model:** a new `RecipeStep` entity — `(Id, RecipeId, SortOrder, Text)` — replaces the `Recipe.Instructions` string. Migration: split existing `Instructions` on double-newline (or numbered-list pattern) into individual rows; anything that doesn't parse cleanly lands as a single step.
**API:** `Recipe` GET returns `steps: [{ id, sortOrder, text }]` instead of `instructions: string`. POST/PUT accept the same shape. The old `Instructions` column can be kept nullable for backward compatibility during the migration window, then dropped.
**Backward compatibility:** the recipe detail view currently renders `instructions` as `whitespace-pre-wrap`. Switch it to a numbered `<ol>` once steps are in place; the edit page replaces the textarea with the step list.
**Open questions:**
- Keep `Instructions` as a migration fallback column, or do a single-step cutover migration?
- Should steps support rich text (bold ingredient names, timers) or stay plain text for v1?
## Lists
### Block create-list flow when no stores exist
+7
View File
@@ -88,6 +88,13 @@ When adding a feature: create `Features/<Name>/<Name>Endpoints.cs` with a `Map<N
### Frontend — SvelteKit with runes
**UI tokens:** form controls, buttons, and select elements use named CSS component classes defined in `src/frontend/src/app.css`. Always use these instead of raw Tailwind strings:
- Inputs / textareas: `.field` (standard, `py-2 text-sm`) or `.field-lg` (prominent, `py-2.5 text-base`)
- Selects: `.select` or `.select-lg` — these add `appearance-none` + a consistent chevron so dropdowns look identical to text inputs
- Buttons: `.btn-primary`, `.btn-secondary`, `.btn-danger-link`
- Width, margin, and flex utilities are added alongside the token class (e.g. `class="field w-full"`)
- Never hardcode the border, background, padding, or focus ring on a new control — always start from a token
- `svelte.config.js` forces `runes: true` for all non-`node_modules` files. Use Svelte 5 runes (`$state`, `$derived`, `$effect`); do not use legacy reactive `$:` syntax.
- `src/lib/api.ts``api<T>(path, opts)` helper. Stores the JWT in `localStorage` under `token`, attaches `Authorization: Bearer …`, and on 401 it clears the token and `goto('/login')`.
- `src/lib/auth.svelte.ts` — runes-based auth state.
@@ -24,7 +24,14 @@ public sealed class RecipeBuilder
public RecipeBuilder ForFamily(int familyId) { _familyId = familyId; return this; }
public RecipeBuilder ForFamily(Family family) { _familyId = family.Id; return this; }
public RecipeBuilder WithIngredient(string name, int sortOrder = 0, decimal? quantity = null, int? unitOfMeasureId = null, int? familyUnitOfMeasureId = null)
public RecipeBuilder WithIngredient(
string name,
int sortOrder = 0,
decimal? quantity = null,
int? unitOfMeasureId = null,
int? familyUnitOfMeasureId = null,
int? productId = null,
int? familyProductId = null)
{
_ingredients.Add(new RecipeIngredient
{
@@ -33,6 +40,8 @@ public sealed class RecipeBuilder
Quantity = quantity,
UnitOfMeasureId = unitOfMeasureId,
FamilyUnitOfMeasureId = familyUnitOfMeasureId,
ProductId = productId,
FamilyProductId = familyProductId,
});
return this;
}
@@ -336,6 +336,100 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
await Assert.That(stored.AllowedUnitCategories & UnitCategoryFlags.Volume).IsEqualTo(UnitCategoryFlags.Volume);
}
[Test]
public async Task Get_returns_allowed_unit_categories_from_global_product_when_no_override()
{
var familyId = await UseDbAsync(db =>
db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync());
var productId = await UseDbAsync(async db =>
{
var p = new Product { Name = "Flour-base", AllowedUnitCategories = UnitCategoryFlags.Weight | UnitCategoryFlags.Volume };
db.Products.Add(p);
await db.SaveChangesAsync();
return p.Id;
});
var recipe = await CreateRecipeAsync(b => b
.Titled("Bread")
.WithIngredient("flour", sortOrder: 1, quantity: 2m, productId: productId));
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/recipes/{recipe.Id}");
var ingredient = body.GetProperty("ingredients").EnumerateArray().Single();
var cats = (UnitCategoryFlags)ingredient.GetProperty("allowedUnitCategories").GetInt32();
await Assert.That(cats).IsEqualTo(UnitCategoryFlags.Weight | UnitCategoryFlags.Volume);
}
[Test]
public async Task Get_returns_override_allowed_unit_categories_when_family_overrides_product()
{
var familyId = await UseDbAsync(db =>
db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync());
var productId = await UseDbAsync(async db =>
{
var p = new Product { Name = "Flour-ovr", AllowedUnitCategories = UnitCategoryFlags.Weight };
db.Products.Add(p);
await db.SaveChangesAsync();
db.Set<FamilyProductOverride>().Add(new FamilyProductOverride
{
FamilyId = familyId,
ProductId = p.Id,
AllowedUnitCategories = UnitCategoryFlags.Volume | UnitCategoryFlags.Count,
});
await db.SaveChangesAsync();
return p.Id;
});
var recipe = await CreateRecipeAsync(b => b
.Titled("Pancakes-ovr")
.WithIngredient("flour", sortOrder: 1, quantity: 1m, productId: productId));
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/recipes/{recipe.Id}");
var ingredient = body.GetProperty("ingredients").EnumerateArray().Single();
var cats = (UnitCategoryFlags)ingredient.GetProperty("allowedUnitCategories").GetInt32();
await Assert.That(cats).IsEqualTo(UnitCategoryFlags.Volume | UnitCategoryFlags.Count);
}
[Test]
public async Task Get_returns_allowed_unit_categories_from_family_product()
{
var familyId = await UseDbAsync(db =>
db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync());
var familyProductId = await UseDbAsync(async db =>
{
var fp = new FamilyProduct
{
FamilyId = familyId,
Name = "House Flour-fp",
AllowedUnitCategories = UnitCategoryFlags.Weight | UnitCategoryFlags.Packaging,
};
db.FamilyProducts.Add(fp);
await db.SaveChangesAsync();
return fp.Id;
});
var recipe = await CreateRecipeAsync(b => b
.Titled("Loaf")
.WithIngredient("house flour", sortOrder: 1, quantity: 1m, familyProductId: familyProductId));
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/recipes/{recipe.Id}");
var ingredient = body.GetProperty("ingredients").EnumerateArray().Single();
var cats = (UnitCategoryFlags)ingredient.GetProperty("allowedUnitCategories").GetInt32();
await Assert.That(cats).IsEqualTo(UnitCategoryFlags.Weight | UnitCategoryFlags.Packaging);
}
[Test]
public async Task Get_returns_zero_allowed_unit_categories_for_unlinked_ingredient()
{
var recipe = await CreateRecipeAsync(b => b
.Titled("Mystery")
.WithIngredient("salt", sortOrder: 1));
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/recipes/{recipe.Id}");
var ingredient = body.GetProperty("ingredients").EnumerateArray().Single();
await Assert.That(ingredient.GetProperty("allowedUnitCategories").GetInt32()).IsEqualTo(0);
}
[Test]
public async Task Create_accumulates_distinct_categories_across_ingredients()
{
@@ -185,6 +185,20 @@ public static class ShoppingListEndpoints
return Results.NoContent();
});
group.MapDelete("/{id:int}/permanent", async (int id, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) =>
{
var familyId = http.User.GetFamilyId();
var list = await db.ShoppingLists.FirstOrDefaultAsync(l => l.Id == id && l.FamilyId == familyId);
if (list is null) return Results.NotFound();
db.ShoppingLists.Remove(list);
await db.SaveChangesAsync();
await hub.Clients.Group(OverviewGroup(familyId)).SendAsync("ListDeleted", new { list.Id });
return Results.NoContent();
});
group.MapPost("/{listId:int}/items", async (int listId, AddItemRequest request, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) =>
{
var familyId = http.User.GetFamilyId();
+4
View File
@@ -1,5 +1,6 @@
using System.Security.Claims;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
@@ -20,6 +21,9 @@ using YesChef.Api.Features.Units;
var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
builder.Services.AddDbContext<YesChefDb>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
+54
View File
@@ -21,3 +21,57 @@ body {
button {
@apply cursor-pointer;
}
/* ── Design tokens ── */
/* Use .field for standard single-line text inputs and textareas inside the app shell. */
@layer components {
.field {
@apply rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-primary focus:outline-none;
}
/* Larger variant for prominent standalone inputs (recipe title, item name search bar). */
.field-lg {
@apply rounded-lg border border-gray-300 bg-white px-3 py-2.5 text-base focus:border-primary focus:outline-none;
}
/*
* Use .select / .select-lg for <select> elements. Removes native OS appearance
* (which can add a grey gradient even when bg-white is set) and injects a
* consistent chevron so all dropdowns look identical to text inputs.
* Note: @apply cannot compose other component classes in Tailwind v4, so
* the base field styles are duplicated here.
*/
.select {
@apply appearance-none rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-primary focus:outline-none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%239ca3af' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-position: right 0.4rem center;
background-repeat: no-repeat;
background-size: 1.1rem 1.1rem;
padding-right: 1.75rem;
}
.select-lg {
@apply appearance-none rounded-lg border border-gray-300 bg-white px-3 py-2.5 text-base focus:border-primary focus:outline-none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%239ca3af' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-position: right 0.4rem center;
background-repeat: no-repeat;
background-size: 1.1rem 1.1rem;
padding-right: 1.75rem;
}
/* Standard action button — primary green. */
.btn-primary {
@apply rounded-lg bg-primary px-4 py-2 font-semibold text-white disabled:opacity-50;
}
/* Outlined button — secondary / cancel. Same height as btn-primary. */
.btn-secondary {
@apply rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 disabled:opacity-50;
}
/* Low-prominence destructive link — use at the bottom of detail pages. */
.btn-danger-link {
@apply text-sm text-danger hover:underline;
}
}
+3 -3
View File
@@ -86,15 +86,15 @@
oninput={onQuantityInput}
placeholder="Qty"
aria-label={ariaLabel}
class="w-20 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
class="field w-20 px-2"
/>
<select
value={composite}
onchange={onUnitChange}
aria-label="Unit"
class="w-24 rounded-lg border border-gray-300 px-1 py-2 text-sm focus:border-primary focus:outline-none"
class="select w-24 px-1"
>
<option value="">unit</option>
<option value="">Unit</option>
{#each visible as unit}
<option value="{unit.kind}:{unit.id}">{unit.abbreviation}</option>
{/each}
+5 -1
View File
@@ -1,3 +1,4 @@
import { untrack } from 'svelte';
import { api } from '$lib/api';
export type UnitKind = 'Global' | 'Family';
@@ -24,7 +25,10 @@ let error = $state<string | null>(null);
export const units = {
get all() {
if (cache === null && !loading) {
void load();
// Use untrack so the load() side-effect (which mutates $state) is
// not flagged as an illegal mutation when this getter is read inside
// a $derived expression.
untrack(() => void load());
}
return cache ?? [];
},
-1
View File
@@ -17,7 +17,6 @@
const navItems = [
{ href: '/lists', label: 'Lists', icon: '📋' },
{ href: '/recipes', label: 'Recipes', icon: '📖' },
{ href: '/stores', label: 'Stores', icon: '🏪' },
{ href: '/family', label: 'Family', icon: '👪' }
];
+14 -3
View File
@@ -46,6 +46,10 @@
lists = lists.filter((l) => l.id !== data.id);
});
connection.on('ListDeleted', (data: { id: number }) => {
lists = lists.filter((l) => l.id !== data.id);
});
connection.on('ListSummaryUpdated', (data: ListSummary) => {
lists = lists.map((l) =>
l.id === data.id ? { ...l, itemCount: data.itemCount, checkedCount: data.checkedCount, updatedAt: data.updatedAt } : l
@@ -89,12 +93,12 @@
bind:value={newName}
placeholder="List name"
required
class="mb-3 w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary focus:outline-none"
class="field mb-3 w-full"
/>
<select
bind:value={newStoreId}
required
class="mb-3 w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary focus:outline-none"
class="select mb-3 w-full"
>
<option value={null} disabled>Select store</option>
{#each stores as store}
@@ -103,7 +107,7 @@
</select>
<button
type="submit"
class="w-full rounded-lg bg-primary py-2 font-semibold text-white"
class="btn-primary w-full"
>
Create
</button>
@@ -148,4 +152,11 @@
{/each}
</div>
{/if}
<div class="mt-6 border-t border-gray-100 pt-4">
<a href="/stores" class="flex items-center justify-between text-sm text-gray-500">
<span>Manage stores</span>
<span>&rsaquo;</span>
</a>
</div>
</div>
+31 -19
View File
@@ -253,6 +253,12 @@
await api(`/api/lists/${listId}`, { method: 'DELETE' });
goto('/lists');
}
async function deleteList() {
if (!confirm(`Permanently delete "${list!.name}"? This cannot be undone.`)) return;
await api(`/api/lists/${listId}/permanent`, { method: 'DELETE' });
goto('/lists');
}
</script>
{#if loading}
@@ -265,22 +271,38 @@
<h2 class="text-2xl font-bold">{list.name}</h2>
<p class="text-sm text-gray-500">{list.store.name}</p>
</div>
<div class="flex gap-2">
<button
onclick={archiveList}
class="rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-500"
>
Archive
</button>
<button
onclick={deleteList}
class="btn-danger-link text-sm"
>
Delete
</button>
</div>
</div>
<form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 space-y-1">
<form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 space-y-2">
<ProductTypeahead
bind:value={newItemName}
placeholder="Add an item..."
ariaLabel="Item name"
inputClass="field-lg w-full"
onsubmit={addItem}
onProductChange={onItemProductChange}
/>
<div class="flex flex-wrap gap-2">
{#if newItemIsApproximate}
<input
type="text"
bind:value={newItemQuantityNote}
placeholder="e.g. to taste"
class="w-36 rounded-lg border border-gray-300 px-2 py-2.5 text-sm focus:border-primary focus:outline-none"
class="field w-36 px-2"
/>
{:else}
<QuantityInput
@@ -288,23 +310,13 @@
allowedUnitCategories={newItemAllowedUnitCategories}
/>
{/if}
<div class="min-w-0 flex-1">
<ProductTypeahead
bind:value={newItemName}
placeholder="Add an item..."
ariaLabel="Item name"
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-base focus:border-primary focus:outline-none"
onsubmit={addItem}
onProductChange={onItemProductChange}
/>
</div>
{#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"
class="select max-w-36 px-2"
aria-label="Section"
>
<option value={null}>Uncategorized</option>
<option value={null}>No section</option>
{#each sections as section (section.id)}
<option value={section.id}>{section.name}</option>
{/each}
@@ -312,7 +324,7 @@
{/if}
<button
type="submit"
class="rounded-lg bg-primary px-4 py-2.5 font-semibold text-white"
class="btn-primary"
>
Add
</button>
@@ -355,10 +367,10 @@
<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"
class="select shrink-0 rounded-md border-gray-200 px-1 py-0.5 text-xs text-gray-500"
aria-label="Section for {item.name}"
>
<option value="">Uncategorized</option>
<option value="">No section</option>
{#each sections as section (section.id)}
<option value={section.id}>{section.name}</option>
{/each}
@@ -369,7 +381,7 @@
class="shrink-0 p-1 text-gray-300 active:text-danger"
aria-label="Remove {item.name}"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</li>
{/each}
@@ -417,7 +429,7 @@
class="shrink-0 p-1 text-gray-300 active:text-danger"
aria-label="Remove {item.name}"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</li>
{/each}
@@ -97,22 +97,16 @@
<div class="mb-4 flex gap-2">
<button
onclick={() => (showAddToList = !showAddToList)}
class="flex-1 rounded-lg bg-primary py-2.5 font-semibold text-white"
class="btn-primary flex-1 py-2.5"
>
Add to list
</button>
<button
onclick={() => goto(`/recipes/${recipeId}/edit`)}
class="rounded-lg border border-gray-300 px-4 py-2.5 text-sm text-gray-700"
class="btn-secondary py-2.5"
>
Edit
</button>
<button
onclick={deleteRecipe}
class="rounded-lg border border-danger px-4 py-2.5 text-sm text-danger"
>
Delete
</button>
</div>
{#if showAddToList}
@@ -162,5 +156,14 @@
</div>
</div>
{/if}
<div class="mt-10 border-t border-gray-100 pt-4">
<button
onclick={deleteRecipe}
class="btn-danger-link"
>
Delete this recipe
</button>
</div>
</div>
{/if}
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { goto, beforeNavigate } from '$app/navigation';
import { api } from '$lib/api';
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
import QuantityInput, { type QuantityValue } from '$lib/QuantityInput.svelte';
@@ -45,18 +45,25 @@
let description = $state('');
let instructions = $state('');
let servings = $state<number | undefined>();
let sourceUrl = $state<string | null>(null);
let ingredients = $state<IngredientForm[]>([]);
let loading = $state(true);
let loadError = $state<string | null>(null);
let saving = $state(false);
let saveError = $state<string | null>(null);
let dirty = $state(false);
let justSaved = $state(false);
const recipeId = $derived(Number(page.params.id));
onMount(async () => {
try {
const recipe = await api<RecipeResponse>(`/api/recipes/${recipeId}`);
title = recipe.title;
description = recipe.description ?? '';
instructions = recipe.instructions ?? '';
servings = recipe.servings ?? undefined;
sourceUrl = recipe.sourceUrl;
ingredients = recipe.ingredients.length === 0
? [emptyIngredient()]
: recipe.ingredients.map((i) => ({
@@ -73,7 +80,29 @@
familyProductId: i.familyProductId,
allowedUnitCategories: i.allowedUnitCategories,
}));
} catch (err) {
loadError = err instanceof Error ? err.message : 'Failed to load recipe.';
} finally {
loading = false;
loaded = !loadError;
}
});
// Becomes true once the initial fetch has populated the form. The dirty
// $effect uses this to ignore the load-time writes and only flip on real
// user edits afterward.
let loaded = $state(false);
$effect(() => {
// Touch every piece of editable state so any change re-runs this.
title; description; instructions; servings; sourceUrl;
$state.snapshot(ingredients);
if (loaded) dirty = true;
});
beforeNavigate(({ cancel }) => {
if (!dirty || justSaved) return;
if (!confirm('You have unsaved changes. Leave without saving?')) cancel();
});
function emptyIngredient(): IngredientForm {
@@ -127,6 +156,7 @@
async function save() {
if (!title.trim()) return;
saving = true;
saveError = null;
try {
await api(`/api/recipes/${recipeId}`, {
method: 'PUT',
@@ -135,7 +165,7 @@
description: description || null,
instructions: instructions || null,
servings: servings || null,
sourceUrl: null,
sourceUrl,
ingredients: ingredients
.filter((i) => i.name.trim())
.map((i, idx) => ({
@@ -151,7 +181,10 @@
}))
})
});
justSaved = true;
goto(`/recipes/${recipeId}`);
} catch (err) {
saveError = err instanceof Error ? err.message : 'Failed to save recipe.';
} finally {
saving = false;
}
@@ -160,6 +193,11 @@
{#if loading}
<p class="py-8 text-center text-gray-400">Loading...</p>
{:else if loadError}
<div class="py-8 text-center">
<p class="mb-3 text-danger">{loadError}</p>
<button onclick={() => goto(`/recipes/${recipeId}`)} class="text-sm text-gray-500">&larr; Back to recipe</button>
</div>
{:else}
<div>
<button onclick={() => goto(`/recipes/${recipeId}`)} class="mb-2 text-sm text-gray-500">&larr; Back</button>
@@ -169,16 +207,16 @@
<input
type="text"
bind:value={title}
placeholder="Recipe title"
placeholder="Recipe title"
required
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-lg focus:border-primary focus:outline-none"
class="field-lg w-full"
/>
<textarea
bind:value={description}
placeholder="Short description (optional)"
placeholder="Short description (optional)"
rows={2}
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none"
class="field-lg w-full"
></textarea>
<div>
@@ -189,7 +227,7 @@
bind:value={servings}
min={1}
placeholder="e.g. 4"
class="mt-1 block w-24 rounded-lg border border-gray-300 px-3 py-2 focus:border-primary focus:outline-none"
class="field mt-1 block w-24"
/>
</label>
</div>
@@ -204,7 +242,7 @@
type="text"
bind:value={ingredient.quantityNote}
placeholder="e.g. to taste"
class="w-44 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
class="field w-44 px-2"
/>
{:else}
<QuantityInput
@@ -218,7 +256,7 @@
initialSelectedName={ingredient.initialName}
placeholder="Ingredient name"
ariaLabel="Ingredient name"
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
inputClass="field w-full"
onProductChange={(p) => onIngredientProductChange(idx, p)}
/>
</div>
@@ -229,7 +267,7 @@
class="px-2 text-gray-300 active:text-danger"
aria-label="Remove ingredient"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
{/if}
</div>
@@ -255,16 +293,30 @@
bind:value={instructions}
placeholder="Instructions (optional)"
rows={6}
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none"
class="field-lg w-full"
></textarea>
{#if saveError}
<p class="text-sm text-danger" role="alert">{saveError}</p>
{/if}
<div class="flex gap-2">
<button
type="button"
onclick={() => goto(`/recipes/${recipeId}`)}
disabled={saving}
class="btn-secondary flex-1 py-3"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
class="w-full rounded-lg bg-primary py-3 font-semibold text-white disabled:opacity-50"
class="btn-primary flex-[2] py-3"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
{/if}
@@ -112,14 +112,14 @@
bind:value={title}
placeholder="Recipe title"
required
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-lg focus:border-primary focus:outline-none"
class="field-lg w-full"
/>
<textarea
bind:value={description}
placeholder="Short description (optional)"
rows={2}
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none"
class="field-lg w-full"
></textarea>
<div>
@@ -130,7 +130,7 @@
bind:value={servings}
min={1}
placeholder="e.g. 4"
class="mt-1 block w-24 rounded-lg border border-gray-300 px-3 py-2 focus:border-primary focus:outline-none"
class="field mt-1 block w-24"
/>
</label>
</div>
@@ -145,7 +145,7 @@
type="text"
bind:value={ingredient.quantityNote}
placeholder="e.g. to taste"
class="w-44 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
class="field w-44 px-2"
/>
{:else}
<QuantityInput
@@ -158,7 +158,7 @@
bind:value={ingredient.name}
placeholder="Ingredient name"
ariaLabel="Ingredient name"
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
inputClass="field w-full"
onProductChange={(p) => onIngredientProductChange(idx, p)}
/>
</div>
@@ -169,7 +169,7 @@
class="px-2 text-gray-300 active:text-danger"
aria-label="Remove ingredient"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
{/if}
</div>
@@ -195,13 +195,13 @@
bind:value={instructions}
placeholder="Instructions (optional)"
rows={6}
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none"
class="field-lg w-full"
></textarea>
<button
type="submit"
disabled={saving}
class="w-full rounded-lg bg-primary py-3 font-semibold text-white disabled:opacity-50"
class="btn-primary w-full py-3"
>
{saving ? 'Saving...' : 'Save Recipe'}
</button>
+6 -3
View File
@@ -152,7 +152,10 @@
</script>
<div>
<h2 class="mb-4 text-2xl font-bold">Stores</h2>
<div class="mb-4">
<a href="/lists" class="text-sm text-gray-500">&larr; Back to lists</a>
<h2 class="mt-1 text-2xl font-bold">Stores</h2>
</div>
<form onsubmit={e => { e.preventDefault(); addStore(); }} class="mb-6 flex gap-2">
<input
@@ -160,9 +163,9 @@
bind:value={newName}
placeholder="New store name"
required
class="flex-1 rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none"
class="field flex-1 py-2.5"
/>
<button type="submit" class="rounded-lg bg-primary px-4 py-2.5 font-semibold text-white">
<button type="submit" class="btn-primary py-2.5">
Add
</button>
</form>