Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7e4ebc15a | |||
| bf01063c3a | |||
| a398f8cf44 | |||
| 32ce4d1a6b | |||
| 09003b963d | |||
| bd540e506f | |||
| 0d20e446e0 | |||
| 7b7e871827 | |||
| b31ff77548 | |||
| 68292c2906 |
+32
@@ -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).
|
- **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.
|
- **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 (2–3 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
|
## Lists
|
||||||
|
|
||||||
### Block create-list flow when no stores exist
|
### Block create-list flow when no stores exist
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ When adding a feature: create `Features/<Name>/<Name>Endpoints.cs` with a `Map<N
|
|||||||
|
|
||||||
### Frontend — SvelteKit with runes
|
### 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.
|
- `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/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.
|
- `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(int familyId) { _familyId = familyId; return this; }
|
||||||
public RecipeBuilder ForFamily(Family family) { _familyId = family.Id; 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
|
_ingredients.Add(new RecipeIngredient
|
||||||
{
|
{
|
||||||
@@ -33,6 +40,8 @@ public sealed class RecipeBuilder
|
|||||||
Quantity = quantity,
|
Quantity = quantity,
|
||||||
UnitOfMeasureId = unitOfMeasureId,
|
UnitOfMeasureId = unitOfMeasureId,
|
||||||
FamilyUnitOfMeasureId = familyUnitOfMeasureId,
|
FamilyUnitOfMeasureId = familyUnitOfMeasureId,
|
||||||
|
ProductId = productId,
|
||||||
|
FamilyProductId = familyProductId,
|
||||||
});
|
});
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,6 +336,100 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
await Assert.That(stored.AllowedUnitCategories & UnitCategoryFlags.Volume).IsEqualTo(UnitCategoryFlags.Volume);
|
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]
|
[Test]
|
||||||
public async Task Create_accumulates_distinct_categories_across_ingredients()
|
public async Task Create_accumulates_distinct_categories_across_ingredients()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -185,6 +185,20 @@ public static class ShoppingListEndpoints
|
|||||||
return Results.NoContent();
|
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) =>
|
group.MapPost("/{listId:int}/items", async (int listId, AddItemRequest request, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) =>
|
||||||
{
|
{
|
||||||
var familyId = http.User.GetFamilyId();
|
var familyId = http.User.GetFamilyId();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
@@ -20,6 +21,9 @@ using YesChef.Api.Features.Units;
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(o =>
|
||||||
|
o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||||
|
|
||||||
builder.Services.AddDbContext<YesChefDb>(options =>
|
builder.Services.AddDbContext<YesChefDb>(options =>
|
||||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||||
|
|
||||||
|
|||||||
@@ -21,3 +21,57 @@ body {
|
|||||||
button {
|
button {
|
||||||
@apply cursor-pointer;
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,15 +86,15 @@
|
|||||||
oninput={onQuantityInput}
|
oninput={onQuantityInput}
|
||||||
placeholder="Qty"
|
placeholder="Qty"
|
||||||
aria-label={ariaLabel}
|
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
|
<select
|
||||||
value={composite}
|
value={composite}
|
||||||
onchange={onUnitChange}
|
onchange={onUnitChange}
|
||||||
aria-label="Unit"
|
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}
|
{#each visible as unit}
|
||||||
<option value="{unit.kind}:{unit.id}">{unit.abbreviation}</option>
|
<option value="{unit.kind}:{unit.id}">{unit.abbreviation}</option>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { untrack } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
export type UnitKind = 'Global' | 'Family';
|
export type UnitKind = 'Global' | 'Family';
|
||||||
@@ -24,7 +25,10 @@ let error = $state<string | null>(null);
|
|||||||
export const units = {
|
export const units = {
|
||||||
get all() {
|
get all() {
|
||||||
if (cache === null && !loading) {
|
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 ?? [];
|
return cache ?? [];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/lists', label: 'Lists', icon: '📋' },
|
{ href: '/lists', label: 'Lists', icon: '📋' },
|
||||||
{ href: '/recipes', label: 'Recipes', icon: '📖' },
|
{ href: '/recipes', label: 'Recipes', icon: '📖' },
|
||||||
{ href: '/stores', label: 'Stores', icon: '🏪' },
|
|
||||||
{ href: '/family', label: 'Family', icon: '👪' }
|
{ href: '/family', label: 'Family', icon: '👪' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,10 @@
|
|||||||
lists = lists.filter((l) => l.id !== data.id);
|
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) => {
|
connection.on('ListSummaryUpdated', (data: ListSummary) => {
|
||||||
lists = lists.map((l) =>
|
lists = lists.map((l) =>
|
||||||
l.id === data.id ? { ...l, itemCount: data.itemCount, checkedCount: data.checkedCount, updatedAt: data.updatedAt } : l
|
l.id === data.id ? { ...l, itemCount: data.itemCount, checkedCount: data.checkedCount, updatedAt: data.updatedAt } : l
|
||||||
@@ -89,12 +93,12 @@
|
|||||||
bind:value={newName}
|
bind:value={newName}
|
||||||
placeholder="List name"
|
placeholder="List name"
|
||||||
required
|
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
|
<select
|
||||||
bind:value={newStoreId}
|
bind:value={newStoreId}
|
||||||
required
|
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>
|
<option value={null} disabled>Select store</option>
|
||||||
{#each stores as store}
|
{#each stores as store}
|
||||||
@@ -103,7 +107,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="w-full rounded-lg bg-primary py-2 font-semibold text-white"
|
class="btn-primary w-full"
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</button>
|
</button>
|
||||||
@@ -148,4 +152,11 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>›</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -253,6 +253,12 @@
|
|||||||
await api(`/api/lists/${listId}`, { method: 'DELETE' });
|
await api(`/api/lists/${listId}`, { method: 'DELETE' });
|
||||||
goto('/lists');
|
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>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@@ -265,22 +271,38 @@
|
|||||||
<h2 class="text-2xl font-bold">{list.name}</h2>
|
<h2 class="text-2xl font-bold">{list.name}</h2>
|
||||||
<p class="text-sm text-gray-500">{list.store.name}</p>
|
<p class="text-sm text-gray-500">{list.store.name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onclick={archiveList}
|
onclick={archiveList}
|
||||||
class="rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-500"
|
class="rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-500"
|
||||||
>
|
>
|
||||||
Archive
|
Archive
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={deleteList}
|
||||||
|
class="btn-danger-link text-sm"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</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">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#if newItemIsApproximate}
|
{#if newItemIsApproximate}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newItemQuantityNote}
|
bind:value={newItemQuantityNote}
|
||||||
placeholder="e.g. to taste"
|
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}
|
{:else}
|
||||||
<QuantityInput
|
<QuantityInput
|
||||||
@@ -288,23 +310,13 @@
|
|||||||
allowedUnitCategories={newItemAllowedUnitCategories}
|
allowedUnitCategories={newItemAllowedUnitCategories}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/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}
|
{#if sections.length > 0}
|
||||||
<select
|
<select
|
||||||
bind:value={newItemSectionId}
|
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"
|
aria-label="Section"
|
||||||
>
|
>
|
||||||
<option value={null}>Uncategorized</option>
|
<option value={null}>No section</option>
|
||||||
{#each sections as section (section.id)}
|
{#each sections as section (section.id)}
|
||||||
<option value={section.id}>{section.name}</option>
|
<option value={section.id}>{section.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -312,7 +324,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="rounded-lg bg-primary px-4 py-2.5 font-semibold text-white"
|
class="btn-primary"
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
@@ -355,10 +367,10 @@
|
|||||||
<select
|
<select
|
||||||
value={item.sectionId ?? ''}
|
value={item.sectionId ?? ''}
|
||||||
onchange={(e) => setItemSection(item.id, (e.currentTarget.value === '' ? null : Number(e.currentTarget.value)))}
|
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}"
|
aria-label="Section for {item.name}"
|
||||||
>
|
>
|
||||||
<option value="">Uncategorized</option>
|
<option value="">No section</option>
|
||||||
{#each sections as section (section.id)}
|
{#each sections as section (section.id)}
|
||||||
<option value={section.id}>{section.name}</option>
|
<option value={section.id}>{section.name}</option>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -369,7 +381,7 @@
|
|||||||
class="shrink-0 p-1 text-gray-300 active:text-danger"
|
class="shrink-0 p-1 text-gray-300 active:text-danger"
|
||||||
aria-label="Remove {item.name}"
|
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>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -417,7 +429,7 @@
|
|||||||
class="shrink-0 p-1 text-gray-300 active:text-danger"
|
class="shrink-0 p-1 text-gray-300 active:text-danger"
|
||||||
aria-label="Remove {item.name}"
|
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>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -97,22 +97,16 @@
|
|||||||
<div class="mb-4 flex gap-2">
|
<div class="mb-4 flex gap-2">
|
||||||
<button
|
<button
|
||||||
onclick={() => (showAddToList = !showAddToList)}
|
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
|
Add to list
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => goto(`/recipes/${recipeId}/edit`)}
|
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
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onclick={deleteRecipe}
|
|
||||||
class="rounded-lg border border-danger px-4 py-2.5 text-sm text-danger"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showAddToList}
|
{#if showAddToList}
|
||||||
@@ -162,5 +156,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { goto } from '$app/navigation';
|
import { goto, beforeNavigate } from '$app/navigation';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
|
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
|
||||||
import QuantityInput, { type QuantityValue } from '$lib/QuantityInput.svelte';
|
import QuantityInput, { type QuantityValue } from '$lib/QuantityInput.svelte';
|
||||||
@@ -45,18 +45,25 @@
|
|||||||
let description = $state('');
|
let description = $state('');
|
||||||
let instructions = $state('');
|
let instructions = $state('');
|
||||||
let servings = $state<number | undefined>();
|
let servings = $state<number | undefined>();
|
||||||
|
let sourceUrl = $state<string | null>(null);
|
||||||
let ingredients = $state<IngredientForm[]>([]);
|
let ingredients = $state<IngredientForm[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
let saving = $state(false);
|
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));
|
const recipeId = $derived(Number(page.params.id));
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
try {
|
||||||
const recipe = await api<RecipeResponse>(`/api/recipes/${recipeId}`);
|
const recipe = await api<RecipeResponse>(`/api/recipes/${recipeId}`);
|
||||||
title = recipe.title;
|
title = recipe.title;
|
||||||
description = recipe.description ?? '';
|
description = recipe.description ?? '';
|
||||||
instructions = recipe.instructions ?? '';
|
instructions = recipe.instructions ?? '';
|
||||||
servings = recipe.servings ?? undefined;
|
servings = recipe.servings ?? undefined;
|
||||||
|
sourceUrl = recipe.sourceUrl;
|
||||||
ingredients = recipe.ingredients.length === 0
|
ingredients = recipe.ingredients.length === 0
|
||||||
? [emptyIngredient()]
|
? [emptyIngredient()]
|
||||||
: recipe.ingredients.map((i) => ({
|
: recipe.ingredients.map((i) => ({
|
||||||
@@ -73,7 +80,29 @@
|
|||||||
familyProductId: i.familyProductId,
|
familyProductId: i.familyProductId,
|
||||||
allowedUnitCategories: i.allowedUnitCategories,
|
allowedUnitCategories: i.allowedUnitCategories,
|
||||||
}));
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
loadError = err instanceof Error ? err.message : 'Failed to load recipe.';
|
||||||
|
} finally {
|
||||||
loading = false;
|
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 {
|
function emptyIngredient(): IngredientForm {
|
||||||
@@ -127,6 +156,7 @@
|
|||||||
async function save() {
|
async function save() {
|
||||||
if (!title.trim()) return;
|
if (!title.trim()) return;
|
||||||
saving = true;
|
saving = true;
|
||||||
|
saveError = null;
|
||||||
try {
|
try {
|
||||||
await api(`/api/recipes/${recipeId}`, {
|
await api(`/api/recipes/${recipeId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -135,7 +165,7 @@
|
|||||||
description: description || null,
|
description: description || null,
|
||||||
instructions: instructions || null,
|
instructions: instructions || null,
|
||||||
servings: servings || null,
|
servings: servings || null,
|
||||||
sourceUrl: null,
|
sourceUrl,
|
||||||
ingredients: ingredients
|
ingredients: ingredients
|
||||||
.filter((i) => i.name.trim())
|
.filter((i) => i.name.trim())
|
||||||
.map((i, idx) => ({
|
.map((i, idx) => ({
|
||||||
@@ -151,7 +181,10 @@
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
justSaved = true;
|
||||||
goto(`/recipes/${recipeId}`);
|
goto(`/recipes/${recipeId}`);
|
||||||
|
} catch (err) {
|
||||||
|
saveError = err instanceof Error ? err.message : 'Failed to save recipe.';
|
||||||
} finally {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
}
|
}
|
||||||
@@ -160,6 +193,11 @@
|
|||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="py-8 text-center text-gray-400">Loading...</p>
|
<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">← Back to recipe</button>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div>
|
<div>
|
||||||
<button onclick={() => goto(`/recipes/${recipeId}`)} class="mb-2 text-sm text-gray-500">← Back</button>
|
<button onclick={() => goto(`/recipes/${recipeId}`)} class="mb-2 text-sm text-gray-500">← Back</button>
|
||||||
@@ -171,14 +209,14 @@
|
|||||||
bind:value={title}
|
bind:value={title}
|
||||||
placeholder="Recipe title"
|
placeholder="Recipe title"
|
||||||
required
|
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
|
<textarea
|
||||||
bind:value={description}
|
bind:value={description}
|
||||||
placeholder="Short description (optional)"
|
placeholder="Short description (optional)"
|
||||||
rows={2}
|
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>
|
></textarea>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -189,7 +227,7 @@
|
|||||||
bind:value={servings}
|
bind:value={servings}
|
||||||
min={1}
|
min={1}
|
||||||
placeholder="e.g. 4"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,7 +242,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
bind:value={ingredient.quantityNote}
|
bind:value={ingredient.quantityNote}
|
||||||
placeholder="e.g. to taste"
|
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}
|
{:else}
|
||||||
<QuantityInput
|
<QuantityInput
|
||||||
@@ -218,7 +256,7 @@
|
|||||||
initialSelectedName={ingredient.initialName}
|
initialSelectedName={ingredient.initialName}
|
||||||
placeholder="Ingredient name"
|
placeholder="Ingredient name"
|
||||||
ariaLabel="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)}
|
onProductChange={(p) => onIngredientProductChange(idx, p)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,7 +267,7 @@
|
|||||||
class="px-2 text-gray-300 active:text-danger"
|
class="px-2 text-gray-300 active:text-danger"
|
||||||
aria-label="Remove ingredient"
|
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>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -255,16 +293,30 @@
|
|||||||
bind:value={instructions}
|
bind:value={instructions}
|
||||||
placeholder="Instructions (optional)"
|
placeholder="Instructions (optional)"
|
||||||
rows={6}
|
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>
|
></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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
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'}
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -112,14 +112,14 @@
|
|||||||
bind:value={title}
|
bind:value={title}
|
||||||
placeholder="Recipe title"
|
placeholder="Recipe title"
|
||||||
required
|
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
|
<textarea
|
||||||
bind:value={description}
|
bind:value={description}
|
||||||
placeholder="Short description (optional)"
|
placeholder="Short description (optional)"
|
||||||
rows={2}
|
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>
|
></textarea>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
bind:value={servings}
|
bind:value={servings}
|
||||||
min={1}
|
min={1}
|
||||||
placeholder="e.g. 4"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
bind:value={ingredient.quantityNote}
|
bind:value={ingredient.quantityNote}
|
||||||
placeholder="e.g. to taste"
|
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}
|
{:else}
|
||||||
<QuantityInput
|
<QuantityInput
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
bind:value={ingredient.name}
|
bind:value={ingredient.name}
|
||||||
placeholder="Ingredient name"
|
placeholder="Ingredient name"
|
||||||
ariaLabel="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)}
|
onProductChange={(p) => onIngredientProductChange(idx, p)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,7 +169,7 @@
|
|||||||
class="px-2 text-gray-300 active:text-danger"
|
class="px-2 text-gray-300 active:text-danger"
|
||||||
aria-label="Remove ingredient"
|
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>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -195,13 +195,13 @@
|
|||||||
bind:value={instructions}
|
bind:value={instructions}
|
||||||
placeholder="Instructions (optional)"
|
placeholder="Instructions (optional)"
|
||||||
rows={6}
|
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>
|
></textarea>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
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'}
|
{saving ? 'Saving...' : 'Save Recipe'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -152,7 +152,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 class="mb-4 text-2xl font-bold">Stores</h2>
|
<div class="mb-4">
|
||||||
|
<a href="/lists" class="text-sm text-gray-500">← 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">
|
<form onsubmit={e => { e.preventDefault(); addStore(); }} class="mb-6 flex gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -160,9 +163,9 @@
|
|||||||
bind:value={newName}
|
bind:value={newName}
|
||||||
placeholder="New store name"
|
placeholder="New store name"
|
||||||
required
|
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
|
Add
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user