feat(ui): meez visual alignment and unified responsive layouts across desktop and mobile

- Resolve ingredient table overflow and 2-column layout on laptop screens (1280px-1440px)
- Align recipe, ingredient, and directory pages with meez visual design specifications
- Implement segmented icon navigation tabs across recipe and ingredient detail views
- Standardize top utility bar and entity detail header alignment across all viewports
- Fix mobile home page filter popup z-index and viewport overflow clipping
- Clean up legacy prototype media queries and consolidate responsive CSS system
This commit is contained in:
2026-08-17 00:48:35 -05:00
parent 272642f372
commit 21651e1ce8
19 changed files with 5200 additions and 1049 deletions
+25
View File
@@ -49,3 +49,28 @@ There is no recipe revision history.
database from portable YAML. They are intended only for initial setup or an
explicit restore. Running either command after application edits can discard
newer SQLite-only data.
## Windows dev-server notes
Node is installed at `C:\Program Files\nodejs` but is not on the default
agent shell PATH. Prefix every npm/npx command:
```bat
cmd /c "set PATH=C:\Program Files\nodejs;%PATH%&& npm run dev:app"
```
`astro dev` runs as a detached daemon (Astro 7). To stop it, find the PID
from the port and kill it directly — `scripts/restart-app.mjs` reads `/proc`
and does not work on Windows:
```bat
netstat -ano | findstr :4322
taskkill /PID <pid> /F /T
```
A long-running dev server inherited from another session can degrade
silently: pages render but Preact islands never hydrate (empty
`astro-island`, no console error). Before debugging component code, check
whether the recipe table hydrates and, if not, restart the dev server. The
URL pattern `?astro&type=script` returns 500 even when hydration works — it
is not a valid diagnostic.
-217
View File
@@ -1,217 +0,0 @@
# meez Recipe App — Site Specification (Phase 1 Discovery)
Source of truth: this document + reference screenshots under `generated/site-spec/screenshots/`. All colors/typography/geometry are exact computed values (CSS px at 1440×900 unless noted). Phase-1 scope: frontend UI discovery only. Network/API contracts intentionally excluded (user decision).
---
## 1. Site Overview
- **App**: meez — restaurant recipe management web app (authenticated SPA).
- **Base**: `https://app.getmeez.com/`; entered at `home?type=recipe&orderBy=last_viewed&direction=desc` (no redirect).
- **Framework**: React SPA in `#root`, **Material UI 5** components (`MuiGrid2`, `MuiAppBar`, `MuiChip`, `MuiTab`, `MuiDialog`, `MuiMenu`…) with JSS-generated class names (`jssNNNN`), webpack chunk bundles (`main.ddfec8b1.js`, `NNNN.hash.chunk.js`). No common framework globals detected; bundle naming is webpack.
- **Extensions/widgets observed**: Intercom chat iframe, candu A/B component (`<candu-component>`), Google Tag Manager (`gtm.js`), Recurly billing (`recurly.js`, `recurly.css`), Google Material Icons font.
- **Auth**: logged-in session (owner identity shown as initials `NW` in list Owner columns). Title format: `{Page} - meez` (list pages), `{RecipeName} - Recipe - {Folder} - meez` (editor).
- **Core concept**: single "home" list view switched by `type` query param: `recipe`, `ingredient`, `recipebook`, `doc`, `purchased_item`. Recipe creation is a wizard (name → paste ingredients/prep → editor). Recipe editor is a two-column workspace with tabs.
- **Upgrade-gated features (never attempted, per user directive)**: Menus nav tab ("Upgrade" pill), New→Menu, New→Count Sheet, editor Nutrition tab ("Upgrade" suffix). Purchase-import services (Invoice Processing, Inventory integrations) mentioned in help copy as contact-gated.
## 2. Route Inventory
All list routes share the same shell (header + nav pill row + list). URL pattern: `/home?type={type}&orderBy={orderBy}&direction={dir}` (+ optional `query=`).
| Route | URL (type=) | Purpose | Major unique components | Notes |
|---|---|---|---|---|
| Recipes | `recipe` | Recipe list | list rows (thumb, name, owner, last-viewed), row kebab menu, sort combobox, search | orderBy: last_viewed, name, date_created, modified, shared; 3-4 recipes |
| Ingredients | `ingredient` | Ingredient library | section header "Needs Attention:", svg type icons, Filter chip shows count `(1)` | 19-20 items; older rows show date (`Aug 12, 2026`) instead of relative time |
| Menus | — | Menu builder | — | **GATED** (`Upgrade` pill next to nav label); route not accessed |
| Recipe Books | `recipebook` | Recipe book collections | 5-column grid incl. "Recipes/Docs" count column | rows: "Sample Recipes" (2), "Get Started With Meez" (4) |
| Docs | `doc` | Help documents | 4-column grid, no Owner values | meez-authored docs (FAQ, how-tos) |
| New Purchase Items | `purchased_item` | Purchase imports needing attention | **empty state**: success message + "Tool Tip / Purchase items" help panel | no items → explanatory card; sort defaults to `created` |
| Recipe editor | `/recipes/{id}/steps?conceptId={id}` | Recipe editing workspace | 2-column editor, tab bar, ingredient table, prep method cards, additional details | title `{name} - Recipe - {folder} - meez`; see §8 |
Navigation: header has logo/back icon (x32), "All" text tab, search box, right-side "New" cluster (add icon + contained "New" button); pill-tab row below lists the six library types with live count badges. `New` button opens a menu: Recipe, Recipe Book, Menu (gated), Doc, Count Sheet (gated), Import, Export.
## 3. Global Design System
- **No CSS custom properties** on `:root` (design tokens are hardcoded MUI/JSS values).
- **Body**: bg `#F3F3F3`, text `#050841`, font CircularCustCapNum, 16px, weight 300, line-height 20px, margin 0.
- **Header (MUI AppBar)**: bg `#FBFBFB`, height 80px, sticky, z-index 1100.
- **Primary brand blue**: `#3D5DF6` — contained buttons, links, selected dropdown items, active sort.
- **Navy**: `#050841` (body text, Share button bg), `#202962` (breadcrumb text, 12px/400).
- **Secondary gray**: `#A5A9C1` (icon buttons); header icon color `rgba(0,0,0,0.54)`; tab text `#3C4679` (500 weight).
- **Active pill bg**: `#DBE4FF`; active editor tab bg `#F1F5FE`; active row/select text blue.
- **Radius scale**: pills 100px (buttons) / 99px (nav chips) / 20px (chips) / 10px (dialogs) / 4px (tabs, selects) / 50% (icon buttons).
- **Shadows**: MUI elevation system (dialogs: `0 11px 15px -7px rgba(0,0,0,.2), 0 24px 38px 3px rgba(0,0,0,.14), 0 9px 46px 8px rgba(0,0,0,.12)`; backdrop `rgba(0,0,0,0.5)`).
- **Buttons**: default text 16px/500; contained = blue bg, white text, pill; icon buttons 24-40px, radius 50%.
- **Spacing rhythm** (repeated values): 8px (row grid gap), 12px (chip/button padding X), 16px (menu option padding, card margins), 20px, 24px, 32px, 40px (dialog padding `30px 38px`), 80px, 160px (content column margins at ≥1440).
## 4. Typography
Primary family: **CircularCustCapNum** (custom, all UI text; Arial fallback for some icon buttons; Roboto also loaded for MUI defaults; Material Icons font for glyph icons).
| Use | Size | Weight | Line-height | Color | Notes |
|---|---|---|---|---|---|
| Body/default | 16px | 300 | 20px | `#050841` | |
| Header buttons ("All", "New", "Done") | 16px | 500 | — | `#3C4679` / white / gray | |
| Nav pill labels | 16px | 400 | 30px | `rgba(0,0,0,0.87)` | h40 pills |
| List titles (links) | 16px | 300 | 20px | `#3D5DF6` (blue link) | |
| List secondary (owner, time) | 16px | 300 | 20px | `#3D5DF6` / `#050841` | time-ago is a blue link |
| Sort combobox | 14px | 400 | 23px | `#050841` | |
| Editor tab labels | 15px | 400 | — | `#050841` | active tab bg `#F1F5FE` |
| Editor input values | 14px | 400 | — | `rgba(0,0,0,0.87)` | |
| Breadcrumb | 12px | 400 | 16px | `#202962` | |
| Section headers ("Needs Attention:") | 16px | 300 | — | `#050841` | |
Letter-spacing normal throughout; no text-transform observed.
## 5. Colors
| Token | Value | Usage |
|---|---|---|
| Ink | `#050841` | body text, Share button bg |
| Primary blue | `#3D5DF6` | contained buttons, links, selected states |
| Page bg | `#F3F3F3` | app background |
| Surface | `#FFFFFF` | rows, cards, dialogs |
| Header bg | `#FBFBFB` | app bar, list header row |
| Active pill | `#DBE4FF` | selected nav chip |
| Active tab bg | `#F1F5FE` | selected editor tab |
| Tab text | `#3C4679` | header "All" tab, unselected |
| Icon gray | `#A5A9C1` | icon buttons, filter icons |
| Icon faint | `rgba(0,0,0,0.54)` | header icon buttons |
| Disabled | `#DADADA` text on `#40B49A` bg | Done button (disabled state) |
| Success green | `#40B49A` | Done button bg |
| Row divider | `#F3F3F3` | row bottom border (0.74px) |
| Backdrop | `rgba(0,0,0,0.5)` | modal scrim |
## 6. Spacing / Layout Conventions
- **Content column**: at 1440px, list column is 1120px centered (x=160, margins 160). At ≤1024: margins collapse to 24px (list x=24, w=1057); at ≤768: full-width (x=0-24); at 390: x=16, w=358.
- **Grid lists**: CSS grid rows, 8px gap; list header row 57px bg `#FBFBFB`; data rows 57px white, bottom border `#F3F3F3`; cells vertically centered.
- **Recipe list columns** (1120 total): 36 (checkbox) | 112 (Type thumb 24×24) | 560 (Name) | 0 (hidden) | 112 (Owner initials) | 224 (Last viewed) | 36 (actions 24×24).
- **Recipe Book columns**: 56 | 84 | 516 | 144 (Recipes/Docs count) | 100 | 172 | 48.
- **Header**: logo icon 28×28 at x32 y26; "All" tab x72 y18; right cluster New icon+button at x1258/1274 y24 (each 32px tall); nav pill row y≈106 (h40, gap 8px); Filter chip right-aligned (h44).
- **Editor**: two columns — ingredient table left (x125-460), prep/details right (x≈778+). Tab bar h48 at y48. Header h80.
- **Padding norms**: chips `8px 16px 8px 12px`; dialog `30px 38px`; menu `8px 0`; menu options `6px 16px`; contained buttons `0 12px`.
## 7. Reusable Components
- **Nav pill chips** (MuiChip-filled/outlined): h40, radius 99px, label 16px/400; active bg `#DBE4FF`; live count in label (e.g., "Recipes 4"); Filter chip variant h44 radius 20px, icon+label.
- **List row**: checkbox (20×20 MUI) + type icon (24×24, svg for ingredients) + name link + owner initials (20px) + time link + 2 icon buttons (24×24, radius 50%): favorite/open + kebab.
- **Row kebab menu** (MUI Menu, portal): width 275, options 16px/300 pad `6px 16px`; items: Add to Recipe Book, Grant view access, Send a copy, Make a copy, Export, Print, Delete.
- **Sort combobox** (MUI Select): 14px, pad `4px 32px 5px 0`; dropdown 196w; selected option blue 400.
- **Search textbox**: 16px input, pad `8px 0 6px`, placeholder "Search "; commits on Enter → `query=` param; count badges live-filter.
- **Buttons**: contained (blue pill, 16px/500), text, icon (radius 50%); "New" = add icon + contained button pair; "Done" = green pill disabled until saved state; "Share" = navy pill (`#050841`).
- **Dialogs**: 700w radius 10px (create wizard), 900w (steps wizard); white bg, pad `30px 38px`; backdrop `rgba(0,0,0,0.5)`; footer buttons Cancel (text) + primary pill (250×48).
- **Form fields**: text inputs 14px, pad `10px 12px`, borderless underline (MUI); FilledInput variants in editor ("Qty", "Unit", "Station Name", "Tag Name" placeholders).
- **Empty state card**: white, pad `0 40px 80px`, title + help text (purchase items page).
- **Editor controls**: "Add Header"/"Add Note" buttons, "Bulk Add"/"Add Ingredients" (outlined, opens textarea dialog 583×320), Total Yield Qty+Unit inputs, toggles "Auto calculate total yield", "Calculate %".
## 8. Per-Route Specification
### 8.1 Recipes list (`/home?type=recipe`)
Header + nav + list as §6. Row: checkbox | Type thumb (24×24) | Name (blue link) | Owner initials | relative time link | action icons. Sort options: Last Modified, Last Viewed, Date Created, Last Modified, Date Shared (direction arrows keyboard_arrow_up/arrow_downward toggle asc/desc). Search filters live with `query=` param; nav badges update (Recipes 1…). Screenshots: `home@1440w.png` (3 rows), `home@1440w-sort-open.png`, `home@1440w-row-menu.png`, `home@1440w-search.png`, `home@1024w.png`, `home@768w.png`, `home@390w.png`.
### 8.2 Ingredients (`/home?type=ingredient`)
Same shell; section header "Needs Attention:" (16px/300, y189); rows have SVG type icons; Filter chip shows `(1)`. Older items show `Aug 12, 2026` date format. Screenshots: `ingredients@1440w.png`, `ingredients@1024w.png`, `ingredients@768w.png`, `ingredients@390w.png`.
### 8.3 Recipe Books (`/home?type=recipebook`)
5-column grid incl. Recipes/Docs count; loading state shows "Loading...". Screenshots: `recipe-books@1440w.png`, `recipe-books@390w.png`.
### 8.4 Docs (`/home?type=doc`)
4-column grid; meez-authored docs; no Owner values. Screenshots: `docs@1440w.png`, `docs@390w.png`.
### 8.5 New Purchase Items (`/home?type=purchased_item`)
Empty state: "Your purchase items are up to date!" + help panel (upload template / Invoice Processing Service / Inventory integrations — services contact-gated). Screenshots: `purchase-items@1440w.png`, `purchase-items@390w.png`.
### 8.6 Recipe create wizard
1. **Create Recipe dialog** (700×636, r10): fields Recipe Name (583×40, 14px), Owner radio "Personal" (disabled for single-user), Folder radio "First Concept", "Add to Recipe Book (optional)" search (placeholder "Search existing recipe books or create new"); buttons Cancel + Next (250×48 blue pill). Screenshot: `recipe-create-modal@1440w.png`.
2. **Add Ingredients & Prep Method dialog** (900×742): Ingredients textarea (387×320) + Prep Method textarea, tip text "(note)" syntax and "To Garnish:" header syntax; buttons Skip + Add Recipe. Screenshot: `recipe-steps-wizard@1440w.png`.
3. On Add Recipe → `/recipes/{id}/steps?conceptId={id}` editor.
### 8.7 Recipe editor (`/recipes/{id}/steps`)
- Header: logo/back icons, breadcrumb `Home / First Concept / Recipes` (12px/400 `#202962`), **Done** (green pill `#40B49A`, disabled in initial state), **Share** (navy pill `#050841`), New cluster, kebab.
- **Tab bar** (h48, y48): Prep Method (active bg `#F1F5FE`), Cost, UoM Equivalency, Nutrition **Upgrade** (gated). At ≤768 an **Ingredients** tab is prepended; at 390 tabs shrink to 70w each in a scrollable bar.
- **Left column — ingredient table**: header "Total Yield" with toggles "Auto calculate total yield" / "Calculate %"; columns Qty (83w) / Ingredient (220w, autocomplete → library match, e.g. "Test Flour"→"Flour") / Notes (232w); tip "Enter to complete, Tab to add a note"; Add Header / Add Note buttons; Add Ingredients (outlined) opens textarea dialog.
- **Right column**: "Add Cover Image" (landscape recommended), **Prep Method** section (step cards: "1." + "Drag & Drop or click to add an image or a video — File should not excede 200mb or 2 min"), Add Header / Add Note.
- **Additional Details** (right, y≈791): Shelf Life, Station Name input, Tag Name input, Owner accordion (radio "First Concept").
- **Cost tab**: adds "Calculate %" toggle context; **UoM Equivalency tab**: "U of M Equivalency" rows (Weight = / Volume = / Each =), help text, "Standard Weight - Volume Conversion" toggle (locked to 8oz = 1 cup when on).
- Screenshots: `recipe-editor@1440w.png`, `recipe-editor-cost@1440w.png`, `recipe-editor-uom@1440w.png`, `recipe-editor@1024w.png`, `recipe-editor@768w.png`, `recipe-editor@390w.png`.
## 9. Responsive Behavior
Viewports emulated (CDP): 1440×900, 1024×768, 768×1024, 390×844 (real window 1157×744 @ dpr 1.35; captures at physical 1296×810 for 1440).
| Breakpoint | Header | Nav | List column | Notes |
|---|---|---|---|---|
| ≥1440 | 80px, one row | pill row y106 | x160, w1120 | |
| 1024 | 80px | y106 | x24, w1057 (margins 160→24) | Filter chip stays right |
| 768 | **120px** (wraps to 2 rows) | y144 | x0, w768 full-bleed | editor: Ingredients tab appears; Filter moves to header row (y76) |
| 390 | **140px** (3 rows) | y148, horizontally scrollable (chips ≥390 wide) | x16, w358 | editor tabs 70w scrollable; New cluster icon-only (no text button found in header at 390) |
Inferred breakpoints from layout transitions: ~1200 (margins collapse), ~900 (header grows, nav row wraps), ~600 (full-bleed list, compact tabs).
## 10. Interaction / State Behavior
- **Search**: type + Enter → URL `query=` param, list filters, count badges update, sort resets to `unsorted`; clearing restores.
- **Sort**: combobox pick → list reorders; arrow icons toggle direction (`direction=asc/desc`); URL reflects `orderBy`/`direction`.
- **Row kebab**: menu with 7 actions (Add to Recipe Book, Grant view access, Send a copy, Make a copy, Export, Print, Delete). Delete NOT exercised (only for test recipe cleanup).
- **Row hover**: second action icon reveals (24×24 buttons, both present in DOM).
- **New menu** (MuiListItemButton list, 375×348): Recipe / Recipe Book / Menu (gated) / Doc / Count Sheet (gated) / Import / Export; opens create wizard (Recipe) — DOM click required; real-mouse clicks failed to activate popover items on the relay (implementation detail for automation).
- **Create wizard**: modal steps; "Next" advances; "Add Recipe" creates draft and opens editor at `/recipes/{id}/steps?conceptId={id}`.
- **Editor dirty state**: Done button disabled initially (green `#40B49A` + gray text); enabled state not observed (not triggered).
- **Ingredient autocomplete**: typing in Ingredient cell matches library ingredients (e.g., "Test Flour" → "Flour"); Qty/Unit cells per row ("g", "ea").
- **Filter chip**: no observable panel opened via mouse on relay; documented as inert in this environment (possible relay input quirk) — flag for manual verification.
- **Loading states**: list routes show "Loading..." while fetching; empty states (purchase items) show guidance card.
- **Dialogs**: ESC and Cancel close; backdrop `rgba(0,0,0,0.5)` click closes (standard MUI).
## 11. Asset Inventory
| Asset | Source/type | Dimensions | Where used | Behavior |
|---|---|---|---|---|
| CircularCustCapNum | `fonts.css` (custom @font-face) | — | all UI text | primary font |
| Material Icons | Google `icon?family=Material+Icons` | — | glyph icons | icon font |
| Roboto | MUI default | — | fallback | |
| MUI SvgIcon set | inline SVG, viewBox 0 0 24 24 | 20 (small) / 24 (medium) | all controls (add, kebab, arrows, filter, share) | `MuiSvgIcon-root` classes; icon font Material Icons also used |
| Logo | header icon button (svg) + "FC" text in editor breadcrumb | 28×28 / 27×27 | app bar left | |
| Thumbnails | list Type column | 24×24 div/svg | rows | ingredients = svg icons |
| Cover image | user upload (editor "Add Cover Image", landscape) | up to 200MB / 2min video | editor right column | drag&drop or click |
| Intercom widget | iframe | — | floating chat | third-party |
| candu component | `<candu-component>` | — | A/B overlays | third-party |
| gtm/recurly scripts | external JS | — | analytics/billing | third-party |
## 12. Animation Behavior
- Menus/popovers (New menu, kebab, sort dropdown): MUI Grow/Fade entry — sampled mid-transition `transition: all`, settling to `background-color 0.15s cubic-bezier(0.4,0,0.2,1)`; entry completes <300ms (opacity 1, transform none at rest). Standard MUI durations (~195-225ms, cubic-bezier(0.4,0,0.2,1)).
- Dialog open: MUI Dialog fade+zoom with backdrop fade; backdrop `rgba(0,0,0,0.5)`.
- Hover: row action icons reveal (opacity/visibility); no transform motion observed.
- No entrance animations on list content observed; loading = text "Loading...".
## 13. Reference Screenshot Index
All under `generated/site-spec/screenshots/` (PNG; 1440 captures at 1296×810 physical):
| File | Route | Viewport |
|---|---|---|
| home@1440w.png | Recipes list | 1440×900 |
| home@1440w-sort-open.png | Recipes, sort dropdown open | 1440×900 |
| home@1440w-row-menu.png | Recipes, row kebab menu | 1440×900 |
| home@1440w-search.png | Recipes, search "banana" | 1440×900 |
| home@1024w.png / home@768w.png / home@390w.png | Recipes list | 1024 / 768 / 390 |
| ingredients@1440w.png, @1024w, @768w, @390w | Ingredients list | all four |
| recipe-books@1440w.png, @390w | Recipe Books | 1440 / 390 |
| docs@1440w.png, @390w | Docs | 1440 / 390 |
| purchase-items@1440w.png, @390w | Purchase items empty state | 1440 / 390 |
| recipe-create-modal@1440w.png | Create wizard step 1 | 1440×900 |
| recipe-steps-wizard@1440w.png | Create wizard step 2 | 1440×900 |
| recipe-editor@1440w.png, @1024w, @768w, @390w | Recipe editor | all four |
| recipe-editor-cost@1440w.png / recipe-editor-uom@1440w.png | Editor Cost / UoM tabs | 1440×900 |
## 14. Unknowns / Things That Could Not Be Observed
- **Upgrade-gated (not attempted, per directive)**: Menus nav + editor flow; New→Menu; New→Count Sheet; editor Nutrition tab; purchase-import services (Invoice Processing, Inventory integrations — contact-gated).
- **Filter chip**: clicking produced no observable panel in this environment (possible relay input quirk); needs manual verification — expected to open a filter drawer/menu.
- **Done button enabled state**: disabled in initial editor state; enabling trigger (save/dirty) not observed — manual check needed.
- **Row action icons** (24×24 pair): exact icon semantics (favorite vs open) not labeled in DOM; inferred from position.
- **Ingredient detail view**: row click navigation target (ingredient editor) not opened (read-only constraint on non-test data).
- **Recipe detail/share flows**: Share menu, Grant view access, Send a copy, Export/Print outputs not exercised.
- **Real window**: native 1157×744 @ dpr 1.35 — all captures used CDP-emulated viewports; physical vs CSS px scaling noted.
- **Test recipe** `OMP DISCOVERY TEST` (id 1210817): created for editor discovery, deleted at phase cleanup via row kebab → Delete → confirm dialog ("Delete recipe?" + "Recipe Deleted Successfully" toast).
+207
View File
@@ -0,0 +1,207 @@
# meez home page measurements
Live measurements of the meez recipe home page
(`https://app.getmeez.com/home?type=recipe`) used as the styling target for
the Formulation app home page. Measured 2026-08-14 with an authenticated
relay-browser tab at a 1440×900 viewport, via `getComputedStyle` and
`getBoundingClientRect`. Re-measure before trusting these values on a new
design pass.
## Global
| Property | Value |
|---|---|
| body background | `#f3f3f3` |
| font family | `CircularCustCapNum, sans-serif` (proprietary; Formulation uses the Inter fallback stack) |
| base font | 16px / weight 300 / line-height 20px |
| text color | `#050841` |
## Header (sticky top bar)
- Height 80px, background `#fbfbfb`, no border, no shadow, z-index 1100.
- Inner toolbar: padding `0 24px`, min-height 48px.
### Search box
- Container: 504 × 40px, background `#fff`, border 1px solid `#f3f3f3`,
border-radius 4px, padding `0 12px`.
- Icon: 18px, `#8283a0`.
- Input: 16px / weight 400, line-height 23px, padding `8px 0 6px`,
color `rgba(0,0,0,.87)`; placeholder `#a5a9c1`.
### New button
- Height 32px, border-radius 100px, background `#3d5df6`, white text,
16px / weight 500, padding `0 12px`.
- "add" icon 18px, 8px gap to label.
### New dropdown
- Paper: 375px wide, border-radius 4px, no border, MUI elevation-2 shadow:
`0 2px 1px -1px rgba(0,0,0,.2), 0 1px 1px 0 rgba(0,0,0,.14), 0 1px 3px 0 rgba(0,0,0,.12)`.
- List padding `22px 0`; items min-height 43.4px, padding `8px 25px`.
- Item icon: 24px, `#a5a9c1`. Label: 16px / weight 500, `#050841`.
- Hover background `#f1f5fe`.
## Workspace layout
- Content column 1120px wide, centered; page wrapper padding `0 32px 32px`.
- Tabs row starts ≈26px below the header; table header ≈46px below the tabs row.
### Tab chips (workspace pills)
- Chip: 40px tall, border-radius 99px, padding `8px 16px 8px 12px`,
gap 8px, 16px / weight 400, color `rgba(0,0,0,.87)`.
- Inactive: background `#fff`, border 1px solid `#ececec`.
- Active: background `#DBE4FF`, no border.
- No hover change (hover state identical to resting state).
- Icon: 20px circle, white SVG with 1.5px inner padding. Per type:
- recipe `#3C4679`
- ingredient `#3F908A`
- book `#F3A642`
- purchase `#3F908A`
- Label: 16px / weight 500, `#050841`, 8px right margin.
- Count: 13px / weight 500, `#a5a9c1`.
### Filter chip
- Transparent background, border-radius 20px, padding 10px.
- Funnel SVG 15×18 (viewBox `0 0 18 21`), `#050841`.
- Text 15px / weight 400, `#050841`. Border stays transparent on hover.
## Directory table
- Header row: 56.66px, background `#fbfbfb`, border-bottom 1px solid
`#f3f3f3`, grid gap 8px, no padding; checkbox cell 36px with 8px left padding.
- Column titles: 14px / weight 400, `#a5a9c1`, line-height 21px.
- Sort icon: 18px, `#a5a9c1`.
- Data row: 56.66px, background `#fff`, border-bottom 1px solid `#f3f3f3`,
gap 8px, no padding. **No hover background.**
- Selected row background `#f1f5fe`.
- meez columns: 36 (checkbox) | 112 (type) | 560 (name) | 112 (owner) |
224 (last viewed) | 36 (actions). Formulation clones only checkbox, type,
name, and actions — Owner/Last Viewed are explicitly out of scope.
- Name text: 15px / weight 500, `#050841`, line-height 22.5px. The wrapping
link is `#3d5df6` but the name paragraph overrides it.
- Row type icon: 24px circle, white 20px SVG, same per-type colors as tabs.
- Checkbox: 20×20; unchecked outline `#ececec`, checked fill `#3d5df6`
with white check.
- Row action button (more_vert): 24×24, `#a5a9c1`, border-radius 50%,
hover background `rgba(0,0,0,.04)`.
### Row action menu
- Paper: 275px, border-radius 4px, elevation-2 shadow, list padding `8px 0`.
- Items: min-height 47.4px, padding `10px 30px`, label 16px / weight 500
`#050841`, icon 24px `#a5a9c1`.
- Hover `#f1f5fe` (verified with real mouse input). Delete item `#f63d48`.
### Delete dialog
- Paper: 700px, border-radius 10px, padding `30px 38px`, MUI elevation-24
shadow:
`0 11px 15px -7px rgba(0,0,0,.2), 0 24px 38px 3px rgba(0,0,0,.14), 0 9px 46px 8px rgba(0,0,0,.12)`.
- Backdrop `rgba(0,0,0,.5)`.
- Title: 28px / weight 700, `#202962`, line-height 39px.
- Subtitle: 15px / weight 400, `#050841`.
- Cancel: transparent, 15px / weight 500, `#050841`, 52px gap to Delete.
- Delete: `#f63d48` pill, 16px / weight 500, padding `16px 24px`,
min-width 250px, height 48px, border-radius 100px.
## Dropdown panels (filter / item-type menus)
- 300px wide, border-radius 4px, no border, elevation-2 shadow,
padding `14px 0`.
- Labels: 14px; hover `#f1f5fe`. Apply button `#3d5df6`.
## Measurement cautions
- MUI hover states (menu items, chips) only appear with real mouse input
(CDP mouse events), not synthetic `mouseover` dispatch.
- The relay tab cannot screenshot while not visible; use computed styles
and rects.
- Re-measure on any new pass: these are point-in-time values from one
authenticated account's render.
## Recipe read view (`/recipes/{id}/steps`)
Measured on the same account (recipe "Marinated Pork Belly"), same viewport
rules apply. Formulation's counterpart: `/app/recipes/{id}/` (read mode).
### Header (48px, background `#fbfbfb`)
- Breadcrumb links "Home / Recipes": 12px / weight 400, `#202962`,
line-height 22.5px; "/" separators `#95969c`, margin 0 4px.
- Edit button: white pill, 1px `#3d5df6` border, radius 100px, height 32px,
padding 0 12px, text 15px / weight 500 `#3d5df6`, edit icon 16px with 8px
right margin.
- Share button (not cloned): `#050841` pill, white 16px / weight 500.
### Layout
- Body `#f3f3f3`; content wrapper starts 16px below the header.
- Two 50/50 columns, both background `#fbfbfb`; right column has a 1px
`#f3f3f3` left border. Left padding `0 36px 35px`, right `0 32px 32px`.
- meez keeps both columns at 1027px viewport width (stacking breakpoint is
lower than Formulation's old 1280px).
### Left column (formula)
- Title: 28px / weight 900, `#050841`, line-height 42px; owner line
12px / weight 500 directly below.
- Batch/yield row: labels 14px / weight 400 `#050841` with 15px right
padding; "1x" batch value 14px / weight 500 with `#a5a9c1` underline;
yield value 15px / weight 500.
- Ingredient table: no header row, no row borders. Component heading rows
18px / weight 500, line-height 27px, bottom border `#fdfdfd`. Ingredient
rows 40.66px tall, cell padding 8px 0 (name cell +10px left). Quantity
number 15px / weight 300, unit 14px. Name link 15px / weight 400,
`rgba(0,0,0,.87)`.
### Right column (tabs + method)
- Tab bar: 48px, at the top of the column. Tabs: icon 24px in a 45px box
(8px padding, 5px right margin), label 13px / weight 400 `#050841`,
padding 0 10px 0 0, 1px `#f3f3f3` right border between tabs.
Active: background `#f1f5fe` + 1px `#3d5df6` bottom border.
- Panel heading row: h2 22px / weight 700, line-height 33px; step count
16px / weight 700 `#a5a9c1` with 8px left margin; 34px bottom margin.
- Step section headings: 18px / weight 500, line-height 27px.
- Steps: number 16px / weight 700, text 16px / weight 300, `#050841`;
separators 1px `#f3f3f3` with 16px vertical margins.
- Additional details: heading 18px / weight 500; label 14px / weight 500,
value 15px / weight 500, `#050841`.
### Tab icons (Material paths, white-on-inherit)
- Prep Method: list icon, viewBox 24 (`M4 10.5c-.83 0-1.5.67-1.5 1.5s.67
1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5m0-6c-.83 0-1.5.67-1.5
1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5m0 12c-.83 0-1.5.68-1.5
1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5M7 19h14v-2H7zm0-6h14
v-2H7zm0-8v2h14V5z`).
- Cost: attach_money icon, viewBox 24.
- UoM Equivalency: custom balance icon, viewBox 24.
- Nutrition: custom heart icon, viewBox 20×18.
### Cost panel (`/recipes/{id}/cost`, measured in the second pass)
- Panel padding `35px 32px 0` (same `#fbfbfb` column).
- h2 22px / weight 700, line-height 33px; description 14px / weight 400
`#95969c`; ≈20px gap before the list.
- Column header row: padding `0 0 4px 14px`, bottom border `#f3f3f3`,
labels 12px / weight 500 `#a5a9c1`; "Expand all | Collapse all" links
12px / weight 500 `#3d5df6`.
- Rows (MUI accordions): min-height 48px, summary padding `12px 32px`,
name 15px / weight 400 `#050841`, cost value 15px / weight 400.
Detail labels ("Purchase Item Name", etc.) 14px / weight 400 `#a5a9c1`.
### UoM Equivalency panel (`/recipes/{id}/equivalency`)
- h2 "U of M Equivalency" 22px / weight 700, line-height 33px.
- Description 14px muted `#95969c`.
- Equivalency matrix: three value columns (Weight / Volume / Each) with
"=" separators; columns ≈124px wide, 79.5px tall.
### Nutrition tab
Gated ("Upgrade" teaser) on the measured account — no read-mode nutrition
panel exists in meez to match.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
import { dev } from "astro";
try {
const server = await dev({
configFile: "astro.app.config.mjs",
server: {
port: 4322,
host: true
}
});
console.log("Astro dev server is running on http://localhost:4322/app/");
} catch (err) {
console.error("Failed to start Astro dev server:", err);
process.exit(1);
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Meez Design Token & Style Extractor
*
* Paste this snippet into the DevTools Console while viewing Meez
* (e.g. https://app.getmeez.com/home?type=recipe or a recipe detail page).
* It will collect computed styles, layout metrics, and SVG icons and copy
* a formatted JSON report to your clipboard.
*/
(() => {
const getStyle = (el, prop) => el ? window.getComputedStyle(el).getPropertyValue(prop) : null;
const extractComponent = (selector, name) => {
const el = document.querySelector(selector);
if (!el) return null;
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return {
name,
selector,
dimensions: { width: rect.width, height: rect.height },
typography: {
fontFamily: style.fontFamily,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
lineHeight: style.lineHeight,
letterSpacing: style.letterSpacing,
color: style.color,
},
surface: {
backgroundColor: style.backgroundColor,
borderRadius: style.borderRadius,
border: `${style.borderWidth} ${style.borderStyle} ${style.borderColor}`,
boxShadow: style.boxShadow,
},
spacing: {
padding: style.padding,
margin: style.margin,
gap: style.gap,
}
};
};
// Collect key UI elements on current page
const report = {
url: window.location.href,
timestamp: new Date().toISOString(),
global: {
bodyBackground: getStyle(document.body, 'background-color'),
fontFamily: getStyle(document.body, 'font-family'),
fontSize: getStyle(document.body, 'font-size'),
color: getStyle(document.body, 'color'),
},
components: {
header: extractComponent('header, [role="banner"], .MuiAppBar-root', 'Header / Top Bar'),
searchBox: extractComponent('input[type="search"], input[placeholder*="Search"]', 'Search Input'),
newButton: extractComponent('button:has(svg), a:has(svg)', 'New Button'),
tableHeader: extractComponent('[role="rowgroup"] [role="row"]:first-child, thead tr', 'Table Header'),
tableRow: extractComponent('[role="rowgroup"] [role="row"]:not(:first-child), tbody tr:first-child', 'Table Data Row'),
tabPillActive: extractComponent('.MuiChip-root, [role="tab"][aria-selected="true"]', 'Active Tab / Pill'),
},
svgIcons: Array.from(document.querySelectorAll('svg')).slice(0, 30).map((svg, idx) => ({
index: idx,
viewBox: svg.getAttribute('viewBox'),
width: svg.getAttribute('width') || svg.clientWidth,
height: svg.getAttribute('height') || svg.clientHeight,
fill: getStyle(svg, 'fill') || getStyle(svg, 'color'),
paths: Array.from(svg.querySelectorAll('path')).map(p => p.getAttribute('d')),
ariaLabel: svg.getAttribute('aria-label') || svg.closest('button, a')?.getAttribute('aria-label') || ''
}))
};
console.log('=== MEEZ EXTRACTED TOKENS ===', report);
const jsonStr = JSON.stringify(report, null, 2);
if (navigator.clipboard) {
navigator.clipboard.writeText(jsonStr).then(() => {
console.log('✅ Tokens copied to clipboard!');
}).catch(() => {
console.log('Copy to clipboard failed. Access report via window.__meezReport');
});
}
window.__meezReport = report;
return report;
})();
+49 -31
View File
@@ -2,6 +2,18 @@
export const prerender = false;
import BaseLayout from "../../../layouts/BaseLayout.astro";
import EntityDirectory from "../../../components/EntityDirectory";
const TYPE_ICONS:Record<"recipe"|"ingredient"|"book"|"purchase",string> = {
recipe:"M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z",
ingredient:"M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z",
book:"M14.0347 0V16H3.12658C1.50098 16 0.527344 15.0264 0.527344 13.4008V2.59923C0.527344 0.97364 1.50098 0 3.12658 0H14.0347ZM12.1594 12.6763L3.95747 12.6765C2.75227 12.6765 2.40226 12.9098 2.40226 13.4008C2.40226 13.9909 2.53647 14.1251 3.12658 14.1251H12.1598L12.1594 12.6763ZM12.1598 1.87492H3.12658C2.53647 1.87492 2.40226 2.00913 2.40226 2.59923L2.40185 10.9999C2.85223 10.8678 3.37428 10.8015 3.95747 10.8015L12.1594 10.8014L12.1598 1.87492ZM10.0768 3.80157V5.67649H4.45204V3.80157H10.0768Z",
purchase:"M19.5 3.5 18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5zM19 19.09H5V4.91h14v14.18zM6 15h12v2H6zm0-4h12v2H6zm0-4h12v2H6z"
};
const TYPE_ICON_TRANSFORMS:Record<"recipe"|"ingredient"|"book"|"purchase",string|undefined> = {
recipe:"scale(1.1, 1.1) translate(4px, 3.5px)",
ingredient:"scale(1.3, 1.3) translate(2.5px, 1px)",
book:"scale(1.1, 1.1) translate(3.5px, 2.5px)",
purchase:undefined
};
import { openDatabase } from "../../../lib/database";
import { readOnlyMode } from "../../../lib/runtime";
import { titleCase } from "../../../lib/format";
@@ -23,8 +35,8 @@ const purchases=database.prepare(`SELECT p.id,p.ingredient_id,p.name,p.supplier_
FROM purchase_items p JOIN ingredients i ON i.id=p.ingredient_id ORDER BY p.name`).all() as any[];
database.close();
const requested=Astro.url.searchParams.get("type")??"ingredient";
const type=["recipe","ingredient","book","purchase"].includes(requested)?requested:"ingredient";
const requested=Astro.url.searchParams.get("type");
const type=["recipe","ingredient","book","purchase"].includes(requested??"")?requested:undefined;
const query=(Astro.url.searchParams.get("q")??"").trim();
const normalizedQuery=query.toLocaleLowerCase();
const validSearchTypes=["recipe","ingredient","book","purchase"];
@@ -34,60 +46,66 @@ const attention=Astro.url.searchParams.get("attention")==="1", missingCost=Astro
const filtering=attention||missingCost||noUsda||unused||emptyRecipe||placeholderSteps;
const filteredIngredients=ingredients.filter((ingredient)=>{if(!filtering)return true;const selected=[missingCost&&ingredient.price_count===0,noUsda&&ingredient.nutrition_count===0,unused&&ingredient.recipe_count===0].filter(Boolean);return missingCost||noUsda||unused?selected.length>0:ingredient.price_count===0||ingredient.nutrition_count===0||ingredient.recipe_count===0;});
const filteredRecipes=recipes.filter(recipe=>!filtering||(emptyRecipe&&recipe.item_count===0)||(placeholderSteps&&recipe.placeholder_count>0)||(!emptyRecipe&&!placeholderSteps&&(recipe.item_count===0||recipe.placeholder_count>0)));
const tabs=[
{type:"recipe",label:"Recipes",count:recipes.length,icon:"▦",kind:"recipe"},
{type:"ingredient",label:"Ingredients",count:ingredients.length,icon:"●",kind:"ingredient"},
{type:"book",label:"Recipe books",count:books.length,icon:"▣",kind:"book"},
{type:"purchase",label:"Purchase items",count:purchases.length,icon:"$",kind:"purchase"},
const FILTER_ICON_PATH="M18 6.0201C18 4.81608 17.1873 3.79266 16.0736 3.46156L16.0736 0.722412C16.0736 0.301005 15.7425 -9.86801e-08 15.3211 -1.171e-07C14.8996 -1.35521e-07 14.5685 0.331105 14.5685 0.752512L14.5685 3.49166C13.4548 3.79266 12.6421 4.84618 12.6421 6.0502C12.6421 7.28432 13.4548 8.30774 14.5685 8.63884L14.5685 19.7459C14.5685 20.1673 14.8996 20.4984 15.3211 20.4984C15.7425 20.4984 16.0736 20.1673 16.0736 19.7459L16.0736 8.63884C17.1873 8.27764 18 7.25422 18 6.0201ZM16.495 6.0502C16.495 6.68231 15.9833 7.22412 15.3211 7.22412C14.6588 7.22412 14.1471 6.68231 14.1471 6.0502C14.1471 5.41809 14.6588 4.87628 15.3211 4.87628C15.9833 4.87628 16.495 5.41809 16.495 6.0502Z";
const tabs:Array<{type:string;label:string;count:number;kind:"recipe"|"ingredient"|"book"|"purchase"}>=[
{type:"recipe",label:"Recipes",count:recipes.length,kind:"recipe"},
{type:"ingredient",label:"Ingredients",count:ingredients.length,kind:"ingredient"},
{type:"book",label:"Recipe books",count:books.length,kind:"book"},
{type:"purchase",label:"Purchase items",count:purchases.length,kind:"purchase"},
];
const allSearchResults=normalizedQuery ? [
...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"recipe",label:"Recipe",icon:"▦",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})),
...ingredients.filter((item)=>`${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"ingredient",label:"Ingredient",icon:"●",name:titleCase(item.name),detail:`Used in ${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/ingredients/${item.id}/`})),
...books.filter((item)=>`${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"book",label:"Recipe book",icon:"▣",name:item.name,detail:`${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:"/app/?type=book"})),
...purchases.filter((item)=>`${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"purchase",label:"Purchase item",icon:"$",name:item.name,detail:titleCase(item.ingredient_name),href:`/app/ingredients/${item.ingredient_id}/#costs`})),
...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"recipe" as const,label:"Recipe",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})),
...ingredients.filter((item)=>`${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"ingredient" as const,label:"Ingredient",name:titleCase(item.name),detail:`Used in ${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/ingredients/${item.id}/`})),
...books.filter((item)=>`${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"book" as const,label:"Recipe book",name:item.name,detail:`${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/recipe-books/${item.id}/`})),
...purchases.filter((item)=>`${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({id:item.id,kind:"purchase" as const,label:"Purchase item",name:item.name,detail:titleCase(item.ingredient_name),href:`/app/ingredients/${item.ingredient_id}/#costs`})),
].sort((a,b)=>a.name.localeCompare(b.name)):[];
const searchResults=filteringSearchTypes?allSearchResults.filter((result)=>selectedSearchTypes.includes(result.kind)):allSearchResults;
const ingredientRows=filteredIngredients.map(item=>({id:item.id,name:titleCase(item.name),href:`/app/ingredients/${item.id}/`,kind:"ingredient" as const,icon:"●"}));
const recipeRows=filteredRecipes.map(item=>({id:item.id,name:item.title,href:`/app/recipes/${item.id}/`,kind:"recipe" as const,icon:"▦"}));
const bookRows=books.map(item=>({id:item.id,name:item.name,href:`/app/recipe-books/${item.id}/`,kind:"book" as const,icon:"▣"}));
const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/ingredients/${item.ingredient_id}/#costs`,kind:"purchase" as const,icon:"$"}));
const searchRows=searchResults.map(({id,kind,name,href,label,detail})=>({id,name,href,kind,detail:`${label} · ${detail}`}));
const ingredientRows=filteredIngredients.map(item=>({id:item.id,name:titleCase(item.name),href:`/app/ingredients/${item.id}/`,kind:"ingredient" as const}));
const recipeRows=filteredRecipes.map(item=>({id:item.id,name:item.title,href:`/app/recipes/${item.id}/`,kind:"recipe" as const}));
const bookRows=books.map(item=>({id:item.id,name:item.name,href:`/app/recipe-books/${item.id}/`,kind:"book" as const}));
const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/ingredients/${item.ingredient_id}/#costs`,kind:"purchase" as const}));
---
<BaseLayout title="Recipe management"><section class="shell directory-workspace">
<div class="workspace-search-tools">
<BaseLayout title="Recipe management"><div class="workspace-search-tools"><div class="workspace-search-tools-inner">
<form class="workspace-global-search" method="get" action="/app/" role="search">
<input type="hidden" name="type" value={type}/>
<input type="hidden" name="type" value={type??""}/>
{selectedSearchTypes.map((selectedType)=><input type="hidden" name="item_type" value={selectedType}/>)}
<svg class="search-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
<input type="search" name="q" value={query} placeholder="Search " aria-label="Search all items" autofocus={Boolean(query)}/>
{query&&<a href={`/app/?type=${type}`} aria-label="Clear search">×</a>}
{query&&<a href={type?`/app/?type=${type}`:"/app/"} aria-label="Clear search">×</a>}
</form>
<details class="search-type-filter" open={filteringSearchTypes}>
<summary>☷ &nbsp; Item type{filteringSearchTypes?` · ${selectedSearchTypes.length}`:""}</summary>
<form method="get" action="/app/"><input type="hidden" name="type" value={type}/><input type="hidden" name="q" value={query}/>
<summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Item type{filteringSearchTypes?` · ${selectedSearchTypes.length}`:""}</summary>
<form method="get" action="/app/"><input type="hidden" name="type" value={type??""}/><input type="hidden" name="q" value={query}/>
<label><input type="checkbox" name="item_type" value="recipe" checked={selectedSearchTypes.includes("recipe")}/> Recipes</label>
<label><input type="checkbox" name="item_type" value="ingredient" checked={selectedSearchTypes.includes("ingredient")}/> Ingredients</label>
<label><input type="checkbox" name="item_type" value="book" checked={selectedSearchTypes.includes("book")}/> Recipe books</label>
<label><input type="checkbox" name="item_type" value="purchase" checked={selectedSearchTypes.includes("purchase")}/> Purchase items</label>
<div><button>Apply</button>{filteringSearchTypes&&<a href={`/app/?type=${type}&q=${encodeURIComponent(query)}`}>All types</a>}</div>
<div><button>Apply</button>{filteringSearchTypes&&<a href={type?`/app/?type=${type}&q=${encodeURIComponent(query)}`:`/app/?q=${encodeURIComponent(query)}`}>All types</a>}</div>
</form>
</details>
{!readOnlyMode&&<details class="workspace-new-menu">
<summary><span class="new-trigger-plus" aria-hidden="true"></span><span>New</span></summary>
<summary><span class="new-trigger-plus" aria-hidden="true"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg></span><span class="new-trigger-label">New</span></summary>
<nav aria-label="Create new item">
<a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true"></span><strong>Recipe</strong></a>
<a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true"></span><strong>Recipe book</strong></a>
<a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path fill="currentColor" d={TYPE_ICONS.recipe} style={TYPE_ICON_TRANSFORMS.recipe?{transform:TYPE_ICON_TRANSFORMS.recipe}:undefined}/></svg></span><strong>Recipe</strong></a>
<a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path fill="currentColor" d={TYPE_ICONS.book} style={TYPE_ICON_TRANSFORMS.book?{transform:TYPE_ICON_TRANSFORMS.book}:undefined}/></svg></span><strong>Recipe book</strong></a>
</nav>
</details>}
</div>
</div>
<nav class="workspace-pills" aria-label="Workspaces">
{tabs.map((tab)=><a class:list={{active:type===tab.type}} href={`/app/?type=${tab.type}`}>{tab.label} <small>{tab.count}</small></a>)}
{type==="ingredient"&&<details class="filter-menu" open={filtering}><summary>☷ &nbsp; Filter{filtering?` · ${filteredIngredients.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="ingredient"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="missing_cost" value="1" checked={missingCost}/> Missing cost</label><label><input type="checkbox" name="no_usda" value="1" checked={noUsda}/> No USDA map</label><label><input type="checkbox" name="unused" value="1" checked={unused}/> Unused ingredient</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=ingredient">Clear</a>}</div></form></details>}
{type==="recipe"&&<details class="filter-menu" open={filtering}><summary>☷ &nbsp; Filter{filtering?` · ${filteredRecipes.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="recipe"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="empty_recipe" value="1" checked={emptyRecipe}/> Empty recipe</label><label><input type="checkbox" name="placeholder_steps" value="1" checked={placeholderSteps}/> Placeholder instructions</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=recipe">Clear</a>}</div></form></details>}
<section class="shell directory-workspace">
<nav class="workspace-pills" aria-label="Workspaces">
{tabs.map((tab)=><a class:list={{active:type===tab.type}} href={`/app/?type=${tab.type}`}><span class={`workspace-pill-icon ${tab.kind}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d={TYPE_ICONS[tab.kind]} style={TYPE_ICON_TRANSFORMS[tab.kind]?{transform:TYPE_ICON_TRANSFORMS[tab.kind]}:undefined}/></svg></span><span>{tab.label}</span><small>{tab.count}</small></a>)}
{type==="ingredient"&&<details class="filter-menu" open={filtering}><summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Filter{filtering?` · ${filteredIngredients.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="ingredient"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="missing_cost" value="1" checked={missingCost}/> Missing cost</label><label><input type="checkbox" name="no_usda" value="1" checked={noUsda}/> No USDA map</label><label><input type="checkbox" name="unused" value="1" checked={unused}/> Unused ingredient</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=ingredient">Clear</a>}</div></form></details>}
{type==="recipe"&&<details class="filter-menu" open={filtering}><summary><svg class="filter-icon" viewBox="0 0 18 21" aria-hidden="true"><path fill="currentColor" d={FILTER_ICON_PATH}/></svg>Filter{filtering?` · ${filteredRecipes.length}`:""}</summary><form method="get"><input type="hidden" name="type" value="recipe"/><label><input type="checkbox" name="attention" value="1" checked={attention}/> Needs attention</label><fieldset><legend>Attention reasons</legend><label><input type="checkbox" name="empty_recipe" value="1" checked={emptyRecipe}/> Empty recipe</label><label><input type="checkbox" name="placeholder_steps" value="1" checked={placeholderSteps}/> Placeholder instructions</label></fieldset><div><button>Apply filter</button>{filtering&&<a href="/app/?type=recipe">Clear</a>}</div></form></details>}
{!readOnlyMode&&<a class="archive-link" href="/app/archive/">Archive</a>}
</nav>
{query?<section class="workspace-search-results" aria-live="polite"><p><strong>{searchResults.length}</strong> {searchResults.length===1?"result":"results"} for “{query}”{filteringSearchTypes&&` · ${selectedSearchTypes.length} item ${selectedSearchTypes.length===1?"type":"types"}`}</p>{searchResults.length?<div>{searchResults.map((result)=><a href={result.href}><span class={`workspace-pill-icon ${result.kind}`}>{result.icon}</span><span><strong>{result.name}</strong><small>{result.detail}</small></span><em>{result.label}</em><b></b></a>)}</div>:<div class="empty-state">No items of the selected types match this search.</div>}</section>:<>
{query?<section class="workspace-search-results" aria-live="polite"><p><strong>{searchResults.length}</strong> {searchResults.length===1?"result":"results"} for “{query}”{filteringSearchTypes&&` · ${selectedSearchTypes.length} item ${selectedSearchTypes.length===1?"type":"types"}`}</p>{searchRows.length?<EntityDirectory client:load rows={searchRows} emptyMessage="No items of the selected types match this search." readOnly={readOnlyMode}/>:<div class="empty-state">No items of the selected types match this search.</div>}</section>:<>
{type==="ingredient"&&<EntityDirectory client:load rows={ingredientRows} entityType="ingredient" emptyMessage="No ingredients match these filters." readOnly={readOnlyMode}/>}
{type==="recipe"&&<EntityDirectory client:load rows={recipeRows} entityType="recipe" emptyMessage="No recipes yet." readOnly={readOnlyMode}/>}
{type==="book"&&<EntityDirectory client:load rows={bookRows} entityType="book" emptyMessage="No recipe books yet." readOnly={readOnlyMode}/>}
{type==="purchase"&&<EntityDirectory client:load rows={purchaseRows} entityType="purchase" emptyMessage="No purchase items yet." readOnly={readOnlyMode}/>}
{!type&&<p class="empty-state">Select a workspace or search to browse recipes, ingredients, and purchase items.</p>}
</>}
</section></BaseLayout>
</section>
</BaseLayout>
+343 -21
View File
@@ -126,29 +126,333 @@ const prepDisplay = prep.map((row) => {
return measure ? `${number(measure.quantity)} ${unitById.get(measure.unit_id)?.symbol ?? measure.unit_id}` : "—";
};
return {...row,weight:showMeasure("mass"),volume:showMeasure("volume"),each:showMeasure("count")};
});
});const RECIPE_TAB_ICONS = {
overview: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z",
costing: "M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4",
equivalencies: "M19.4 3.3h-6.6v-.5c0-.4-.3-.7-.8-.7-.4 0-.8.3-.8.7v.5H4.6L0 14s.2 3.8 4.7 3.8S9.4 14 9.4 14L6.1 6.2h5.1v15.7h1.5V6.2h5.1L14.6 14s.2 3.8 4.7 3.8S24 14 24 14L19.4 3.3zM7.7 14H1.5l3.1-7.4L7.7 14zm8.5 0l3.1-7.4 3.1 7.4h-6.2z",
nutrition: "M9.42859 2.37431L9.80926 2.82134L10.1899 2.37431C11.1674 1.22652 12.668 0.5 14.2234 0.5C16.9685 0.5 19.1185 2.64998 19.1185 5.3951C19.1185 7.0848 18.3631 8.65707 16.9325 10.4062C15.4961 12.1623 13.4317 14.0352 10.8957 16.3348L10.895 16.3354L9.80799 17.325L8.72319 16.345L8.72211 16.344L8.71119 16.3341C6.18062 14.0344 4.12043 12.1623 2.68618 10.4075C1.25541 8.6571 0.5 7.08481 0.5 5.3951C0.5 2.64998 2.64998 0.5 5.3951 0.5C6.95051 0.5 8.45117 1.22652 9.42859 2.37431ZM3.70568 10.127C5.0829 11.7363 7.04455 13.5134 9.36637 15.6157L9.45571 15.7051L9.80926 16.0586L10.1628 15.7051L10.2522 15.6157C12.574 13.5134 14.5356 11.7363 15.9128 10.127C17.287 8.52131 18.1567 6.99709 18.1567 5.3951C18.1567 3.1571 16.4614 1.46185 14.2234 1.46185C12.6415 1.46185 11.0895 2.39876 10.4049 3.77684H9.22149C8.52967 2.40009 6.97866 1.46185 5.3951 1.46185C3.1571 1.46185 1.46185 3.1571 1.46185 5.3951C1.46185 6.99709 2.3315 8.52131 3.70568 10.127Z"
};
const RECIPE_TAB_VIEWBOX = { overview: "0 0 24 24", costing: "0 0 24 24", equivalencies: "0 0 24 24", nutrition: "0 0 20 18" };
const recipeTabIcon = (name: string) => `<span class="recipe-tab-icon"><svg viewBox="${RECIPE_TAB_VIEWBOX[name as keyof typeof RECIPE_TAB_VIEWBOX]}" aria-hidden="true"><path fill="currentColor" d="${RECIPE_TAB_ICONS[name as keyof typeof RECIPE_TAB_ICONS]}"/></svg></span>`;
database.close();
---
<BaseLayout title={titleCase(ingredient.name)} immersive>
<section class="entity-detail-shell">
<DetailUtility section="Ingredients" sectionHref="/app/?type=ingredient" />
<header class="entity-detail-header"><div><p class="entity-breadcrumb"><a href="/app/?type=ingredient">← Ingredients</a></p>{editing?<textarea class="editable-entity-title ingredient-title-editor" name="name" form="ingredient-identity-form" aria-label="Ingredient name" rows="1" required>{ingredient.name}</textarea>:<h1>{titleCase(ingredient.name)}</h1>}</div>{!readOnlyMode&&<div class="entity-header-actions">{editing?<button class="primary-command" id="ingredient-done" type="button">✓ Done</button>:<a class="edit-command" href={`/app/ingredients/${id}/?edit=1`}>✎ Edit</a>}{editing&&<details class="detail-actions-menu ingredient-actions-menu"><summary aria-label="Ingredient actions">⋮</summary><div><form method="post" data-confirm-message="Merge this ingredient? This changes every recipe that uses it."><label><span>Merge into</span><select name="target_id" required><option value="">Select canonical ingredient</option>{mergeCandidates.map(candidate=><option value={candidate.id}>{candidate.name}</option>)}</select></label><button name="intent" value="merge">Merge ingredient</button></form></div></details>}</div>}</header>
{message&&<div class="success-notice entity-notice">{message}</div>}{error&&<div class="notice entity-notice">{error}</div>}
<div class="entity-detail-grid">
<main class="entity-primary">
{editing&&<form method="post" id="ingredient-identity-form" class="ingredient-identity-form ingredient-identity-data"><input type="hidden" name="intent" value="identity"/><input type="hidden" name="status" value={ingredient.status}/><input type="hidden" id="ingredient-prep-json" name="prep_json" value="[]"/></form>}
<section id="prep" class="entity-section ingredient-prep-section"><h2>Prep Actions</h2><p class="panel-intro">Any action taken on an ingredient that changes its yield or its weight-to-volume equivalency from the original raw state.</p>{editing?<><div class="prep-edit-wrap"><table class="prep-action-table prep-edit-table"><thead><tr><th>Prep Action</th><th>Yield %</th><th>Weight</th><th>Volume</th><th>Each</th><th></th></tr></thead><tbody id="ingredient-prep-rows" data-actions={JSON.stringify(actions)}>{prepDisplay.map(x=><tr class="prep-edit-row"><td><select class="prep-row-action" aria-label="Prep action">{actions.map(action=><option value={action.id} selected={action.id===x.action_id}>{action.name}</option>)}</select><input class="prep-row-notes" value={x.notes??""} placeholder="Optional notes" aria-label="Prep action notes"/></td><td><span class="percent-input"><input class="prep-row-yield" type="number" min="0.01" step="0.01" required value={x.yield_factor*100} aria-label="Yield percent"/><i>%</i></span></td>{[x.weight,x.volume,x.each].map(value=><td><button class="prep-equivalency-link" type="button" title="Edit this prep action's UoM equivalency">{value==="—"?"Set":value}</button></td>)}<td><button class="prep-row-remove" type="button" aria-label={`Remove ${x.name}`}>×</button></td></tr>)}</tbody></table></div><button id="add-prep-row" class="outlined-add-action" type="button"> Add Prep Action</button><p class="field-help">Use 100% for no change, 80% for trim or cooking loss, or 250% when cooking produces 2.5 times the original weight. Select Weight, Volume, or Each to define its equivalency.</p></>:prep.length?<table class="prep-action-table"><thead><tr><th>Prep Action</th><th>Yield %</th><th>Weight</th><th>Volume</th><th>Each</th></tr></thead><tbody>{prepDisplay.map(x=><tr><td><strong>{x.name}</strong>{x.notes&&<small>{x.notes}</small>}</td><td>{number(x.yield_factor*100)}%</td><td>{x.weight}</td><td>{x.volume}</td><td>{x.each}</td></tr>)}</tbody></table>:<p class="empty-copy">No prep actions defined.</p>}</section>
<section id="usage" class:list={["entity-section","additional-card",{"ingredient-additional-edit":editing}]}><h2>Additional Details</h2>{editing?<div class="ingredient-detail-fields"><label><span>Tags</span><input id="ingredient-tags" name="tags" form="ingredient-identity-form" value={ingredientTags.join(", ")} placeholder="Tag Name"/></label><label><span>Description</span><textarea name="description" form="ingredient-identity-form" rows="4" placeholder="Write your ingredient description">{ingredient.description??""}</textarea></label><label><span>Ingredient aliases</span><small>One alias per line</small><textarea name="aliases" form="ingredient-identity-form" rows="4">{aliases.map(x=>x.name).join("\n")}</textarea></label></div>:<><details open><summary>Recipes On <small>{usedIn.length}</small></summary>{usedIn.length?<ul>{usedIn.map(x=><li><a href={`/app/recipes/${x.id}/`}>{x.title}</a></li>)}</ul>:<p>This ingredient is not used by a recipe.</p>}</details>{ingredientTags.length>0&&<div class="ingredient-tag-view"><strong>Tags</strong><span>{ingredientTags.map(tag=><i>{tag}</i>)}</span></div>}{ingredient.description&&<div class="ingredient-description-view"><strong>Description</strong><p>{ingredient.description}</p></div>}<details><summary>Ingredient aliases <small>{aliases.length}</small></summary>{aliases.length?<ul>{aliases.map(x=><li>{x.name}</li>)}</ul>:<p>No aliases.</p>}</details></>}</section>
<BaseLayout title={editing ? `Edit ${titleCase(ingredient.name)}` : titleCase(ingredient.name)} immersive>
<section class="recipe-detail-shell recipe-read-shell ingredient-read-shell">
<DetailUtility section="Ingredients" sectionHref="/app/?type=ingredient">
<div class="entity-header-actions">
{editing ? (
<button class="primary-command" id="ingredient-done" type="button">✓ Done</button>
) : !readOnlyMode && (
<a class="edit-command" href={`/app/ingredients/${id}/?edit=1`}><svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>Edit</a>
)}
{editing && (
<details class="detail-actions-menu ingredient-actions-menu">
<summary aria-label="Ingredient actions">⋮</summary>
<div>
<form method="post" data-confirm-message="Merge this ingredient? This changes every recipe that uses it.">
<label><span>Merge into</span><select name="target_id" required><option value="">Select canonical ingredient</option>{mergeCandidates.map(candidate=><option value={candidate.id}>{candidate.name}</option>)}</select></label>
<button name="intent" value="merge">Merge ingredient</button>
</form>
</div>
</details>
)}
</div>
</DetailUtility>
<div class="recipe-read-left ingredient-read-left">
<header class="entity-detail-header">
<div>
<p class="entity-breadcrumb"><a href="/app/?type=ingredient">← Ingredients</a></p>
{editing ? (
<textarea class="editable-entity-title ingredient-title-editor" name="name" form="ingredient-identity-form" aria-label="Ingredient name" rows="1" required>{ingredient.name}</textarea>
) : (
<h1>{titleCase(ingredient.name)}</h1>
)}
</div>
</header>
{message && <div class="success-notice entity-notice">{message}</div>}
{error && <div class="notice entity-notice">{error}</div>}
<main class="ingredient-main-content entity-tab-panel active" data-panel="overview">
{editing && (
<form method="post" id="ingredient-identity-form" class="ingredient-identity-form ingredient-identity-data">
<input type="hidden" name="intent" value="identity"/>
<input type="hidden" name="status" value={ingredient.status}/>
<input type="hidden" id="ingredient-prep-json" name="prep_json" value="[]"/>
</form>
)}
<section id="prep" class="entity-section ingredient-prep-section">
<h2>Prep Actions</h2>
<p class="panel-intro">Any action taken on an ingredient that changes its yield or its weight-to-volume equivalency from the original raw state.</p>
{editing ? (
<>
<button id="add-prep-row" class="outlined-add-action" type="button"> Add Prep Action</button>
<div class="prep-edit-wrap">
<table class="prep-action-table prep-edit-table">
<thead>
<tr>
<th style="width:35%;">Prep Action</th>
<th style="width:18%;">Yield %</th>
<th style="width:15%;">Weight</th>
<th style="width:15%;">Volume</th>
<th style="width:12%;">Each</th>
<th style="width:5%;"></th>
</tr>
</thead>
<tbody id="ingredient-prep-rows" data-actions={JSON.stringify(actions)}>
{prepDisplay.map(x => (
<tr class="prep-edit-row">
<td>
<select class="prep-row-action" aria-label="Prep action">
{actions.map(action => (
<option value={action.id} selected={action.id === x.action_id}>{action.name}</option>
))}
</select>
<input class="prep-row-notes" value={x.notes ?? ""} placeholder="Optional notes" aria-label="Prep action notes"/>
</td>
<td>
<span class="percent-input">
<input class="prep-row-yield" type="number" min="0.01" step="0.01" required value={x.yield_factor * 100} aria-label="Yield percent"/>
<i>%</i>
</span>
</td>
{[x.weight, x.volume, x.each].map(value => (
<td>
<button class="prep-equivalency-link" type="button" title="Edit this prep action's UoM equivalency">{value === "—" ? "Set" : value}</button>
</td>
))}
<td>
<button class="prep-row-remove" type="button" aria-label={`Remove ${x.name}`}>×</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p class="field-help">Use 100% for no change, 80% for trim or cooking loss, or 250% when cooking produces 2.5 times the original weight. Select Weight, Volume, or Each to define its equivalency.</p>
</>
) : prep.length ? (
<table class="prep-action-table">
<thead>
<tr>
<th style="width:40%;">Prep Action</th>
<th style="width:20%;">Yield %</th>
<th style="width:15%;">Weight</th>
<th style="width:15%;">Volume</th>
<th style="width:10%;">Each</th>
</tr>
</thead>
<tbody>
{prepDisplay.map(x => (
<tr>
<td><strong>{x.name}</strong>{x.notes && <small>{x.notes}</small>}</td>
<td>{number(x.yield_factor * 100)}%</td>
<td>{x.weight}</td>
<td>{x.volume}</td>
<td>{x.each}</td>
</tr>
))}
</tbody>
</table>
) : (
<p class="empty-copy">This ingredient currently has no prep actions. Edit ingredient to add prep actions.</p>
)}
</section>
<section id="usage" class:list={["entity-section", "additional-card", { "ingredient-additional-edit": editing }]}>
<h2>Additional Details</h2>
{editing ? (
<div class="ingredient-detail-fields">
<label>
<span>Tags</span>
<input id="ingredient-tags" name="tags" form="ingredient-identity-form" value={ingredientTags.join(", ")} placeholder="Tag Name"/>
</label>
<label>
<span>Description</span>
<textarea name="description" form="ingredient-identity-form" rows="4" placeholder="Write your ingredient description">{ingredient.description ?? ""}</textarea>
</label>
<label>
<span>Ingredient aliases</span>
<small>One alias per line</small>
<textarea name="aliases" form="ingredient-identity-form" rows="4">{aliases.map(x => x.name).join("\n")}</textarea>
</label>
</div>
) : (
<>
<details open>
<summary>Recipes On <small>{usedIn.length}</small></summary>
{usedIn.length ? <ul>{usedIn.map(x => <li><a href={`/app/recipes/${x.id}/`}>{x.title}</a></li>)}</ul> : <p>This ingredient is not used by a recipe.</p>}
</details>
{ingredientTags.length > 0 && (
<div class="ingredient-tag-view">
<strong>Tags</strong>
<span>{ingredientTags.map(tag => <i>{tag}</i>)}</span>
</div>
)}
{ingredient.description && (
<div class="ingredient-description-view">
<strong>Description</strong>
<p>{ingredient.description}</p>
</div>
)}
<details>
<summary>Ingredient aliases <small>{aliases.length}</small></summary>
{aliases.length ? <ul>{aliases.map(x => <li>{x.name}</li>)}</ul> : <p>No aliases.</p>}
</details>
</>
)}
</section>
</main>
<aside class="entity-secondary">
<nav class="entity-tabs" aria-label="Ingredient details"><button class="active" type="button" data-tab="costs">$ <span>Cost</span></button><button type="button" data-tab="equivalencies">⚖ <span>UoM Equivalency</span></button><button type="button" data-tab="nutrition">♡ <span>Nutrition</span></button></nav>
<section id="costs" class="workspace-panel entity-tab-panel active" data-panel="costs"><h2>Ingredient Cost</h2><p class="panel-intro">Purchase packages, usable yield, and current prices.</p>{editing?<div class="ingredient-cost-edit">{purchases.map(x=><section class="ingredient-purchase-form"><header><h3>{x.name}</h3><form method="post" data-confirm-message="Remove this purchase item and its price history?"><input type="hidden" name="purchase_item_id" value={x.id}/><button name="intent" value="delete_purchase_item">Remove</button></form></header><PurchaseItemForm item={x} units={units}/></section>)}<details class="purchase-editor add-purchase" open={purchases.length===0}><summary> Add purchase item</summary><PurchaseItemForm units={units}/></details></div>:purchases.length?purchases.map(x=><div class="ingredient-cost-row"><span><strong>{x.name}</strong><small>{x.supplier_id||"No supplier"} · {x.status}</small></span><span><strong>{x.latest_price!=null?`${x.latest_currency} ${Number(x.latest_price).toFixed(2)}`:"No price"}</strong><small>{number(x.package_quantity)} {unitById.get(x.package_unit_id)?.symbol??x.package_unit_id}{x.units_per_case>1?` × ${x.units_per_case}`:""}</small></span></div>):<p class="empty-copy">No purchase cost has been entered.</p>}</section>
<section id="equivalencies" class="workspace-panel entity-tab-panel" data-panel="equivalencies"><h2>UoM Equivalency</h2><p class="panel-intro">Define equivalent weight, volume, or count measurements for this ingredient.</p><div class="equivalency-list">{densities.map(x=><p><strong>{number(x.volume_quantity)} {unitById.get(x.volume_unit_id)?.symbol??x.volume_unit_id}</strong><span>=</span><strong>{number(x.mass_quantity)} {unitById.get(x.mass_unit_id)?.symbol??x.mass_unit_id}</strong><small>{x.state||"Density"} · sourced density</small></p>)}{conversionRows.map(x=><div class="equivalency-row"><p><strong>{number(x.from_quantity)} {unitById.get(x.from_unit_id)?.symbol??x.from_unit_id}</strong><span>=</span><strong>{number(x.to_quantity)} {unitById.get(x.to_unit_id)?.symbol??x.to_unit_id}</strong><small>{x.state||"Conversion"} · {x.isManual?"manual":x.source.title||"sourced"}</small></p>{editing&&x.isManual&&<div class="equivalency-actions"><button type="button" class="edit-equivalency" data-id={x.id} data-from-quantity={x.from_quantity} data-from-unit={x.from_unit_id} data-to-quantity={x.to_quantity} data-to-unit={x.to_unit_id} data-state={x.state??""}>Edit</button><form method="post" data-confirm-message="Remove this equivalency?"><input type="hidden" name="conversion_id" value={x.id}/><button name="intent" value="delete_conversion">Delete</button></form></div>}</div>)}</div>{!densities.length&&!conversions.length&&<p class="empty-copy">No equivalencies have been defined.</p>}{editing&&<details class="inline-editor equivalency-editor"><summary> Add Equivalency</summary><form method="post" class="inline-form"><input type="hidden" name="conversion_id" value=""/><label><span>From amount</span><input name="from_quantity" type="number" min="0.0001" step="any" value="1"/></label><label><span>From unit</span><select name="from_unit_id">{units.map(x=><option value={x.id}>{x.name} ({x.symbol})</option>)}</select></label><i>=</i><label><span>To amount</span><input name="to_quantity" type="number" min="0.0001" step="any" required/></label><label><span>To unit</span><select name="to_unit_id">{units.map(x=><option value={x.id}>{x.name} ({x.symbol})</option>)}</select></label><label><span>Preparation state</span><input name="state" placeholder="e.g. chopped"/></label><button name="intent" value="conversion">Add</button><button type="button" class="cancel-equivalency" hidden>Cancel</button></form></details>}</section>
<section id="nutrition" class="workspace-panel entity-tab-panel" data-panel="nutrition"><h2>Nutrition</h2><p class="panel-intro">Nutrition values are sourced from the ingredient's mapped USDA FoodData Central record.</p>{editing&&<form method="post" class="usda-id-form"><label><span>USDA FoodData Central ID</span><input name="fdc_id" inputmode="numeric" pattern="[0-9]+" required value={usdaMapping?.source.external_id ?? ""}/></label><button name="intent" value="usda_mapping">Update from USDA</button>{usdaMapping?.sourceUrl&&<a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">Open USDA ↗</a>}</form>}{usdaMapping?<article class="ingredient-nutrition"><div class="nutrition-mapping-head"><div><strong>{usdaMapping.source.title}</strong><small>{usdaMapping.status} · USDA FoodData Central</small></div>{usdaMapping.sourceUrl&&<a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">View source ↗</a>}</div>{usdaMapping.nutrients.length?<dl>{usdaMapping.nutrients.map((entry:[string,number])=>{const[key,value]=entry;const[label,unit]=nutrientLabels[key]??[key.replaceAll("_"," "),""];return <div><dt>{label}</dt><dd>{number(value)} {unit}</dd></div>})}</dl>:<p class="notice">No reviewed nutrient values stored.</p>}<footer>{usdaMapping.source.external_id&&<span>Record {usdaMapping.source.external_id}</span>}{usdaMapping.source.retrieved_at&&<span>Retrieved {usdaMapping.source.retrieved_at}</span>}</footer></article>:<p class="empty-copy">No USDA record mapped.{editing&&" Enter an FDC ID above."}</p>}</section>
</aside>
</div>
<div class="recipe-read-right ingredient-read-right">
<div class="recipe-workspace-tabs recipe-read-tabs entity-tabs" aria-label="Ingredient details">
<button class="mobile-only-tab active" type="button" data-tab="overview"><Fragment set:html={recipeTabIcon("overview")}/><span class="recipe-tab-label">Details</span></button>
<button type="button" data-tab="costs"><Fragment set:html={recipeTabIcon("costing")}/><span class="recipe-tab-label">Cost</span></button>
<button type="button" data-tab="equivalencies"><Fragment set:html={recipeTabIcon("equivalencies")}/><span class="recipe-tab-label">UoM Equivalency</span></button>
<button type="button" data-tab="nutrition"><Fragment set:html={recipeTabIcon("nutrition")}/><span class="recipe-tab-label">Nutrition</span></button>
</div>
<div class="recipe-view-details ingredient-view-details">
<section id="costs" class="workspace-panel entity-tab-panel active" data-panel="costs">
<h2>Ingredient Cost</h2>
<p class="panel-intro">Purchase packages, usable yield, and current prices.</p>
{editing ? (
<div class="ingredient-cost-edit">
<details class="purchase-editor add-purchase" open={purchases.length === 0}>
<summary> Add purchase item</summary>
<PurchaseItemForm units={units}/>
</details>
{purchases.map(x => (
<section class="ingredient-purchase-form">
<header>
<h3>{x.name}</h3>
<form method="post" data-confirm-message="Remove this purchase item and its price history?">
<input type="hidden" name="purchase_item_id" value={x.id}/>
<button name="intent" value="delete_purchase_item">Remove</button>
</form>
</header>
<PurchaseItemForm item={x} units={units}/>
</section>
))}
</div>
) : purchases.length ? (
purchases.map(x => (
<div class="ingredient-cost-row">
<span>
<strong>{x.name}</strong>
<small>{x.supplier_id || "No supplier"} · {x.status}</small>
</span>
<span>
<strong>{x.latest_price != null ? `${x.latest_currency} ${Number(x.latest_price).toFixed(2)}` : "No price"}</strong>
<small>{number(x.package_quantity)} {unitById.get(x.package_unit_id)?.symbol ?? x.package_unit_id}{x.units_per_case > 1 ? ` × ${x.units_per_case}` : ""}</small>
</span>
</div>
))
) : (
<p class="empty-copy">No purchase cost has been entered.</p>
)}
</section>
<section id="equivalencies" class="workspace-panel entity-tab-panel" data-panel="equivalencies">
<h2>U of M Equivalency</h2>
<p class="panel-intro"><strong>XX Weight = XX Volume = XX Each</strong><br>You can define a custom ingredient conversion from weight to volume and to a pc/each of the ingredient.</p>
<div class="equivalency-list">
{densities.map(x => (
<p>
<strong>{number(x.volume_quantity)} {unitById.get(x.volume_unit_id)?.symbol ?? x.volume_unit_id}</strong>
<span>=</span>
<strong>{number(x.mass_quantity)} {unitById.get(x.mass_unit_id)?.symbol ?? x.mass_unit_id}</strong>
<small>{x.state || "Density"} · sourced density</small>
</p>
))}
{conversionRows.map(x => (
<div class="equivalency-row">
<p>
<strong>{number(x.from_quantity)} {unitById.get(x.from_unit_id)?.symbol ?? x.from_unit_id}</strong>
<span>=</span>
<strong>{number(x.to_quantity)} {unitById.get(x.to_unit_id)?.symbol ?? x.to_unit_id}</strong>
<small>{x.state || "Conversion"} · {x.isManual ? "manual" : x.source.title || "sourced"}</small>
</p>
{editing && x.isManual && (
<div class="equivalency-actions">
<button type="button" class="edit-equivalency" data-id={x.id} data-from-quantity={x.from_quantity} data-from-unit={x.from_unit_id} data-to-quantity={x.to_quantity} data-to-unit={x.to_unit_id} data-state={x.state ?? ""}>Edit</button>
<form method="post" data-confirm-message="Remove this equivalency?">
<input type="hidden" name="conversion_id" value={x.id}/>
<button name="intent" value="delete_conversion">Delete</button>
</form>
</div>
)}
</div>
))}
</div>
{!densities.length && !conversions.length && <p class="empty-copy">No equivalencies have been defined.</p>}
{editing && (
<details class="inline-editor equivalency-editor">
<summary> Add Equivalency</summary>
<form method="post" class="inline-form">
<input type="hidden" name="conversion_id" value=""/>
<label><span>From amount</span><input name="from_quantity" type="number" min="0.0001" step="any" value="1"/></label>
<label><span>From unit</span><select name="from_unit_id">{units.map(x => <option value={x.id}>{x.name} ({x.symbol})</option>)}</select></label>
<i>=</i>
<label><span>To amount</span><input name="to_quantity" type="number" min="0.0001" step="any" required/></label>
<label><span>To unit</span><select name="to_unit_id">{units.map(x => <option value={x.id}>{x.name} ({x.symbol})</option>)}</select></label>
<label><span>Preparation state</span><input name="state" placeholder="e.g. chopped"/></label>
<button name="intent" value="conversion">Add</button>
<button type="button" class="cancel-equivalency" hidden>Cancel</button>
</form>
</details>
)}
</section>
<section id="nutrition" class="workspace-panel entity-tab-panel" data-panel="nutrition">
<h2>Nutrition</h2>
<p class="panel-intro">Nutrition values are sourced from the ingredient's mapped USDA FoodData Central record.</p>
{editing && (
<form method="post" class="usda-id-form">
<label>
<span>USDA FoodData Central ID</span>
<input name="fdc_id" inputmode="numeric" pattern="[0-9]+" required value={usdaMapping?.source.external_id ?? ""}/>
</label>
<button name="intent" value="usda_mapping">Update from USDA</button>
{usdaMapping?.sourceUrl && <a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">Open USDA ↗</a>}
</form>
)}
{usdaMapping ? (
<article class="ingredient-nutrition">
<div class="nutrition-mapping-head">
<div>
<strong>{usdaMapping.source.title}</strong>
<small>{usdaMapping.status} · USDA FoodData Central</small>
</div>
{usdaMapping.sourceUrl && <a href={usdaMapping.sourceUrl} target="_blank" rel="noreferrer">View source ↗</a>}
</div>
{usdaMapping.nutrients.length ? (
<dl>
{usdaMapping.nutrients.map((entry: [string, number]) => {
const [key, value] = entry;
const [label, unit] = nutrientLabels[key] ?? [key.replaceAll("_", " "), ""];
return <div><dt>{label}</dt><dd>{number(value)} {unit}</dd></div>;
})}
</dl>
) : (
<p class="notice">No reviewed nutrient values stored.</p>
)}
<footer>
{usdaMapping.source.external_id && <span>Record {usdaMapping.source.external_id}</span>}
{usdaMapping.source.retrieved_at && <span>Retrieved {usdaMapping.source.retrieved_at}</span>}
</footer>
</article>
) : (
<p class="empty-copy">No USDA record mapped.{editing && " Enter an FDC ID above."}</p>
)}
</section>
</div>
</div>
</section>
{editing&&<script is:inline>
{editing && <script is:inline>
const ingredientForm=document.querySelector('#ingredient-identity-form'),doneButton=document.querySelector('#ingredient-done'),prepRows=document.querySelector('#ingredient-prep-rows'),prepJson=document.querySelector('#ingredient-prep-json');
let ingredientDirty=false,ingredientSubmitting=false;
const setIngredientDirty=(value=true)=>{ingredientDirty=value;doneButton?.classList.toggle('dirty',value)};
@@ -185,7 +489,25 @@ database.close();
const rows=[...prepRows.querySelectorAll('.prep-edit-row')],values=rows.map(row=>({action_id:row.querySelector('.prep-row-action').value,yield_percent:Number(row.querySelector('.prep-row-yield').value),notes:row.querySelector('.prep-row-notes').value.trim()})),ids=values.map(row=>row.action_id);
if(new Set(ids).size!==ids.length){alert('Each prep action can only appear once.');return}if(values.some(row=>!Number.isFinite(row.yield_percent)||row.yield_percent<=0)){alert('Each prep yield must be greater than 0%.');return}prepJson.value=JSON.stringify(values);if(!ingredientForm.reportValidity())return;doneButton.disabled=true;doneButton.textContent='Saving…';ingredientForm.requestSubmit();
});
window.addEventListener('beforeunload',event=>{if(ingredientDirty&&!ingredientSubmitting)event.preventDefault()});
</script>}
<script is:inline>document.querySelectorAll('form[data-confirm-message]').forEach(form=>form.addEventListener('submit',event=>{if(!confirm(form.dataset.confirmMessage))event.preventDefault()}));document.querySelectorAll('.entity-tabs button').forEach((button)=>button.addEventListener('click',()=>{document.querySelectorAll('.entity-tabs button').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.entity-tab-panel').forEach(x=>x.classList.remove('active'));button.classList.add('active');document.querySelector(`[data-panel="${button.dataset.tab}"]`)?.classList.add('active');}));const hash=location.hash.slice(1);if(hash)document.querySelector(`[data-tab="${hash}"]`)?.click();</script>
window.addEventListener('beforeunload',event=>{if(ingredientDirty&&!ingredientSubmitting)event.preventDefault()});
</script>
}
<script is:inline>
document.querySelectorAll('form[data-confirm-message]').forEach(form=>form.addEventListener('submit',event=>{if(!confirm(form.dataset.confirmMessage))event.preventDefault()}));
function switchIngredientTab(tabName) {
document.querySelectorAll('.entity-tabs button').forEach(x=>x.classList.toggle('active', x.dataset.tab === tabName));
document.querySelectorAll('.entity-tab-panel').forEach(x=>x.classList.toggle('active', x.dataset.panel === tabName));
window.scrollTo({ top: 0, behavior: 'instant' });
}
document.querySelectorAll('.entity-tabs button').forEach((button)=>button.addEventListener('click',()=>{
switchIngredientTab(button.dataset.tab);
}));
const hash=location.hash.slice(1);
if(hash) {
const target = document.querySelector(`[data-tab="${hash}"]`);
if (target) target.click();
} else if (window.innerWidth <= 900) {
switchIngredientTab('overview');
}
</script>
</BaseLayout>
+282 -32
View File
@@ -1,7 +1,7 @@
---
export const prerender = false;
import BaseLayout from "../../../../layouts/BaseLayout.astro";
import { databaseProjection, duplicateRecipe, editableRecipe, openDatabase, refreshSiteProjection, saveRecipeMetadata } from "../../../../lib/database";
import { databaseProjection, editableRecipe, openDatabase, refreshSiteProjection, saveRecipeMetadata } from "../../../../lib/database";
import { recipeStructure } from "../../../../lib/database";
import RecipeStructureEditor from "../../../../components/RecipeStructureEditor";
import RecipeCalculator, { LiveCostValues, LiveNutritionValues } from "../../../../components/RecipeCalculator";
@@ -21,16 +21,12 @@ let error: string | undefined;
if (Astro.request.method === "POST") {
try {
const form = await Astro.request.formData();
if(form.get("intent")==="media"){
const url=String(form.get("url")??"").trim(),mediaType=String(form.get("media_type")??"image"),stepId=String(form.get("step_id")??"").trim()||null;if(!url)throw new Error("Media URL is required.");if(!["image","video"].includes(mediaType))throw new Error("Invalid media type.");
const position=(database.prepare("SELECT coalesce(max(position),0)+1 position FROM recipe_media WHERE recipe_id=?").get(id) as any).position;database.prepare("INSERT INTO recipe_media(recipe_id,id,step_id,media_type,url,caption,position) VALUES (?,?,?,?,?,?,?)").run(id,`media_${Date.now()}`,stepId,mediaType,url,String(form.get("caption")??"").trim()||null,position);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1#additional`,303);
}
if(form.get("intent")==="additional"){
const row=database.prepare("SELECT source_json FROM recipes WHERE id=?").get(id) as any,source=JSON.parse(row.source_json??"{}");
const shelfQuantity=Number(form.get("shelf_quantity")),shelfUnit=String(form.get("shelf_unit")??"").trim();
if(shelfQuantity>0&&shelfUnit)source.shelf_life={duration:{quantity:shelfQuantity,unit_id:shelfUnit},storage_condition:String(form.get("storage_condition")??"").trim()||undefined};else delete source.shelf_life;
const notes=String(form.get("notes")??"").split("\n").map(value=>value.trim()).filter(Boolean);
database.prepare("UPDATE recipes SET station=?,cover_media_url=?,notes_json=?,source_json=? WHERE id=?").run(String(form.get("station")??"").trim()||null,String(form.get("cover_media_url")??"").trim()||null,JSON.stringify(notes),JSON.stringify(source),id);
database.prepare("UPDATE recipes SET station=?,notes_json=?,source_json=? WHERE id=?").run(String(form.get("station")??"").trim()||null,JSON.stringify(notes),JSON.stringify(source),id);
refreshSiteProjection(database);database.close();return Astro.redirect(`/app/recipes/${id}/?edit=1#additional`,303);
}
if (form.get("intent") === "duplicate") {
@@ -117,9 +113,8 @@ const ingredientOptions = (database.prepare("SELECT id, name FROM ingredients WH
const recipeOptions = database.prepare("SELECT id, title AS name FROM recipes WHERE deleted_at IS NULL ORDER BY title").all() as Array<{ id: string; name: string }>;
const prepActionOptions = database.prepare("SELECT id, name FROM prep_actions ORDER BY name").all() as Array<{ id: string; name: string }>;
const recipeConversions=database.prepare("SELECT * FROM recipe_measure_conversions WHERE recipe_id=? ORDER BY id").all(id) as any[];
const additional=database.prepare("SELECT station,cover_media_url,notes_json,source_json FROM recipes WHERE id=?").get(id) as any;
const additional=database.prepare("SELECT station,notes_json,source_json FROM recipes WHERE id=?").get(id) as any;
const additionalSource=JSON.parse(additional.source_json??"{}"),shelfLife=additionalSource.shelf_life;
const media=database.prepare("SELECT * FROM recipe_media WHERE recipe_id=? ORDER BY position").all(id) as any[];
const projection = databaseProjection(database) as { recipes:Recipe[]; ingredients:Ingredient[]; units:Unit[]; sourceMappings:SourceMapping[]; purchaseItems:PurchaseItem[]; prepActions:PrepAction[] };
const domainRecipe = projection.recipes.find((entry) => entry.id === id)!;
const recipeMap = new Map(projection.recipes.map((entry) => [entry.id, entry]));
@@ -149,42 +144,297 @@ database.close();
const categories = JSON.parse(recipe.categories_json).join(", ");
const tags = JSON.parse(recipe.tags_json).join(", ");
const saved = Astro.url.searchParams.get("saved") === "1";
const RECIPE_TAB_ICONS={
formula:"M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z",
method:"M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5m0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5m0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5M7 19h14v-2H7zm0-6h14v-2H7zm0-8v2h14V5z",
costing:"M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4",
equivalencies:"M19.4 3.3h-6.6v-.5c0-.4-.3-.7-.8-.7-.4 0-.8.3-.8.7v.5H4.6L0 14s.2 3.8 4.7 3.8S9.4 14 9.4 14L6.1 6.2h5.1v15.7h1.5V6.2h5.1L14.6 14s.2 3.8 4.7 3.8S24 14 24 14L19.4 3.3zM7.7 14H1.5l3.1-7.4L7.7 14zm8.5 0l3.1-7.4 3.1 7.4h-6.2z",
nutrition:"M9.42859 2.37431L9.80926 2.82134L10.1899 2.37431C11.1674 1.22652 12.668 0.5 14.2234 0.5C16.9685 0.5 19.1185 2.64998 19.1185 5.3951C19.1185 7.0848 18.3631 8.65707 16.9325 10.4062C15.4961 12.1623 13.4317 14.0352 10.8957 16.3348L10.895 16.3354L9.80799 17.325L8.72319 16.345L8.72211 16.344L8.71119 16.3341C6.18062 14.0344 4.12043 12.1623 2.68618 10.4075C1.25541 8.6571 0.5 7.08481 0.5 5.3951C0.5 2.64998 2.64998 0.5 5.3951 0.5C6.95051 0.5 8.45117 1.22652 9.42859 2.37431ZM3.70568 10.127C5.0829 11.7363 7.04455 13.5134 9.36637 15.6157L9.45571 15.7051L9.80926 16.0586L10.1628 15.7051L10.2522 15.6157C12.574 13.5134 14.5356 11.7363 15.9128 10.127C17.287 8.52131 18.1567 6.99709 18.1567 5.3951C18.1567 3.1571 16.4614 1.46185 14.2234 1.46185C12.6415 1.46185 11.0895 2.39876 10.4049 3.77684H9.22149C8.52967 2.40009 6.97866 1.46185 5.3951 1.46185C3.1571 1.46185 1.46185 3.1571 1.46185 5.3951C1.46185 6.99709 2.3315 8.52131 3.70568 10.127Z"
};
const RECIPE_TAB_VIEWBOX={formula:"0 0 14 16",method:"0 0 24 24",costing:"0 0 24 24",equivalencies:"0 0 24 24",nutrition:"0 0 20 18"};
const recipeTabIcon=(name:string)=>`<span class="recipe-tab-icon"><svg viewBox="${RECIPE_TAB_VIEWBOX[name as keyof typeof RECIPE_TAB_VIEWBOX]}" aria-hidden="true"><path fill="currentColor" d="${RECIPE_TAB_ICONS[name as keyof typeof RECIPE_TAB_ICONS]}"/></svg></span>`;
---
<BaseLayout title={editing?`Edit ${recipe.title}`:recipe.title} immersive>
<section class="recipe-detail-shell">
<DetailUtility section="Recipes" sectionHref="/app/?type=recipe" />
<header class="entity-detail-header"><div><p class="entity-breadcrumb"><a href="/app/?type=recipe">← Recipes</a></p>{editing?<input class="editable-entity-title" name="title" value={recipe.title} form="recipe-details-form" aria-label="Recipe name" required/>:<h1>{recipe.title}</h1>}</div>{!readOnlyMode&&<div class="entity-header-actions">{editing?<button class="primary-command" id="recipe-done" type="button" data-view-url={`/app/recipes/${id}/`}>✓ Done</button>:<a class="edit-command" href={`/app/recipes/${id}/?edit=1`}>✎ Edit</a>}<details class="detail-actions-menu"><summary aria-label="Recipe actions">⋮</summary><div><form method="post"><button name="intent" value="duplicate">Duplicate recipe</button></form></div></details></div>}</header>
<div class="recipe-workspace-tabs">{editing?<><button class="active" type="button" data-edit-recipe-tab="method">☷ Prep Method</button><button type="button" data-edit-recipe-tab="costing">$ Cost</button><button type="button" data-edit-recipe-tab="equivalencies">⚖ UoM Equivalency</button><button type="button" data-edit-recipe-tab="nutrition">♡ Nutrition</button></>:<><button class="active" type="button" data-recipe-tab="method">☷ Prep Method</button><button type="button" data-recipe-tab="costing">$ Cost</button><button type="button" data-recipe-tab="equivalencies">⚖ UoM Equivalency</button><button type="button" data-recipe-tab="nutrition">♡ Nutrition</button></>}</div>
{editing?<><section class="recipe-overview-strip">
<form method="post" id="recipe-details-form" class="editor-form recipe-overview-form">
<input type="hidden" name="save_version" value={recipe.save_version} />
{saved && <div class="success-notice">Changes saved.</div>}
{error && <div class="notice">{error}</div>}
<div class:list={["inline-yield-editor",{"auto-calculated":autoYield}]}><span>Finished Yield</span><label><input name="yield_quantity" type="number" min="0.0001" step="any" required value={recipe.yield_quantity} placeholder="Qty" aria-label="Finished yield quantity" readonly={autoYield}/></label><label><select name="yield_unit_id" aria-label="Finished yield unit" aria-disabled={autoYield}>{units.map((unit) => <option value={unit.id} selected={recipe.yield_unit_id === unit.id}>{unit.symbol}</option>)}</select></label><input type="hidden" name="yield_servings" value={recipe.yield_servings??""}/><input type="hidden" name="yield_basis" value={recipe.yield_basis??""}/>{autoYield&&<small>Calculated from convertible ingredient quantities</small>}</div>
<input type="hidden" name="summary" value={recipe.summary??""}/><input type="hidden" name="categories" value={categories}/><input type="hidden" name="tags" value={tags}/>
</form>
<form method="post" class:list={["inline-auto-yield",{active:autoYield}]}><input type="hidden" name="intent" value="auto_yield"/><button class="toggle-button" aria-label={`${autoYield?"Disable":"Enable"} automatic total yield`}><i></i></button><span>Auto calculate total yield</span>{autoYield&&<small><b>Revert</b> to original and disable auto calculate</small>}</form>
</section>
<section id="structure" class="recipe-structure-workspace unified-recipe-editor"><RecipeStructureEditor client:load recipeId={recipe.id} initial={structure} ingredients={ingredientOptions} recipes={recipeOptions} units={units} prepActions={prepActionOptions} weightRates={weightRates} autoYield={autoYield} showMethod={true}/></section>
<section id="costing" class="recipe-edit-tab-panel" data-edit-recipe-panel="costing"><LiveCostValues client:load cost={cost} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnit={unitMap.get(domainRecipe.yield.amount.unit_id)?.symbol??domainRecipe.yield.amount.unit_id} editable/></section>
<section id="equivalencies" class="recipe-equivalence-editor recipe-edit-tab-panel" data-edit-recipe-panel="equivalencies"><h2>UoM Equivalency</h2><p>Define how this finished recipe converts between weight, volume, and portions.</p>{recipeConversions.map(x=><div><strong>{x.from_quantity} {x.from_unit_id}</strong><span>=</span><strong>{x.to_quantity} {x.to_unit_id}</strong><small>{x.notes}</small></div>)}<form method="post"><input name="from_quantity" type="number" min="0.0001" step="any" value="1"/><select name="from_unit_id">{units.map(x=><option value={x.id}>{x.name}</option>)}</select><span>=</span><input name="to_quantity" type="number" min="0.0001" step="any"/><select name="to_unit_id">{units.map(x=><option value={x.id}>{x.name}</option>)}</select><input name="notes" placeholder="Notes"/><button name="intent" value="conversion">Add equivalency</button></form></section>
<section id="nutrition" class="recipe-edit-nutrition recipe-edit-tab-panel" data-edit-recipe-panel="nutrition"><LiveNutritionValues client:load nutrition={nutrition} servings={domainRecipe.yield.servings} ingredients={nutritionIngredients} editable saveVersion={recipe.save_version}/></section>
<section id="additional" class="recipe-additional-editor"><h2>Additional Details</h2><form id="recipe-additional-form"><label class="cover-media-field"><span>{additional.cover_media_url?"Replace Cover Image":"Add Cover Image"}</span>{additional.cover_media_url&&<img src={additional.cover_media_url} alt=""/>}<input form="recipe-additional-form" name="cover_media_url" type="url" value={additional.cover_media_url??""} placeholder="Paste image URL"/></label><fieldset><legend>Shelf Life</legend><input name="shelf_quantity" type="number" min="0" step="any" value={shelfLife?.duration?.quantity??""} placeholder="Qty"/><select name="shelf_unit"><option value="">Unit</option>{["hour","day","week","month"].map(unit=><option value={unit} selected={shelfLife?.duration?.unit_id===unit}>{unit}</option>)}</select><input name="storage_condition" value={shelfLife?.storage_condition??""} placeholder="Storage condition"/></fieldset><label><span>Station</span><input name="station" value={additional.station??""} placeholder="Station Name"/></label><label><span>Tags</span><input name="tags" value={tags} placeholder="Tag Name"/></label></form></section>
</>:<><section id="structure" class="recipe-view-workspace"><div class="recipe-view-formula"><RecipeCalculator client:load components={calculatorComponents} units={Object.fromEntries(unitMap)} basisUnitId={domainRecipe.scaling?.basis_amount?.unit_id??domainRecipe.yield.amount.unit_id} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnitId={domainRecipe.yield.amount.unit_id} yieldConversions={domainRecipe.measure_conversions??[]} mode={domainRecipe.scaling?.mode??"amount"} nutrition={nutrition} cost={cost} servings={domainRecipe.yield.servings} showDerived={false} showPercentControls={false}/></div><div class="recipe-view-details"><section class="recipe-view-method recipe-tab-panel active" data-recipe-panel="method"><h2>Prep Method <small>{domainRecipe.steps.length}</small></h2><ol>{domainRecipe.steps.map(step=><li class:list={{placeholder:step.instruction.startsWith("TODO:")}}><strong>{step.order}.</strong><span>{step.instruction}{media.filter(entry=>entry.step_id===step.id).map(entry=><figure class="step-media">{entry.media_type==="image"?<img src={entry.url} alt={entry.caption??""}/>:<video src={entry.url} controls/>}{entry.caption&&<figcaption>{entry.caption}</figcaption>}</figure>)}</span></li>)}</ol></section><section class="recipe-tab-panel recipe-view-equivalencies" data-recipe-panel="equivalencies"><h2>UoM Equivalency</h2>{recipeConversions.length?recipeConversions.map(x=><div><strong>{x.from_quantity} {x.from_unit_id}</strong><span>=</span><strong>{x.to_quantity} {x.to_unit_id}</strong><small>{x.notes}</small></div>):<p>No recipe-level equivalencies have been defined.</p>}</section><section id="costing" class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="costing"><LiveCostValues client:load cost={cost} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnit={unitMap.get(domainRecipe.yield.amount.unit_id)?.symbol??domainRecipe.yield.amount.unit_id}/></section><section id="nutrition" class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="nutrition"><LiveNutritionValues client:load nutrition={nutrition} servings={domainRecipe.yield.servings} ingredients={nutritionIngredients}/></section></div></section><section class="recipe-additional-view">{additional.cover_media_url&&<figure><img src={additional.cover_media_url} alt="" loading="lazy"/></figure>}<div><h2>Additional details</h2>{additional.station&&<p><strong>Station</strong><span>{additional.station}</span></p>}{shelfLife&&<p><strong>Shelf life</strong><span>{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}</span></p>}{JSON.parse(additional.notes_json??"[]").length>0&&<ul>{JSON.parse(additional.notes_json).map((note:string)=><li>{note}</li>)}</ul>}</div></section></>}
<section class="recipe-detail-shell recipe-read-shell">
<DetailUtility section="Recipes" sectionHref="/app/?type=recipe">
<div class="entity-header-actions">
{editing ? (
<button class="primary-command" id="recipe-done" type="button" data-view-url={`/app/recipes/${id}/`}>✓ Done</button>
) : !readOnlyMode && (
<a class="edit-command" href={`/app/recipes/${id}/?edit=1`}><svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>Edit</a>
)}
<details class="detail-actions-menu">
<summary aria-label="Recipe actions"><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg></summary>
<div><form method="post"><button name="intent" value="duplicate">Duplicate recipe</button></form></div>
</details>
</div>
</DetailUtility>
<div class="recipe-read-left">
<header class="entity-detail-header">
<div>
{editing ? (
<input class="editable-entity-title" name="title" value={recipe.title} form="recipe-details-form" aria-label="Recipe name" required/>
) : (
<h1>{recipe.title}</h1>
)}
</div>
</header>
{editing && (
<section class="recipe-overview-strip">
<form method="post" id="recipe-details-form" class="editor-form recipe-overview-form">
<input type="hidden" name="save_version" value={recipe.save_version} />
{saved && <div class="success-notice">Changes saved.</div>}
{error && <div class="notice">{error}</div>}
<div class:list={["inline-yield-editor",{"auto-calculated":autoYield}]}>
<span class="yield-title-label">Total Yield</span>
<div class="yield-inputs-row">
<label><input name="yield_quantity" type="number" min="0.0001" step="any" required value={recipe.yield_quantity} placeholder="Qty" aria-label="Finished yield quantity" readonly={autoYield}/></label>
<label><select name="yield_unit_id" aria-label="Finished yield unit" aria-disabled={autoYield}>{units.map((unit) => <option value={unit.id} selected={recipe.yield_unit_id === unit.id}>{unit.symbol}</option>)}</select></label>
</div>
<input type="hidden" name="yield_servings" value={recipe.yield_servings??""}/>
<input type="hidden" name="yield_basis" value={recipe.yield_basis??""}/>
</div>
<input type="hidden" name="summary" value={recipe.summary??""}/>
<input type="hidden" name="categories" value={categories}/>
<input type="hidden" name="tags" value={tags}/>
</form>
<form method="post" class:list={["inline-auto-yield",{active:autoYield}]}>
<input type="hidden" name="intent" value="auto_yield"/>
<button class="toggle-button" aria-label={`${autoYield?"Disable":"Enable"} automatic total yield`}><i></i></button>
<span>Auto calculate total yield</span>
</form>
</section>
)}
{editing ? (
<section id="structure" class="recipe-structure-workspace unified-recipe-editor">
<RecipeStructureEditor client:load recipeId={recipe.id} initial={structure} ingredients={ingredientOptions} recipes={recipeOptions} units={units} prepActions={prepActionOptions} weightRates={weightRates} autoYield={autoYield} showMethod={true}/>
</section>
) : (
<section class="recipe-view-formula-pane recipe-tab-panel active" data-recipe-panel="formula">
<div class="recipe-view-formula">
<RecipeCalculator client:load components={calculatorComponents} units={Object.fromEntries(unitMap)} basisUnitId={domainRecipe.scaling?.basis_amount?.unit_id??domainRecipe.yield.amount.unit_id} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnitId={domainRecipe.yield.amount.unit_id} yieldConversions={domainRecipe.measure_conversions??[]} mode={domainRecipe.scaling?.mode??"amount"} nutrition={nutrition} cost={cost} servings={domainRecipe.yield.servings} showDerived={false} showPercentControls={false}/>
</div>
</section>
)}
</div>
<div class="recipe-read-right">
<div class="recipe-workspace-tabs recipe-read-tabs">
<button class="mobile-only-tab active" type="button" data-recipe-tab="formula"><Fragment set:html={recipeTabIcon("formula")}/><span class="recipe-tab-label">Ingredients</span></button>
<button type="button" data-recipe-tab="method"><Fragment set:html={recipeTabIcon("method")}/><span class="recipe-tab-label">Prep Method</span></button>
<button type="button" data-recipe-tab="costing"><Fragment set:html={recipeTabIcon("costing")}/><span class="recipe-tab-label">Cost</span></button>
<button type="button" data-recipe-tab="equivalencies"><Fragment set:html={recipeTabIcon("equivalencies")}/><span class="recipe-tab-label">UoM Equivalency</span></button>
<button type="button" data-recipe-tab="nutrition"><Fragment set:html={recipeTabIcon("nutrition")}/><span class="recipe-tab-label">Nutrition</span></button>
</div>
<section class="recipe-view-details-pane">
<div class="recipe-view-details">
{editing ? (
<section class="recipe-tab-panel active" data-recipe-panel="method">
<div id="recipe-method-editor-slot"></div>
<section id="additional" class="recipe-additional-editor">
<h2>Additional details</h2>
<form id="recipe-additional-form">
<fieldset>
<legend>Shelf Life</legend>
<input name="shelf_quantity" type="number" min="0" step="any" value={shelfLife?.duration?.quantity??""} placeholder="Qty"/>
<select name="shelf_unit">
<option value="">Unit</option>
{["hour","day","week","month"].map(unit=><option value={unit} selected={shelfLife?.duration?.unit_id===unit}>{unit}</option>)}
</select>
<input name="storage_condition" value={shelfLife?.storage_condition??""} placeholder="Storage condition"/>
</fieldset>
<label><span>Station</span><input name="station" value={additional.station??""} placeholder="Station Name"/></label>
<label><span>Tags</span><input name="tags" value={tags} placeholder="Tag Name"/></label>
</form>
</section>
</section>
) : (
<section class="recipe-view-method recipe-tab-panel active" data-recipe-panel="method">
<h2>Prep Method <small>{domainRecipe.steps.length}</small></h2>
<ol>
{domainRecipe.steps.map(step=><li class:list={{placeholder:step.instruction.startsWith("TODO:")}}><strong>{step.order}.</strong><span>{step.instruction}</span></li>)}
</ol>
</section>
)}
<section class="recipe-tab-panel recipe-view-equivalencies" data-recipe-panel="equivalencies">
<h2>U of M Equivalency</h2>
<p class="uom-equation-title">XX Weight = XX Volume = XX Each</p>
<p class="uom-helper-text">
If you would like to use this recipe by weight, volume, and even by the portion - you can customize that here.
<button class="uom-help-btn" type="button" aria-label="Help on UoM Equivalency">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 16h-2v-2h2v2zm1.07-7.75l-.9.92C12.45 11.9 12 12.5 12 14h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H7c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.04-.42 1.99-1.07 2.75z"/>
</svg>
</button>
</p>
<div class="uom-toggle-row">
<label class="toggle-switch">
<input type="checkbox" id="standard-conversion-toggle" checked />
<i></i>
</label>
<div class="toggle-label-group">
<strong>Standard Weight - Volume Conversion</strong>
<small>When toggled on, conversions are locked to 8oz = 1 cup. Toggle off to customize.</small>
</div>
</div>
<div class="uom-equation-row">
<!-- Weight Group -->
<div class="uom-equation-group">
<span class="uom-group-label">Weight</span>
<div class="uom-box-pair">
<input
type="number"
id="uom-mass-qty"
name="mass_conversion"
class="uom-qty-input"
value={domainRecipe.yield.amount.quantity || 95}
placeholder="1"
step="any"
min="0.0001"
aria-label="Weight quantity"
/>
<select id="uom-mass-unit" name="mass_unit" class="uom-unit-select" aria-label="Weight unit">
{units.filter(u => u.dimension === "mass" || (!u.dimension && ["gram", "g", "oz", "ounce", "lb", "pound", "kg"].includes(u.id))).map(u => (
<option value={u.id} selected={u.id === (domainRecipe.yield.amount.unit_id || "gram") || u.id === "gram" || u.symbol === "g"}>
{u.symbol || u.name}
</option>
))}
</select>
</div>
</div>
<!-- Equals Sign -->
<div class="uom-equation-separator">=</div>
<!-- Volume Group -->
<div class="uom-equation-group">
<span class="uom-group-label">Volume</span>
<div class="uom-box-pair">
<input
type="number"
id="uom-volume-qty"
name="volume_conversion"
class="uom-qty-input"
value="1"
placeholder="1"
step="any"
min="0.0001"
aria-label="Volume quantity"
/>
<select id="uom-volume-unit" name="volume_unit" class="uom-unit-select" aria-label="Volume unit">
{units.filter(u => u.dimension === "volume" || (!u.dimension && ["cup", "fl_oz", "tbsp", "tsp", "ml", "liter", "l", "gallon", "quart", "pint"].includes(u.id))).map(u => (
<option value={u.id} selected={u.id === "cup" || u.symbol === "cup"}>
{u.symbol || u.name}
</option>
))}
</select>
</div>
</div>
<!-- Equals Sign -->
<div class="uom-equation-separator">=</div>
<!-- Each Group -->
<div class="uom-equation-group">
<span class="uom-group-label">Each</span>
<div class="uom-box-pair">
<input
type="number"
id="uom-container-qty"
name="container_conversion"
class="uom-qty-input"
value={domainRecipe.yield.servings || 1}
placeholder="1"
step="any"
min="0.0001"
aria-label="Each quantity"
/>
<select id="uom-container-unit" name="container_unit" class="uom-unit-select" aria-label="Each unit">
<option value="serving" selected>serving</option>
<option value="portion">portion</option>
<option value="each">each</option>
<option value="ea.">ea.</option>
{units.filter(u => u.dimension === "count").map(u => (
<option value={u.id}>{u.symbol || u.name}</option>
))}
</select>
</div>
</div>
</div>
{recipeConversions.length > 0 && (
<div class="uom-custom-conversions">
<h3>Custom Equivalencies</h3>
{recipeConversions.map(x => (
<div class="uom-custom-row">
<strong>{x.from_quantity} {unitMap.get(x.from_unit_id)?.symbol ?? x.from_unit_id}</strong>
<span>=</span>
<strong>{x.to_quantity} {unitMap.get(x.to_unit_id)?.symbol ?? x.to_unit_id}</strong>
{x.notes && <small>{x.notes}</small>}
</div>
))}
</div>
)}
</section>
<section class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="costing">
<LiveCostValues client:load cost={cost} yieldQuantity={domainRecipe.yield.amount.quantity} yieldUnit={unitMap.get(domainRecipe.yield.amount.unit_id)?.symbol??domainRecipe.yield.amount.unit_id} editable={editing}/>
</section>
<section class="recipe-derived-panel recipe-tab-panel" data-recipe-panel="nutrition">
<LiveNutritionValues client:load nutrition={nutrition} servings={domainRecipe.yield.servings} ingredients={nutritionIngredients} editable={editing} saveVersion={recipe.save_version}/>
</section>
</div>
</section>
{!editing && (
<section class="recipe-additional-view">
<div>
<h2>Additional details</h2>
{additional.station&&<p><strong>Station</strong><span>{additional.station}</span></p>}
{shelfLife&&<p><strong>Shelf life</strong><span>{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}</span></p>}
{JSON.parse(additional.notes_json??"[]").length>0&&<ul>{JSON.parse(additional.notes_json).map((note:string)=><li>{note}</li>)}</ul>}
</div>
</section>
)}
</div>
</section>
{!editing&&<script is:inline>document.querySelectorAll('[data-recipe-tab]').forEach(button=>button.addEventListener('click',()=>{document.querySelectorAll('[data-recipe-tab]').forEach(x=>x.classList.remove('active'));document.querySelectorAll('[data-recipe-panel]').forEach(x=>x.classList.remove('active'));button.classList.add('active');document.querySelector(`[data-recipe-panel="${button.dataset.recipeTab}"]`)?.classList.add('active');}));const recipeHash=location.hash.slice(1);if(recipeHash)document.querySelector(`[data-recipe-tab="${recipeHash}"]`)?.click();</script>}
<script is:inline>
function switchRecipeTab(tabName) {
document.querySelectorAll('[data-recipe-tab]').forEach(x=>x.classList.toggle('active', x.dataset.recipeTab === tabName));
document.querySelectorAll('[data-recipe-panel]').forEach(x=>x.classList.toggle('active', x.dataset.recipePanel === tabName));
window.scrollTo({ top: 0, behavior: 'instant' });
}
document.querySelectorAll('[data-recipe-tab]').forEach(button=>button.addEventListener('click',()=>{
switchRecipeTab(button.dataset.recipeTab);
}));
const recipeHash=location.hash.slice(1);
if(recipeHash) {
const targetTab = document.querySelector(`[data-recipe-tab="${recipeHash}"]`);
if (targetTab) {
targetTab.click();
}
} else if (window.innerWidth <= 900) {
switchRecipeTab('formula');
}
</script>
{editing&&<script is:inline>
let recipeDirty=false;
const setRecipeDirty=(value=true)=>{recipeDirty=value;document.querySelector('#recipe-done')?.classList.toggle('dirty',value)};
const setupTagEditor=()=>{const source=document.querySelector('#recipe-additional-form input[name="tags"]');if(!source||source.dataset.enhanced)return;source.dataset.enhanced='1';source.type='hidden';const editor=document.createElement('div'),chips=document.createElement('div'),entry=document.createElement('input');editor.className='tag-chip-editor';chips.className='tag-chip-list';entry.className='tag-chip-entry';entry.placeholder='Tag Name';let tags=source.value.split(',').map(value=>value.trim()).filter(Boolean);const render=()=>{chips.replaceChildren(...tags.map(tag=>{const chip=document.createElement('span'),label=document.createElement('b'),remove=document.createElement('button');label.textContent=tag;remove.type='button';remove.textContent='×';remove.ariaLabel=`Remove ${tag}`;remove.onclick=()=>{tags=tags.filter(value=>value!==tag);source.value=tags.join(', ');render();setRecipeDirty()};chip.append(label,remove);return chip}));source.value=tags.join(', ')};const add=()=>{const tag=entry.value.trim().replace(/^#+/,'');if(tag&&!tags.some(value=>value.toLowerCase()===tag.toLowerCase())){tags.push(tag);setRecipeDirty()}entry.value='';render()};entry.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===','){event.preventDefault();add()}else if(event.key==='Backspace'&&!entry.value&&tags.length){tags.pop();render();setRecipeDirty()}});entry.addEventListener('blur',add);source.after(editor);editor.append(chips,entry);render()};
const setupRecipeEditor=async()=>{const method=document.querySelector('.method-editor');if(!method||method.dataset.tabsReady)return;method.dataset.tabsReady='1';const coverSlot=method.querySelector('#recipe-cover-slot'),additionalSlot=method.querySelector('#recipe-additional-slot'),panelSlot=method.querySelector('#recipe-tab-panel-slot'),panels=[...document.querySelectorAll('[data-edit-recipe-panel]')],additional=document.querySelector('#additional'),cover=additional?.querySelector('.cover-media-field'),prepChildren=[method.querySelector(':scope > h2'),method.querySelector(':scope > ol'),method.querySelector(':scope > button')].filter(Boolean);for(let frame=0;frame<120&&panels.some(panel=>[...panel.querySelectorAll('astro-island')].some(island=>island.hasAttribute('ssr')));frame++)await new Promise(requestAnimationFrame);if(cover&&coverSlot)coverSlot.append(cover);panels.forEach(panel=>{panel.hidden=true;panelSlot?.append(panel)});if(additional&&additionalSlot)additionalSlot.append(additional);document.querySelectorAll('[data-edit-recipe-tab]').forEach(button=>button.addEventListener('click',()=>{const selected=button.dataset.editRecipeTab;document.querySelectorAll('[data-edit-recipe-tab]').forEach(tab=>tab.classList.toggle('active',tab===button));prepChildren.forEach(child=>child.hidden=selected!=='method');if(coverSlot)coverSlot.hidden=selected!=='method';if(additionalSlot)additionalSlot.hidden=selected!=='method';if(panelSlot)panelSlot.hidden=selected==='method';panels.forEach(panel=>panel.hidden=panel.dataset.editRecipePanel!==selected)}));};
document.addEventListener('recipe:editor-ready',setupRecipeEditor);
document.addEventListener('recipe:dirty',event=>setRecipeDirty(Boolean(event.detail)));
document.querySelector('#recipe-details-form')?.addEventListener('input',()=>setRecipeDirty());
document.querySelector('#recipe-additional-form')?.addEventListener('input',()=>setRecipeDirty());
document.querySelector('#recipe-details-form')?.addEventListener('submit',event=>event.preventDefault());
window.addEventListener('beforeunload',event=>{if(recipeDirty)event.preventDefault()});
setTimeout(()=>{setupRecipeEditor();setupTagEditor()},0);
setTimeout(()=>setupTagEditor(),0);
document.querySelector('#recipe-done')?.addEventListener('click',async(event)=>{const button=event.currentTarget;button.disabled=true;button.textContent='Saving…';const saved=await new Promise(resolve=>document.dispatchEvent(new CustomEvent('recipe:save-structure',{detail:{complete:resolve}})));if(saved){recipeDirty=false;location.href=button.dataset.viewUrl}else{button.disabled=false;button.textContent='✓ Done';}});
</script>}
</BaseLayout>
+36 -2
View File
@@ -20,9 +20,43 @@ const resolvedHref=sectionHref??inferred.href;
<li><a href="/app/">Home</a></li>
{resolvedSection&&<li aria-current="page"><a href={resolvedHref}>{resolvedSection}</a></li>}
</ol>
<slot />
</div>
<div class="detail-tools">
<form action="/app/" role="search"><input name="q" type="search" placeholder="Search" aria-label="Search all items"/></form>
{!readOnlyMode&&<details class="detail-new-menu"><summary><span class="new-trigger-plus" aria-hidden="true"></span><span>New</span></summary><div><a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true">▦</span><strong>Recipe</strong></a><a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true">▣</span><strong>Recipe book</strong></a></div></details>}
<form action="/app/" role="search" class="detail-search-form">
<span class="search-input-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
</span>
<input name="q" type="text" placeholder="Search" aria-label="Search all items" class="search-field" autocomplete="off"/>
<button type="button" class="search-clear-btn" aria-label="Clear search" title="Clear search">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</form>
{!readOnlyMode&&<details class="detail-new-menu">
<summary><span class="new-trigger-plus" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg></span><span>New</span></summary>
<div>
<a href="/app/recipes/new/"><span class="new-menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z" style="transform: scale(1.1, 1.1) translate(4px, 3.5px);"/></svg></span><strong>Recipe</strong></a>
<a href="/app/recipe-books/new/"><span class="new-menu-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="24" height="24"><path fill="currentColor" d="M14.0347 0V16H3.12658C1.50098 16 0.527344 15.0264 0.527344 13.4008V2.59923C0.527344 0.97364 1.50098 0 3.12658 0H14.0347ZM12.1594 12.6763L3.95747 12.6765C2.75227 12.6765C2.40226 12.9098 2.40226 13.4008C2.40226 13.9909 2.53647 14.1251 3.12658 14.1251H12.1598L12.1594 12.6763ZM12.1598 1.87492H3.12658C2.53647 1.87492 2.40226 2.00913 2.40226 2.59923L2.40185 10.9999C2.85223 10.8678 3.37428 10.8015 3.95747 10.8015L12.1594 10.8014L12.1598 1.87492ZM10.0768 3.80157V5.67649H4.45204V3.80157H10.0768Z" style="transform: scale(1.1, 1.1) translate(3.5px, 2.5px);"/></svg></span><strong>Recipe book</strong></a>
</div>
</details>}
</div>
</nav>
<script>
document.querySelectorAll('.detail-search-form').forEach((formEl) => {
const form = formEl as HTMLFormElement;
const input = form.querySelector('.search-field') as HTMLInputElement | null;
const clearBtn = form.querySelector('.search-clear-btn') as HTMLButtonElement | null;
if (!input || !clearBtn) return;
const sync = () => {
if (input.value.trim().length > 0) form.classList.add('has-value');
else form.classList.remove('has-value');
};
input.addEventListener('input', sync);
clearBtn.addEventListener('click', () => {
input.value = '';
sync();
input.focus();
});
sync();
});
</script>
+51 -14
View File
@@ -1,9 +1,21 @@
import { useRef,useState } from "preact/hooks";
export type DirectoryRow = {
id:string; name:string; href?:string; kind:"recipe"|"ingredient"|"book"|"purchase"; icon:string;
id:string; name:string; href?:string; kind:"recipe"|"ingredient"|"book"|"purchase"; detail?:string;
};
type Props={ rows:DirectoryRow[]; entityType:DirectoryRow["kind"]; emptyMessage:string; readOnly?:boolean };
const TYPE_ICONS:Record<DirectoryRow["kind"],string> = {
recipe:"M13.125 0C13.6428 0 14.0625 0.419733 14.0625 0.9375V14.0625C14.0625 14.5803 13.6428 15 13.125 15H0.9375C0.419733 15 0 14.5803 0 14.0625V0.9375C0 0.419733 0.419733 0 0.9375 0H13.125ZM12.1875 1.875H1.875V13.125H12.1875V1.875ZM11.25 9.375V11.25H5.625V9.375H11.25ZM4.6875 9.375V11.25H2.8125V9.375H4.6875ZM11.25 6.5625V8.4375H5.625V6.5625H11.25ZM4.6875 6.5625V8.4375H2.8125V6.5625H4.6875ZM11.25 3.75V5.625H5.625V3.75H11.25ZM4.6875 3.75V5.625H2.8125V3.75H4.6875Z",
ingredient:"M7.57975 2.24434L7.65872 2.13161C8.13511 1.47697 8.63143 1.08241 9.16762 0.957718C9.57278 0.863497 9.96486 1.18135 10.0434 1.66766C10.1219 2.15397 9.85705 2.62459 9.45189 2.71881C9.18697 2.78042 8.8382 3.1513 8.44252 3.84957C8.78173 3.85543 9.10757 3.88576 9.42032 3.94078C11.9002 4.37705 13.577 7.45745 12.4331 11.6424C11.496 15.0703 9.68081 16.5977 7.229 15.682C6.85528 15.5611 6.58673 15.5046 6.4542 15.5046C6.40147 15.5046 6.36601 15.5163 6.32134 15.5491L6.15949 15.644C3.74207 16.7473 1.8122 15.2446 0.553846 11.7108C-0.274666 9.38411 -0.146354 7.48777 0.75651 6.03344C1.3667 5.05056 2.12543 4.48603 3.08872 4.01846C3.29048 3.92053 3.51746 3.85185 3.7715 3.81087C3.24172 3.20799 2.94401 2.30051 2.83382 1.12442L2.74286 0.153477L3.63312 0.0415436C5.52154 -0.195889 6.8746 0.590513 7.57975 2.24434ZM3.88234 5.71589C3.2053 6.04451 2.69949 6.42087 2.31601 7.03856C1.73369 7.97655 1.64642 9.26637 2.2885 11.0695C3.21226 13.6636 4.17073 14.4453 5.3377 13.9576C5.67319 13.7379 6.05207 13.6244 6.4542 13.6244C6.81471 13.6244 7.24894 13.7157 7.82562 13.9033C9.09594 14.3772 9.97129 13.6407 10.6554 11.1379C11.5187 7.9797 10.4582 6.03137 9.10636 5.79357C8.42613 5.6739 7.60929 5.71807 6.65655 5.93628L6.446 5.9845L6.2363 5.93257C4.98214 5.62199 4.16221 5.58004 3.88234 5.71589ZM4.69764 1.88235C4.80315 2.16337 4.92885 2.34891 5.06454 2.4469C5.26392 2.5909 5.53003 2.73516 5.86115 2.87726C5.58815 2.34716 5.20687 2.02464 4.69764 1.88235Z",
book:"M14.0347 0V16H3.12658C1.50098 16 0.527344 15.0264 0.527344 13.4008V2.59923C0.527344 0.97364 1.50098 0 3.12658 0H14.0347ZM12.1594 12.6763L3.95747 12.6765C2.75227 12.6765 2.40226 12.9098 2.40226 13.4008C2.40226 13.9909 2.53647 14.1251 3.12658 14.1251H12.1598L12.1594 12.6763ZM12.1598 1.87492H3.12658C2.53647 1.87492 2.40226 2.00913 2.40226 2.59923L2.40185 10.9999C2.85223 10.8678 3.37428 10.8015 3.95747 10.8015L12.1594 10.8014L12.1598 1.87492ZM10.0768 3.80157V5.67649H4.45204V3.80157H10.0768Z",
purchase:"M19.5 3.5 18 2l-1.5 1.5L15 2l-1.5 1.5L12 2l-1.5 1.5L9 2 7.5 3.5 6 2 4.5 3.5 3 2v20l1.5-1.5L6 22l1.5-1.5L9 22l1.5-1.5L12 22l1.5-1.5L15 22l1.5-1.5L18 22l1.5-1.5L21 22V2l-1.5 1.5zM19 19.09H5V4.91h14v14.18zM6 15h12v2H6zm0-4h12v2H6zm0-4h12v2H6z"
};
const TYPE_ICON_TRANSFORMS:Record<DirectoryRow["kind"],string|undefined> = {
recipe:"scale(1.1, 1.1) translate(4px, 3.5px)",
ingredient:"scale(1.3, 1.3) translate(2.5px, 1px)",
book:"scale(1.1, 1.1) translate(3.5px, 2.5px)",
purchase:undefined
};
type Props={ rows:DirectoryRow[]; entityType?:DirectoryRow["kind"]; emptyMessage:string; readOnly?:boolean };
export default function EntityDirectory({rows,entityType,emptyMessage,readOnly=false}:Props) {
const [selected,setSelected]=useState<string[]>([]),[deleting,setDeleting]=useState(false),[error,setError]=useState("");
@@ -12,34 +24,59 @@ export default function EntityDirectory({rows,entityType,emptyMessage,readOnly=f
const allSelected=rows.length>0&&selected.length===rows.length;
const toggle=(id:string)=>setSelected(current=>current.includes(id)?current.filter(value=>value!==id):[...current,id]);
const requestDelete=(ids:string[])=>{if(!ids.length)return;setPendingDelete(ids);dialog.current?.showModal();};
const kindById=new Map(rows.map(row=>[row.id,row.kind]));
const remove=async()=>{
const ids=pendingDelete;
if(!ids.length)return;
setDeleting(true);setError("");
const response=await fetch("/api/app/entities/delete",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({type:entityType,ids})});
const result=await response.json();
if(!response.ok){setError(result.error??"Unable to delete selection.");setDeleting(false);dialog.current?.close();return;}
const groups=new Map<DirectoryRow["kind"],string[]>();
for(const id of ids){const kind=kindById.get(id)??entityType??"recipe";if(!groups.has(kind))groups.set(kind,[]);groups.get(kind)!.push(id);}
try{
for(const [kind,groupIds] of groups){
const response=await fetch("/api/app/entities/delete",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({type:kind,ids:groupIds})});
const result=await response.json();
if(!response.ok){setError(result.error??"Unable to delete selection.");dialog.current?.close();return;}
}
} finally { setDeleting(false); }
location.reload();
};
return <section class={`entity-directory-table${readOnly?" read-only":""}`}>
{!readOnly&&<div class="entity-directory-toolbar entity-directory-head">
<input aria-label={`Select all ${entityType} items`} type="checkbox" checked={allSelected} ref={input=>{if(input)input.indeterminate=selected.length>0&&!allSelected;}} onChange={()=>setSelected(allSelected?[]:rows.map(row=>row.id))}/>
<span>Type</span>
<span>Name</span>
<div class="entity-directory-toolbar-actions">{selected.length>0&&<><strong>{selected.length} selected</strong><button class="bulk-delete" type="button" disabled={deleting} onClick={()=>requestDelete(selected)}> Delete</button><button type="button" onClick={()=>setSelected([])}>Clear</button></>}</div>
{!readOnly&&<div class={`entity-directory-toolbar entity-directory-head${selected.length>0?" has-selection":""}`}>
<input aria-label={`Select all ${entityType??"items"}`} type="checkbox" checked={allSelected} ref={input=>{if(input)input.indeterminate=selected.length>0&&!allSelected;}} onChange={()=>setSelected(allSelected?[]:rows.map(row=>row.id))}/>
{selected.length>0 ? (
<div class="entity-directory-selection-bar">
<strong class="selection-count">{selected.length}&nbsp;Selected</strong>
<div class="entity-directory-toolbar-actions">
<button class="bulk-action-btn bulk-delete" type="button" disabled={deleting} onClick={()=>requestDelete(selected)} title="Delete selected" aria-label="Delete selected">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
<span>Delete</span>
</button>
<button class="bulk-action-btn bulk-clear" type="button" onClick={()=>setSelected([])} title="Clear selection" aria-label="Clear selection">
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
<span>Clear</span>
</button>
</div>
</div>
) : (
<>
<span>Type</span>
<span>Name</span>
<div class="entity-directory-toolbar-actions"></div>
</>
)}
</div>}
{error&&<p class="directory-error">{error}</p>}
<div>{rows.map(row=><div class={`entity-directory-row${selected.includes(row.id)?" selected":""}`}>
{!readOnly&&<input aria-label={`Select ${row.name}`} type="checkbox" checked={selected.includes(row.id)} onChange={()=>toggle(row.id)}/>}
<span class={`workspace-pill-icon ${row.kind}`}>{row.icon}</span>
<span class="entity-directory-name">{row.href?<a href={row.href}><strong>{row.name}</strong></a>:<strong>{row.name}</strong>}</span>
{!readOnly&&<details class="entity-row-actions"><summary aria-label={`Actions for ${row.name}`}></summary><div><button type="button" onClick={()=>requestDelete([row.id])}>Delete</button></div></details>}
<span class={`workspace-pill-icon ${row.kind}`}><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d={TYPE_ICONS[row.kind]} style={TYPE_ICON_TRANSFORMS[row.kind]?{transform:TYPE_ICON_TRANSFORMS[row.kind]}:undefined}/></svg></span>
<span class="entity-directory-name">{row.href?<a href={row.href}><strong>{row.name}</strong>{row.detail&&<small>{row.detail}</small>}</a>:<><strong>{row.name}</strong>{row.detail&&<small>{row.detail}</small>}</>}</span>
{!readOnly&&<details class="entity-row-actions"><summary aria-label={`Actions for ${row.name}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg></summary><div><button type="button" onClick={()=>requestDelete([row.id])}>Delete</button></div></details>}
</div>)}</div>
{rows.length===0&&<div class="empty-state">{emptyMessage}</div>}
{!readOnly&&<dialog class="delete-confirmation" ref={dialog} onClose={()=>{if(!deleting)setPendingDelete([]);}}>
<form method="dialog"><button class="dialog-close" aria-label="Close">×</button></form>
<h2>Delete {pendingDelete.length===1?"item":`${pendingDelete.length} items`}?</h2>
<p>This permanently removes the selected {pendingDelete.length===1?entityType:`${entityType} items`}. This action cannot be undone.</p>
<p>This permanently removes the selected {pendingDelete.length===1?(entityType??"item"):`${pendingDelete.length} ${entityType??"items"}`}. This action cannot be undone.</p>
<div><form method="dialog"><button disabled={deleting}>Cancel</button></form><button class="confirm-delete" type="button" disabled={deleting} onClick={remove}>{deleting?"Deleting…":"Delete"}</button></div>
</dialog>}
</section>;
+41 -18
View File
@@ -49,7 +49,7 @@ function convertItem(quantity: number, fromUnitId: string, toUnitId: string, ite
export default function RecipeCalculator({ components, units, basisUnitId, yieldQuantity, yieldUnitId, nutrition, cost, servings, showDerived = true, showPercentControls = true, yieldConversions = [] }: Props) {
const [factor, setFactor] = useState(1);
const [calculatePercent,setCalculatePercent]=useState(true);
const [calculatePercent,setCalculatePercent]=useState(showPercentControls);
const [percentMode,setPercentMode]=useState<"standard"|"bakers">("standard");
const [yieldDisplayUnitId, setYieldDisplayUnitId] = useState(yieldUnitId);
const [lineUnits, setLineUnits] = useState<Record<string, string>>({});
@@ -78,29 +78,52 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
return (
<section class="calculator" aria-labelledby="formula-heading">
<div class="calculator-heading">
<div>
<p class="eyebrow">Scalable formula</p>
<h2 id="formula-heading">Ingredients</h2>
</div>
<label class="basis-input">
<span>Batch multiplier</span>
<span class="input-with-unit recipe-scale-control batch-size-control">
<label class="basis-input batch-multiplier-field">
<span class="scale-label">Batch:</span>
<span class="recipe-scale-control batch-size-control">
<input
type="number"
min="0"
step="any"
aria-label="Batch multiplier"
value={roundForDisplay(factor)}
onInput={(event) => setFactor(Number((event.currentTarget as HTMLInputElement).value))}
/>
<span class="scale-suffix">x</span>
</span>
</label>
<label class="basis-input">
<span>Finished yield</span>
<span class="quantity-control recipe-scale-control"><input type="number" min="0" step="any" value={roundForDisplay(scaledYield)} onInput={(event) => changeYield(Number(event.currentTarget.value))}/><select aria-label="Yield unit" value={yieldDisplayUnitId} onChange={(event) => setYieldDisplayUnitId(event.currentTarget.value)}>{yieldUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}</select></span>
<label class="basis-input finished-yield-field">
<span class="scale-label">Yield:</span>
<span class="recipe-scale-control quantity-control">
<input
type="number"
min="0"
step="any"
aria-label="Finished yield quantity"
value={roundForDisplay(scaledYield)}
onInput={(event) => changeYield(Number((event.currentTarget as HTMLInputElement).value))}
/>
<select aria-label="Finished yield unit" value={yieldDisplayUnitId} onChange={(event) => setYieldDisplayUnitId((event.currentTarget as HTMLSelectElement).value)}>
{yieldUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}
</select>
</span>
</label>
<div class="calculator-percent-controls">{showPercentControls&&calculatePercent&&<span class="percent-mode"><button class={percentMode==="standard"?"active":""} onClick={()=>setPercentMode("standard")}>Standard %</button><button class={percentMode==="bakers"?"active":""} onClick={()=>setPercentMode("bakers")}>Baker's %</button></span>}{showPercentControls&&<label class="calculate-toggle"><span>Calculate %</span><input type="checkbox" checked={calculatePercent} onChange={(event)=>setCalculatePercent(event.currentTarget.checked)}/><i></i></label>}</div>
{showPercentControls && (
<div class="calculator-percent-controls">
{calculatePercent && (
<span class="percent-mode">
<button type="button" class={percentMode === "standard" ? "active" : ""} onClick={() => setPercentMode("standard")}>Standard %</button>
<button type="button" class={percentMode === "bakers" ? "active" : ""} onClick={() => setPercentMode("bakers")}>Baker's %</button>
</span>
)}
<label class="calculate-toggle">
<span>Calculate %</span>
<input type="checkbox" checked={calculatePercent} onChange={(event) => setCalculatePercent((event.currentTarget as HTMLInputElement).checked)} />
<i></i>
</label>
</div>
)}
</div>
<p class="scale-relationship">1× produces {number(yieldQuantity)} {units[yieldUnitId]?.symbol??yieldUnitId} finished yield. Changing the multiplier, finished yield, or any ingredient amount scales the entire recipe.</p>
{calculatePercent&&percentMode==="bakers"&&baseItems.length>0&&<section class="bakers-base-summary"><strong>Base</strong><div>{baseItems.map(item=>{const displayUnitId=lineUnits[item.id]??item.amount.unit_id;const displayQuantity=convertItem(item.amount.quantity*validFactor,item.amount.unit_id,displayUnitId,item,units);return <p><span><input aria-label={`${item.label} base quantity`} type="number" min="0" step="any" value={roundForDisplay(displayQuantity)} onInput={(event)=>{const canonicalQuantity=convertItem(Number(event.currentTarget.value),displayUnitId,item.amount.unit_id,item,units);setFactor(item.amount.quantity>0?canonicalQuantity/item.amount.quantity:1)}}/><select aria-label={`${item.label} base unit`} value={displayUnitId} onChange={(event)=>setLineUnits((current)=>({...current,[item.id]:event.currentTarget.value}))}>{itemUnits(item,units).map(unit=><option value={unit.id}>{unit.symbol}</option>)}</select></span><a href={item.href}>{item.label}</a></p>})}</div></section>}
{components.map((component) => (
@@ -108,7 +131,7 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
{components.length > 1 && <h3>{component.name}</h3>}
<div class="table-wrap">
<table class="recipe-ingredients">
<thead><tr><th>Ingredient</th>{calculatePercent&&<th>{percentMode==="standard"?"Standard %":"Baker's %"}</th>}<th>Weight</th></tr></thead>
<thead><tr><th>Amount</th><th>Ingredient</th>{calculatePercent&&<th>{percentMode==="standard"?"Standard %":"Baker's %"}</th>}</tr></thead>
<tbody>
{component.items.filter(item=>percentMode!=="bakers"||!calculatePercent||!item.basisMember).map((item) => {
const displayUnitId = lineUnits[item.id] ?? item.amount.unit_id;
@@ -120,13 +143,13 @@ export default function RecipeCalculator({ components, units, basisUnitId, yield
};
return (
<tr key={item.id} class={item.basisMember ? "basis-row" : ""}>
<td><span class="quantity-control line-quantity"><input aria-label={`${item.label} quantity`} type="number" min="0" step="any" value={roundForDisplay(displayQuantity)} onInput={(event) => changeLineQuantity(Number(event.currentTarget.value))}/><select aria-label={`${item.label} unit`} value={displayUnitId} style={{width:`${(compatibleUnits.find((unit) => unit.id === displayUnitId)?.symbol ?? displayUnitId).length + 0.75}ch`}} onChange={(event) => { const next = event.currentTarget.value; try { convertItem(item.amount.quantity * validFactor, item.amount.unit_id, next, item, units); } catch { event.currentTarget.value = displayUnitId; return; } setLineUnits((current) => ({ ...current, [item.id]: next })); }}>{compatibleUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}</select></span></td>
<td>
<span class="calculator-ingredient-name">{item.href ? <a href={item.href}>{item.label}</a> : item.label}{item.attention&&<span class="ingredient-attention-icon" role="img" aria-label={item.attentionMessage??"Needs attention"} title={item.attentionMessage??"Needs attention"}>!</span>}</span>
<span class="calculator-ingredient-name">{item.href ? <a href={item.href}>{item.label}</a> : item.label}{item.attention&&<span class="ingredient-attention-icon" role="img" aria-label={item.attentionMessage??"Needs attention"} title={item.attentionMessage??"Needs attention"}><svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><path fill="currentColor" d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></svg></span>}</span>
{item.optional && <span class="muted"> optional</span>}
{item.notes && <small>{item.notes}</small>}
</td>
{calculatePercent&&<td>{itemWeight(item)==null||percentageBase<=0?"":`${number(itemWeight(item)!/percentageBase*100)}%`}</td>}
<td><span class="quantity-control line-quantity"><input aria-label={`${item.label} quantity`} type="number" min="0" step="any" value={roundForDisplay(displayQuantity)} onInput={(event) => changeLineQuantity(Number(event.currentTarget.value))}/><select aria-label={`${item.label} unit`} value={displayUnitId} onChange={(event) => setLineUnits((current) => ({ ...current, [item.id]: event.currentTarget.value }))}>{compatibleUnits.map((unit) => <option value={unit.id} key={unit.id}>{unit.symbol}</option>)}</select></span></td>
{calculatePercent&&<td class="recipe-percent-cell">{itemWeight(item)==null||percentageBase<=0?"":`${number(itemWeight(item)!/percentageBase*100)}%`}</td>}
</tr>
);
})}
@@ -176,7 +199,7 @@ function CostLedgerLine({ line, factor, currency, editable, expanded }: { line:C
const body=<>
<span class={`cost-subject-icon ${line.kind}`}>{line.kind==="recipe"?"R":""}</span>
<a href={href}>{line.name}</a>
{line.completeness<1&&<span class="cost-attention" title="Cost information is incomplete">!</span>}
{line.completeness<1&&<span class="cost-attention" title="Cost information is incomplete" aria-label="Cost information is incomplete"><svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><path fill="currentColor" d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></svg></span>}
<span class="cost-line-value">{line.cost!=null?money.format(line.cost*factor):line.kind==="ingredient"&&editable?<a href={href}>Add cost&nbsp; ✎</a>:""}</span>
</>;
if(line.purchase||line.children?.length)return <details class="cost-ledger-line" open={expanded}><summary>{body}</summary><div class="cost-line-detail">{line.purchase?<><div><small>Purchase item name</small><strong>{line.purchase.name}</strong></div><div><small>Purchase cost</small><strong>{money.format(line.purchase.price)}</strong></div><div><small>Purchase unit</small><strong>{number(line.purchase.packageQuantity)} {line.purchase.packageUnitId}</strong></div><div><small>Date added</small><strong>{line.purchase.effectiveAt}</strong></div><div><small>Item ID #</small><strong>{line.purchase.sku??""}</strong></div><div><small>Vendor</small><strong>{line.purchase.supplier??""}</strong></div></>:<p>No usable purchase cost is available.</p>}{line.children?.length?<div class="cost-child-lines">{line.children.map(child=><CostLedgerLine line={child} factor={factor} currency={currency} editable={editable} expanded={expanded}/>)}</div>:null}</div></details>;
+481 -309
View File
@@ -1,4 +1,6 @@
import { useEffect, useState } from "preact/hooks";
import { Fragment } from "preact";
import { createPortal } from "preact/compat";
import type { RecipeStructure } from "../lib/database";
import { percentage, targetWeight } from "../lib/percentages";
@@ -40,6 +42,11 @@ export default function RecipeStructureEditor({
const [prepText, setPrepText] = useState("");
const [prepError, setPrepError] = useState("");
const [pendingIngredients, setPendingIngredients] = useState<Option[]>([]);
const [methodTarget, setMethodTarget] = useState<HTMLElement | null>(null);
useEffect(() => {
setMethodTarget(document.getElementById("recipe-method-editor-slot"));
}, []);
const [baseline, setBaseline] = useState(JSON.stringify(initial));
const [state, setState] = useState<"idle" | "saving" | "saved" | "error">(
"idle",
@@ -448,70 +455,98 @@ export default function RecipeStructureEditor({
{autoYield && unconvertedYieldItems() > 0 && (
<p class="auto-yield-warning">Auto yield excludes {unconvertedYieldItems()} ingredient {unconvertedYieldItems() === 1 ? "amount" : "amounts"} without a weight equivalency.</p>
)}
{data.components.map((component, componentIndex) => (
<fieldset class={`component-editor${dragging?.kind === "component" && dragging.index === componentIndex ? " dragging" : ""}`} key={component.id} onDragOver={(event) => event.preventDefault()} onDrop={() => {
if (dragging?.kind === "component") setData((current) => ({ ...current, components: move(current.components, dragging.index, componentIndex) }));
setDragging(undefined);
}}>
<legend>Ingredient header {componentIndex + 1}</legend>
<div class="editor-inline">
<input
aria-label="Component name"
value={component.name}
onInput={(event) =>
updateComponent(componentIndex, (value) => ({
...value,
name: event.currentTarget.value,
}))
}
/>
<button
class="remove-row"
onClick={() =>
setData((current) => ({
...current,
components: current.components.filter(
(_, index) => index !== componentIndex,
),
}))
}
disabled={data.components.length === 1}
>
</button>
<span class="drag-handle" title="Drag to reorder" draggable onDragStart={() => setDragging({ kind: "component", index: componentIndex })}>⠿</span>
</div>
{(component.notes ?? []).map((note, noteIndex) => (
<div class="component-note-row">
<input
class="component-notes"
aria-label={`${component.name} note ${noteIndex + 1}`}
value={note}
placeholder="Add note"
onInput={(event) => updateComponent(componentIndex, (value) => ({
...value,
notes: (value.notes ?? []).map((entry, index) => index === noteIndex ? event.currentTarget.value : entry),
}))}
/>
<button class="remove-row" aria-label="Remove note" onClick={() => updateComponent(componentIndex, (value) => ({ ...value, notes: (value.notes ?? []).filter((_, index) => index !== noteIndex) }))}>⊖</button>
</div>
))}
<div class="table-wrap">
<table class={`editor-items${calculatePercent && percentMode === "bakers" ? " bakers-mode" : ""}`}>
<thead>
<tr>
{calculatePercent && percentMode === "bakers" && (
<th class="base-column">Base</th>
)}
{calculatePercent && <th class="percent-column">%</th>}
<th class="quantity-column">Qty</th>
<th class="unit-column">Unit</th>
<th class="entity-column">Ingredient / Recipe</th>
<th class="notes-column">Notes</th>
<th class="actions-column"></th>
</tr>
</thead>
<tbody>
<div class="table-wrap">
<table class={`editor-items${calculatePercent && percentMode === "bakers" ? " bakers-mode" : ""}`}>
<thead>
<tr>
<th class="quantity-column">Qty</th>
<th class="unit-column">Unit</th>
<th class="entity-column">Ingredient / Recipe</th>
<th class="notes-column">Notes</th>
{calculatePercent && percentMode === "bakers" && (
<th class="base-column">Base</th>
)}
{calculatePercent && <th class="percent-column">%</th>}
<th class="actions-column"></th>
</tr>
</thead>
<tbody>
{data.components.map((component, componentIndex) => (
<Fragment key={component.id}>
{(data.components.length > 1 || Boolean(component.name?.trim())) && (
<tr
class={`component-header-row${dragging?.kind === "component" && dragging.index === componentIndex ? " dragging" : ""}`}
key={`header-${component.id}`}
onDragOver={(event) => event.preventDefault()}
onDrop={() => {
if (dragging?.kind === "component") setData((current) => ({ ...current, components: move(current.components, dragging.index, componentIndex) }));
setDragging(undefined);
}}
>
<td colSpan={calculatePercent ? (percentMode === "bakers" ? 7 : 6) : 5}>
<div class="component-header-content">
<input
class="component-name-input"
aria-label="Component name"
placeholder="Edit Header"
value={component.name}
onInput={(event) =>
updateComponent(componentIndex, (value) => ({
...value,
name: event.currentTarget.value,
}))
}
/>
<div class="component-header-actions">
<button
type="button"
class="remove-component-btn"
title="Delete section"
aria-label="Delete section"
onClick={() =>
setData((current) => ({
...current,
components: current.components.filter(
(_, index) => index !== componentIndex,
),
}))
}
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M7 11v2h10v-2zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"/></svg>
</button>
<span
class="drag-handle"
title="Drag to reorder section"
aria-label="Drag to reorder section"
draggable
onDragStart={() => setDragging({ kind: "component", index: componentIndex })}
>
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path fill="currentColor" d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2"/></svg>
</span>
</div>
</div>
</td>
</tr>
)}
{(component.notes ?? []).map((note, noteIndex) => (
<tr class="component-note-row" key={`note-${component.id}-${noteIndex}`}>
<td colSpan={calculatePercent ? (percentMode === "bakers" ? 7 : 6) : 5}>
<div class="component-note-content">
<input
class="component-notes"
aria-label={`${component.name} note ${noteIndex + 1}`}
value={note}
placeholder="Add section note"
onInput={(event) => updateComponent(componentIndex, (value) => ({
...value,
notes: (value.notes ?? []).map((entry, index) => index === noteIndex ? event.currentTarget.value : entry),
}))}
/>
<button class="remove-row" aria-label="Remove note" onClick={() => updateComponent(componentIndex, (value) => ({ ...value, notes: (value.notes ?? []).filter((_, index) => index !== noteIndex) }))}>✕</button>
</div>
</td>
</tr>
))}
{component.items.map((item, itemIndex) => {
const isPending = Boolean(
item.ingredient_id &&
@@ -534,16 +569,121 @@ export default function RecipeStructureEditor({
const shownChoices = choices.filter((entry) => !typedValue.trim() || [entry.name, ...(entry.aliases ?? [])].some((name) => normal(name).includes(normal(typedValue)))).slice(0, 10);
const hasUncommittedInput = normal(typedValue) !== normal(label ?? "");
const canCreateInput = Boolean(typedValue.trim()) && !exactMatch;
const isSubrecipe = Boolean(item.subrecipe_id);
return (
<tr class={`${dragging?.kind === "item" && dragging.component === componentIndex && dragging.index === itemIndex ? "dragging" : ""}${isPending ? " pending-ingredient-row" : ""}`} key={item.id} onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
<tr class={`${dragging?.kind === "item" && dragging.component === componentIndex && dragging.index === itemIndex ? "dragging" : ""}${isPending ? " pending-ingredient-row" : ""}${isSubrecipe ? " is-subrecipe-row" : ""}`} key={item.id} onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
event.stopPropagation();
if (dragging?.kind === "item" && dragging.component === componentIndex) updateComponent(componentIndex, (value) => ({ ...value, items: move(value.items, dragging.index, itemIndex) }));
setDragging(undefined);
}}>
<td class="quantity-column">
<input
type="number"
min="0"
step="any"
aria-label="Quantity"
value={item.quantity}
onInput={(event) =>
updateComponent(componentIndex, (value) => ({
...value,
items: value.items.map((line, index) =>
index === itemIndex
? {
...line,
quantity: Number(
event.currentTarget.value,
),
}
: line,
),
}))
}
/>
</td>
<td class="unit-column">
<select
aria-label="Unit"
value={item.unit_id}
onChange={(event) =>
updateComponent(componentIndex, (value) => ({
...value,
items: value.items.map((line, index) =>
index === itemIndex
? {
...line,
unit_id: event.currentTarget.value,
}
: line,
),
}))
}
>
{units.map((unit) => (
<option value={unit.id}>
{unit.symbol || unit.name}
</option>
))}
</select>
</td>
<td class="entity-column">
<div class={`row-entity-combobox${hasUncommittedInput ? " unmatched" : ""}${isSubrecipe ? " is-subrecipe" : ""}`}>
<input
type="text"
value={typedValue}
placeholder="Ingredient or Recipe"
aria-label={`Ingredient or recipe for row ${itemIndex + 1}`}
aria-expanded={focusedItem === item.id}
aria-autocomplete="list"
onFocus={(event) => { setFocusedItem(item.id); event.currentTarget.select(); }}
onBlur={() => window.setTimeout(() => setFocusedItem((current) => current === item.id ? undefined : current), 120)}
onInput={(event) => setRowQuery((current) => ({ ...current, [item.id]: event.currentTarget.value }))}
/>
{isPending && (
<span class="ingredient-attention-icon" aria-label="New unmatched ingredient" title="New ingredient; details can be completed after saving">
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true">
<path fill="currentColor" d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/>
</svg>
</span>
)}
{focusedItem === item.id && (
<div class="row-entity-results" role="listbox">
{shownChoices.map((entry) => (
<button type="button" role="option" onMouseDown={(event) => event.preventDefault()} onClick={() => replaceLineReference(componentIndex, itemIndex, entry.kind, entry.id, entry.name)}>
<strong>{entry.name}</strong><small class={`badge-${entry.kind}`}>{entry.kind === "recipe" ? "Recipe" : "Ingredient"}</small>
</button>
))}
{canCreateInput && (
<div class="row-create-actions">
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => createPendingRowIngredient(componentIndex, itemIndex, typedValue)}>+ Create “{typedValue}”</button>
</div>
)}
</div>
)}
</div>
{isPending && <small class="pending-ingredient-help">New ingredient · saved automatically</small>}
</td>
<td class="notes-column">
<input
class="line-notes"
aria-label={`${label} notes`}
value={item.notes ?? ""}
placeholder="Add Notes"
onInput={(event) =>
updateComponent(componentIndex, (value) => ({
...value,
items: value.items.map((line, index) =>
index === itemIndex
? { ...line, notes: event.currentTarget.value }
: line,
),
}))
}
/>
</td>
{calculatePercent && percentMode === "bakers" && (
<td class="base-column">
<input
aria-label={`Use ${label} as a Baker's percentage base`}
aria-label={`${label} baker's base member`}
type="checkbox"
checked={item.basis_member}
onChange={(event) =>
@@ -592,106 +732,13 @@ export default function RecipeStructureEditor({
)}
</td>
)}
<td class="quantity-column">
<input
type="number"
min="0.0001"
step="any"
value={item.quantity}
onInput={(event) =>
updateComponent(componentIndex, (value) => ({
...value,
items: value.items.map((line, index) =>
index === itemIndex
? {
...line,
quantity: Number(
event.currentTarget.value,
),
}
: line,
),
}))
}
/>
</td>
<td class="unit-column">
<select
value={item.unit_id}
onChange={(event) =>
updateComponent(componentIndex, (value) => ({
...value,
items: value.items.map((line, index) =>
index === itemIndex
? {
...line,
unit_id: event.currentTarget.value,
}
: line,
),
}))
}
>
{units.map((unit) => (
<option value={unit.id}>
{unit.name} ({unit.symbol})
</option>
))}
</select>
</td>
<td class="entity-column">
<div class={`row-entity-combobox${hasUncommittedInput ? " unmatched" : ""}`}>
<input
type="text"
value={typedValue}
aria-label={`Ingredient or recipe for row ${itemIndex + 1}`}
aria-expanded={focusedItem === item.id}
aria-autocomplete="list"
onFocus={(event) => { setFocusedItem(item.id); event.currentTarget.select(); }}
onBlur={() => window.setTimeout(() => setFocusedItem((current) => current === item.id ? undefined : current), 120)}
onInput={(event) => setRowQuery((current) => ({ ...current, [item.id]: event.currentTarget.value }))}
/>
{isPending && <span class="ingredient-attention-icon" aria-label="New unmatched ingredient" title="New ingredient; details can be completed after saving">!</span>}
{focusedItem === item.id && (
<div class="row-entity-results" role="listbox">
{shownChoices.map((entry) => (
<button type="button" role="option" onMouseDown={(event) => event.preventDefault()} onClick={() => replaceLineReference(componentIndex, itemIndex, entry.kind, entry.id, entry.name)}>
<strong>{entry.name}</strong><small>{entry.kind === "recipe" ? "Recipe" : "Ingredient"}</small>
</button>
))}
{canCreateInput && (
<div class="row-create-actions">
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => createPendingRowIngredient(componentIndex, itemIndex, typedValue)}>Create New Ingredient</button>
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => createPendingRowIngredient(componentIndex, itemIndex, typedValue)}>Create Ingredient w/ Prep Action</button>
</div>
)}
</div>
)}
</div>
{isPending && <small class="pending-ingredient-help">New ingredient · created on save</small>}
</td>
<td class="notes-column">
<input
class="line-notes"
aria-label={`${label} notes`}
value={item.notes ?? ""}
placeholder="Add notes"
onInput={(event) =>
updateComponent(componentIndex, (value) => ({
...value,
items: value.items.map((line, index) =>
index === itemIndex
? { ...line, notes: event.currentTarget.value }
: line,
),
}))
}
/>
</td>
<td class="actions-column">
<span class="row-actions">
<div class="row-actions-group">
<button
class="remove-row"
type="button"
class="remove-row-btn"
title="Delete"
aria-label="Delete ingredient"
onClick={() =>
updateComponent(componentIndex, (value) => ({
...value,
@@ -701,109 +748,158 @@ export default function RecipeStructureEditor({
}))
}
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M7 11v2h10v-2zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"/></svg>
</button>
<span class="drag-handle" title="Drag to reorder" draggable onDragStart={(event) => { event.stopPropagation(); setDragging({ kind: "item", component: componentIndex, index: itemIndex }); }}>⠿</span>
</span>
<span
class="drag-handle"
title="Drag to reorder"
aria-label="Drag to reorder"
draggable
onDragStart={(event) => {
event.stopPropagation();
setDragging({
kind: "item",
component: componentIndex,
index: itemIndex,
});
}}
>
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path fill="currentColor" d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2"/></svg>
</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div class="add-line">
<div class="recipe-item-search">
<input
value={query[component.id] ?? ""}
placeholder="Type an ingredient or recipe"
aria-label={`Search items for ${component.name}`}
onInput={(event) => {
const value=event.currentTarget.value;
setQuery((current) => ({ ...current, [component.id]: value }));
}}
/>
{(query[component.id]?.trim() ?? "") && (
<div class="recipe-item-results">
{[
...[...ingredients,...pendingIngredients].map((item) => ({...item,kind:"ingredient" as const})),
...recipes.filter((item) => item.id !== recipeId).map((item) => ({...item,kind:"recipe" as const})),
].filter((item) => [item.name,...(item.aliases??[])].some((name) => name.toLowerCase().includes(query[component.id].trim().toLowerCase()))).slice(0,8).map((item) => (
<button onClick={() => {
addLine(componentIndex, `${item.kind}:${item.id}`);
}}><strong>{item.name}</strong><small>{item.kind === "recipe" ? "Recipe" : "Ingredient"}</small></button>
))}
{!ingredients.some((item) => normal(item.name) === normal(query[component.id])) &&
!pendingIngredients.some((item) => normal(item.name) === normal(query[component.id])) && (
</Fragment>
))}
</tbody>
</table>
</div>
<div class="quick-add-bar">
<div class="quick-add-search">
<input
value={query[data.components[data.components.length - 1]?.id ?? data.components[0]?.id ?? ""] ?? ""}
placeholder="1cup onion sliced"
aria-label="Add ingredient or recipe"
onInput={(event) => {
const value = event.currentTarget.value;
const activeId = data.components[data.components.length - 1]?.id ?? data.components[0]?.id;
if (activeId) setQuery((current) => ({ ...current, [activeId]: value }));
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
const activeIndex = data.components.length - 1;
const activeId = data.components[activeIndex]?.id ?? data.components[0]?.id;
const val = (query[activeId ?? ""] ?? "").trim();
if (val) {
createPendingIngredient(activeIndex, val);
setQuery((current) => ({ ...current, [activeId]: "" }));
}
}
}}
/>
{Boolean((query[data.components[data.components.length - 1]?.id ?? data.components[0]?.id ?? ""] ?? "").trim()) && (
<div class="recipe-item-results">
{[
...[...ingredients, ...pendingIngredients].map((item) => ({ ...item, kind: "ingredient" as const })),
...recipes.filter((item) => item.id !== recipeId).map((item) => ({ ...item, kind: "recipe" as const })),
].filter((item) => {
const activeId = data.components[data.components.length - 1]?.id ?? data.components[0]?.id ?? "";
const q = (query[activeId] ?? "").trim().toLowerCase();
return [item.name, ...(item.aliases ?? [])].some((name) => name.toLowerCase().includes(q));
}).slice(0, 8).map((item) => (
<button type="button" onClick={() => {
const activeIndex = data.components.length - 1;
const activeId = data.components[activeIndex]?.id ?? data.components[0]?.id ?? "";
addLine(activeIndex, `${item.kind}:${item.id}`);
setQuery((current) => ({ ...current, [activeId]: "" }));
}}><strong>{item.name}</strong><small>{item.kind === "recipe" ? "Recipe" : "Ingredient"}</small></button>
))}
{(() => {
const activeId = data.components[data.components.length - 1]?.id ?? data.components[0]?.id ?? "";
const q = query[activeId] ?? "";
if (!ingredients.some((item) => normal(item.name) === normal(q)) && !pendingIngredients.some((item) => normal(item.name) === normal(q))) {
return (
<button
type="button"
class="create-ingredient-result"
onClick={() => createPendingIngredient(componentIndex, query[component.id])}
onClick={() => {
const activeIndex = data.components.length - 1;
createPendingIngredient(activeIndex, q);
setQuery((current) => ({ ...current, [activeId]: "" }));
}}
>
<strong>+ Create “{query[component.id].trim()}”</strong>
<strong>+ Create “{q.trim()}”</strong>
<small>New ingredient</small>
</button>
)}
</div>
)}
);
}
return null;
})()}
</div>
<input
class="add-line-notes"
value={draftNotes[component.id] ?? ""}
placeholder="Add notes"
aria-label={`Notes for the new item in ${component.name}`}
onInput={(event) => setDraftNotes((current) => ({ ...current, [component.id]: event.currentTarget.value }))}
/>
</div>
</fieldset>
))}
<div class="structure-add-actions">
<button
type="button"
onClick={() =>
setData((current) => ({
...current,
components: [
...current.components,
{
id: uid("component"),
name: "New component",
items: [],
notes: [],
},
],
}))
}
>
<span aria-hidden="true"></span>
Add Header
</button>
<button
type="button"
onClick={() =>
setData((current) => {
if (current.components.length === 0) {
)}
</div>
<div class="quick-add-notes">
<input placeholder="Add notes" aria-label="Add notes" />
</div>
</div>
<div class="structure-bottom-bar">
<div class="editor-keyboard-hint">
<span><kbd>Enter</kbd> to complete</span>
<span><kbd>Tab</kbd> to add a note</span>
</div>
<div class="structure-add-actions">
<button
type="button"
class="action-link-btn"
onClick={() =>
setData((current) => ({
...current,
components: [
...current.components,
{
id: uid("component"),
name: "",
items: [],
notes: [],
},
],
}))
}
>
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z"/></svg>
Add Header
</button>
<button
type="button"
class="action-link-btn"
onClick={() =>
setData((current) => {
if (current.components.length === 0) {
return {
...current,
components: [{ id: uid("component"), name: "", items: [], notes: [""] }],
};
}
const lastIndex = current.components.length - 1;
return {
...current,
components: [{ id: uid("component"), name: "New component", items: [], notes: [""] }],
components: current.components.map((component, index) =>
index === lastIndex ? { ...component, notes: [...(component.notes ?? []), ""] } : component
),
};
}
const lastIndex = current.components.length - 1;
return {
...current,
components: current.components.map((component, index) =>
index === lastIndex ? { ...component, notes: [...(component.notes ?? []), ""] } : component
),
};
})
}
>
<span aria-hidden="true"></span>
Add Note
</button>
<button class="bulk-add-trigger" type="button" onClick={() => { setBulkError(""); setBulkOpen(true); }}>
Add Ingredients
</button>
})
}
>
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z"/></svg>
Add Note
</button>
<button class="bulk-add-trigger" type="button" onClick={() => { setBulkError(""); setBulkOpen(true); }}>
Add Ingredients
</button>
</div>
</div>
{bulkOpen && (
<div class="bulk-ingredient-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setBulkOpen(false); }}>
@@ -833,12 +929,11 @@ export default function RecipeStructureEditor({
autoFocus
value={prepText}
onInput={(event) => { setPrepText(event.currentTarget.value); setPrepError(""); }}
placeholder={'Dry Mix:\nHeat oven to 350°F\nCombine dry ingredients in a medium bowl\n\nGarnish:\nAdd chopped chocolate chips as garnish\n(note about plating)'}
placeholder={'Cut & Portion Pork Belly:\n1. Dice pork belly into 1" cubes\n2. Transfer into container\n\nTo Marinate:\n3. Mix gochujang, mustard, and oil\n4. Coat pork thoroughly and refrigerate'}
/>
{prepError && <p class="bulk-dialog-error">{prepError}</p>}
<div class="bulk-entry-help">
<span>New line <small>represents a new prep step</small></span>
<span>Add headers <small>using a colon : eg To Garnish:</small></span>
<span>Add headers <small>using a colon : eg To Sear:</small></span>
<span>Add notes to prep method <small>by putting them in (notes)</small></span>
</div>
<footer>
@@ -849,60 +944,137 @@ export default function RecipeStructureEditor({
</div>
)}
{showMethod && (
<section class="method-editor">
<div id="recipe-cover-slot"></div>
<h2>Prep Method <small>{data.steps.length}</small></h2>
<ol>
{data.steps.map((step, index) => {
const stepKind = step.instruction.trim().endsWith(":") ? " prep-heading" : /^\(.+\)$/.test(step.instruction.trim()) ? " prep-note" : "";
return (
<li class={`${dragging?.kind === "step" && dragging.index === index ? "dragging" : ""}${stepKind}`} onDragOver={(event) => event.preventDefault()} onDrop={() => {
if (dragging?.kind === "step") setData((current) => ({ ...current, steps: move(current.steps, dragging.index, index) }));
setDragging(undefined);
}}>
<textarea
rows={2}
value={step.instruction}
onInput={(event) =>
setData((current) => ({
...current,
steps: current.steps.map((value, position) =>
position === index
? { ...value, instruction: event.currentTarget.value }
: value,
),
}))
}
/>
<span class="row-actions">
<button
class="remove-row"
onClick={() =>
setData((current) => ({
...current,
steps: current.steps.filter(
(_, position) => position !== index,
),
}))
}
disabled={data.steps.length === 1}
>
</button>
<span class="drag-handle" title="Drag to reorder" draggable onDragStart={() => setDragging({ kind: "step", index })}>⠿</span>
</span>
</li>
)})}
</ol>
<button
class="secondary-action"
onClick={() => { setPrepError(""); setPrepOpen(true); }}
>
Add Prep Steps
</button>
<div id="recipe-additional-slot"></div>
<div id="recipe-tab-panel-slot"></div>
</section>
methodTarget ? createPortal(
<section class="method-editor">
<div class="method-header-row">
<h2>Prep Method <small>{data.steps.length}</small></h2>
</div>
<ol class="method-steps-list">
{data.steps.map((step, index) => {
const stepKind = step.instruction.trim().endsWith(":") ? " prep-heading" : /^\(.+\)$/.test(step.instruction.trim()) ? " prep-note" : "";
return (
<li class={`method-step-card${dragging?.kind === "step" && dragging.index === index ? " dragging" : ""}${stepKind}`} onDragOver={(event) => event.preventDefault()} onDrop={() => {
if (dragging?.kind === "step") setData((current) => ({ ...current, steps: move(current.steps, dragging.index, index) }));
setDragging(undefined);
}}>
<span class="step-num">{index + 1}.</span>
<div class="step-body">
<textarea
rows={2}
class="step-textarea"
placeholder="Add Prep Step"
value={step.instruction}
onInput={(event) =>
setData((current) => ({
...current,
steps: current.steps.map((value, position) =>
position === index
? { ...value, instruction: event.currentTarget.value }
: value,
),
}))
}
/>
</div>
<div class="step-actions">
<button
type="button"
class="remove-step-btn"
title="Delete step"
onClick={() =>
setData((current) => ({
...current,
steps: current.steps.filter(
(_, position) => position !== index,
),
}))
}
disabled={data.steps.length === 1}
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M7 11v2h10v-2zm5-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8"/></svg>
</button>
<span class="drag-handle" title="Drag to reorder" draggable onDragStart={() => setDragging({ kind: "step", index })}>
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path fill="currentColor" d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2"/></svg>
</span>
</div>
</li>
)})}
</ol>
<div class="method-footer-actions">
<button
type="button"
class="action-link-btn"
onClick={() =>
setData((current) => ({
...current,
steps: [
...current.steps,
{ id: uid("step"), instruction: "New Section:", equipment_ids: [] },
],
}))
}
>
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z"/></svg>
Add Header
</button>
<button
type="button"
class="action-link-btn"
onClick={() =>
setData((current) => ({
...current,
steps: [
...current.steps,
{ id: uid("step"), instruction: "(Note)", equipment_ids: [] },
],
}))
}
>
<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6z"/></svg>
Add Note
</button>
<button
type="button"
class="bulk-prep-btn"
onClick={() => { setPrepError(""); setPrepOpen(true); }}
>
Add Prep Steps
</button>
</div>
</section>,
methodTarget
) : (
<section class="method-editor">
<div class="method-header-row">
<h2>Prep Method <small>{data.steps.length}</small></h2>
</div>
<ol class="method-steps-list">
{data.steps.map((step, index) => (
<li class="method-step-card" key={step.id}>
<span class="step-num">{index + 1}.</span>
<div class="step-body">
<textarea
rows={2}
class="step-textarea"
placeholder="Add Prep Step"
value={step.instruction}
onInput={(event) =>
setData((current) => ({
...current,
steps: current.steps.map((value, position) =>
position === index
? { ...value, instruction: event.currentTarget.value }
: value,
),
}))
}
/>
</div>
</li>
))}
</ol>
</section>
)
)}
</section>
);
+3516 -405
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
/**
* Meez Unified Design System Tokens
* Measured directly from live authenticated Meez session
*/
:root {
/* Surface Colors */
--meez-bg-page: #f3f3f3;
--meez-bg-card: #fbfbfb;
--meez-bg-card-alt: #f1f5fe;
--meez-bg-card-hover: #f1f5fe;
--meez-bg-white: #ffffff;
--meez-bg-active-pill: #dbe4ff;
/* Text & Ink Colors */
--meez-text-primary: #050841;
--meez-text-secondary: #202962;
--meez-text-muted: #a5a9c1;
--meez-text-subtle: #757677;
--meez-text-gray: #8283a0;
--meez-text-body: rgba(0, 0, 0, 0.87);
--meez-text-danger: #f63d48;
/* Brand, Type Badge & Accent Colors */
--meez-blue: #3d5df6;
--meez-blue-hover: #304fdf;
--meez-blue-active: #1236e1;
--meez-blue-tint: #f1f5fe;
--meez-blue-badge: #dbe4ff;
--meez-type-recipe: #3c4679;
--meez-type-ingredient: #3f908a;
--meez-type-book: #f3a642;
--meez-type-purchase: #3f908a;
--meez-green: #3f908a;
--meez-green-dark: #20ab85;
--meez-orange: #f3a642;
--meez-danger: #f63d48;
--meez-danger-hover: #e02834;
--meez-danger-bg: #fff1f2;
--meez-danger-tint: #fff0e8;
--meez-danger-dark: #9a3412;
/* Border Colors */
--meez-border-subtle: #f3f3f3;
--meez-border-default: #ececec;
--meez-border-strong: #dfe3ec;
--meez-border-divider: #eeeef3;
/* Elevation Shadows (Material UI standard elevations) */
--meez-shadow-1: 0 1px 2px rgba(15, 23, 42, 0.04);
--meez-shadow-elevation-2: 0 2px 1px -1px rgba(0,0,0,0.2), 0 1px 1px 0 rgba(0,0,0,0.14), 0 1px 3px 0 rgba(0,0,0,0.12);
--meez-shadow-elevation-4: 0 2px 4px -1px rgba(0,0,0,0.2), 0 4px 5px 0 rgba(0,0,0,0.14), 0 1px 10px 0 rgba(0,0,0,0.12);
--meez-shadow-elevation-8: 0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 10px 1px rgba(0,0,0,0.14), 0 3px 14px 2px rgba(0,0,0,0.12);
--meez-shadow-elevation-24: 0 11px 15px -7px rgba(0,0,0,0.2), 0 24px 38px 3px rgba(0,0,0,0.14), 0 9px 46px 8px rgba(0,0,0,0.12);
/* Border Radii */
--meez-radius-xs: 2px;
--meez-radius-sm: 4px;
--meez-radius-md: 6px;
--meez-radius-lg: 8px;
--meez-radius-xl: 10px;
--meez-radius-pill: 100px;
--meez-radius-chip: 999px;
--meez-radius-circle: 50%;
/* Transitions */
--meez-transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1);
--meez-transition-normal: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
/* Fonts */
--meez-font-sans: "CircularCustCapNum", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}