chore(backend): implement high-priority improvements from code review

- Configuration: dotenv support, env vars for PORT, DB_PATH, CORS_ORIGIN, rate limits
- Security: API key auth for write endpoints, rate limiting on import, configurable CORS, image URL normalization
- Reliability: DB transactions for create/update, dirty flag for saves, foreign key enforcement (PRAGMA), duplicate detection O(1) optimization
- Features: health check endpoint (/api/health)
- Bugfix: corrected tag assignment/removal routes (param order)
- Testing: added tests for PUT/DELETE recipes, tag CRUD and assignment, enabled foreign keys in tests
- All 46 backend tests passing

Closes #<ticket-if-any>
This commit is contained in:
Paul Huliganga 2026-03-29 23:11:27 -04:00
parent 163455f834
commit 8b729d7fc4
110 changed files with 7268 additions and 1154 deletions

20
.env.example Normal file
View File

@ -0,0 +1,20 @@
# Server configuration
PORT=3000
DATABASE_PATH=data/recipes.db
NODE_ENV=development
# CORS configuration
# Set to specific origin in production (e.g., https://your-frontend.com)
# Use * for development only
ALLOWED_ORIGIN=*
# Rate limiting (import endpoint)
IMPORT_RATE_LIMIT_WINDOW_MS=60000
IMPORT_RATE_LIMIT_MAX_REQUESTS=10
# API authentication (optional but recommended for production)
# Set a strong random string if API_KEY is defined; write endpoints will require it
#API_KEY=
# Logging
LOG_LEVEL=info

View File

@ -0,0 +1,70 @@
# T06 — Styling Guardrails + Lightweight Governance
Date: 2026-03-27
Scope: `recipe-manager/frontend`
## Why this exists
The styling stabilization pass established a token + primitive contract. This governance doc keeps that contract from drifting back into mixed styling modes.
## Canonical styling contract (short version)
1. **Tokens are defined in one place:** `src/styles/tokens.css`
2. **Build UI from shared primitives first:** `src/components/ui/primitives.tsx` + `ui-*` classes from `src/index.css`
3. **Use tokenized values in classes/styles:** `var(--...)` or Tailwind theme aliases mapped to tokens
4. **Do not add new raw Tailwind palette utilities** (e.g. `bg-blue-500`, `text-slate-600`) in stabilized shared surfaces
5. **Do not hardcode hex colors in `className`** (e.g. `bg-[#1f2937]`) for app UI
Reference docs:
- `./styling-token-contract.md`
- `./ui-primitive-contract.md`
- `./t05-legacy-style-debt-cleanup.md`
## Lightweight automated guardrail
A scoped check is available:
```bash
cd frontend
npm run style:guardrails
```
Current guard scope (intentional, low-risk):
- `src/App.tsx`
- `src/components/MissionControlPanel.tsx`
- `src/components/ui/primitives.tsx`
What it flags:
- direct palette utility classes (`bg-blue-*`, `text-slate-*`, etc.)
- hex colors in arbitrary utility classes (`bg-[#...]`, `text-[#...]`, etc.)
If violations are found in guarded files, the script exits non-zero.
## Contributor checklist (copy into PR template/comments as needed)
- [ ] New UI uses `Ui*` primitives or existing `ui-*` classes where possible
- [ ] Any new visual token was added to `tokens.css` first
- [ ] No new hardcoded palette utility classes in stabilized shell/shared primitive areas
- [ ] No new hardcoded hex colors in class utilities for app surfaces
- [ ] `npm run style:guardrails` passes
- [ ] `npm run lint` passes
## Review heuristics for agents/contributors
When touching frontend UI, quickly answer:
1. "Can this use an existing `Ui*` primitive?"
2. "If I need a new color/radius/shadow, did I add a semantic token first?"
3. "Am I introducing a second styling path that future edits will copy?"
If the answer to (3) is yes, pause and refactor to the contract.
## Intentional limitations (for now)
- The guard script is **scoped**, not repo-wide, because existing pages still contain known legacy palette usage (tracked in T01/T04).
- The check is intentionally simple regex-based (fast, no lint plugin churn).
- This is not wired into heavy CI yet; it is run locally and can be added to future CI once high-drift pages are migrated.
## Expansion path after T04/T07
As additional pages are stabilized, add them to `frontend/scripts/style-guardrails.mjs` scope to progressively tighten enforcement without breaking current workflows.

View File

@ -0,0 +1,184 @@
# T01 — Styling Inventory + Drift Map
Date: 2026-03-27
Project: `recipe-manager/frontend`
Task: **T01 — Styling Inventory + Drift Map**
## Scope audited
- Token sources: `src/styles/tokens.css`, `src/theme.ts`, `frontend/tailwind.config.js`
- Shared styling layer: `src/index.css` (`ui-*` primitives)
- Legacy stylesheet: `src/App.css`
- App shell + representative screens/components:
- `src/App.tsx`
- `src/pages/RecipeListPage.tsx`
- `src/pages/RecipeDetailPage.tsx`
- `src/pages/CookModePage.tsx`
- `src/pages/ImportUrlPage.tsx`
- `src/pages/NotFoundPage.tsx`
- `src/components/RecipeCard.tsx`
- `src/components/RecipeForm.tsx`
- `src/components/TagSelector.tsx`
- `src/components/Toast.tsx`
- `src/components/MissionControlPanel.tsx`
- `src/components/ErrorBoundary.tsx`
---
## 1) Where styling primitives currently live
### Canonical CSS token surface (already strong)
- **`src/styles/tokens.css`**
- semantic color, typography, spacing, radius, shadow, focus, gradients
- includes dark-mode token overrides
### Utility-class primitives (already present)
- **`src/index.css` @layer components** defines:
- `.ui-page`, `.ui-section`, `.ui-card`
- `.ui-btn`, `.ui-btn-primary`, `.ui-btn-secondary`
- `.ui-input`, `.ui-textarea`, `.ui-select`
- `.ui-chip`, `.ui-badge`
- Also contains global focus-visible behavior and utility legacy classes (`.card`, `.shadow-card`, `animate-slide-in`)
### Tailwind token mapping
- **`frontend/tailwind.config.js`** maps many Tailwind theme keys to CSS vars (good bridge)
### Parallel TS token layer (drift risk)
- **`src/theme.ts`** duplicates many token values from `tokens.css`
- `colors`, `typography`, `spacing`, `radius`, `shadows`, `componentStyles`
### Legacy stylesheet
- **`src/App.css`** contains Vite/demo-era nested selectors (`#next-steps`, `.hero`, `.counter`, etc.)
- not imported by `main.tsx` or other src files in current app
---
## 2) Systems currently being mixed
1. **UI primitives + token vars** (`ui-*`, `var(--...)`)
2. **Raw Tailwind palette utilities** (`bg-blue-*`, `text-slate-*`, `border-gray-*`, etc.)
3. **Inline style objects via `theme.ts`** (`style={{ borderRadius: radius.lg }}`, `boxShadow: shadows.card`)
4. **One-off arbitrary values** (`hover:[box-shadow:var(--shadow-hover)]`, `shadow-[...]`)
5. **Legacy class debt** (`App.css`, and old helper classes in `index.css`)
Result: there is no enforced single styling path; authors can pick multiple parallel approaches in the same file.
---
## 3) Inventory by file (top-level pages + shared components)
Legend:
- **A** = ui-* primitives + var tokens (preferred direction)
- **B** = raw Tailwind palette utilities (non-tokenized drift)
- **C** = inline `theme.ts` style objects
- **D** = legacy/older pattern
### App shell
- `src/App.tsx` → **B-heavy**
- Header/nav/footer use many `blue/slate/gray` classes and custom focus ring colors
- Does not use `ui-page`, `ui-section`, `ui-btn` contract
### Pages
- `src/pages/RecipeListPage.tsx` → **A + C mix (plus some B)**
- strong `ui-*` adoption
- frequent inline radius/style injections from `theme.ts`
- `src/pages/RecipeDetailPage.tsx` → **A + B + C mixed**
- uses many `ui-*` classes but also many non-token Tailwind palette classes and inline styles
- `src/pages/CookModePage.tsx` → **B-only (high drift)**
- almost entirely gray/blue/green/red Tailwind palette utilities
- no `ui-*` shell/primitives
- `src/pages/ImportUrlPage.tsx` → **B + C (high drift)**
- heavily uses slate/blue/indigo/green palette classes
- inline radius/progress width styles
- `src/pages/NotFoundPage.tsx` → **B-only**
### Shared components
- `src/components/RecipeForm.tsx` → **A-dominant**
- `src/components/TagSelector.tsx` → **A-dominant + tiny C**
- `src/components/RecipeCard.tsx` → **A + C mixed**
- `src/components/Toast.tsx` → **C-dominant**
- visual appearance mostly from `theme.ts` inline styles
- `src/components/MissionControlPanel.tsx` → **B-only**
- `src/components/ErrorBoundary.tsx` → **B-only**
### Approximate drift signal (quick scan)
- Highest raw palette concentration: `CookModePage`, `ImportUrlPage`, `App.tsx`, `RecipeDetailPage`
- Highest inline-style concentration: `RecipeListPage`, `RecipeCard`, `Toast`
---
## 4) Representative drift hotspots
1. **`App.tsx` (app shell)**
- Entire global frame (header/nav/footer) is on blue/slate palette utilities
- creates visual disconnect from tokenized warm palette used in recipe surfaces
2. **`CookModePage.tsx`**
- Separate visual language (blue/gray success/error blocks) and no `ui-*` primitives
- likely to regress independently if token theme changes
3. **`ImportUrlPage.tsx`**
- Similar drift pattern to CookModePage; raw utility palette + custom gradients
4. **`RecipeDetailPage.tsx`**
- Mixed approach in same component: token vars + ui-* + raw palette + inline styles
- hard to predict and maintain consistency
5. **`Toast.tsx` + `theme.ts` coupling**
- toast visuals encoded in TS style objects instead of ui primitives/token classes
6. **`App.css` + legacy selectors in `index.css`**
- `App.css` appears unused yet present
- `.card` / `.shadow-card` coexist with `ui-card`, increasing ambiguity
---
## 5) Highest-risk problem areas
- **Token drift risk**: same values duplicated in `tokens.css` and `theme.ts`.
- **App-shell inconsistency risk**: `App.tsx` not on design-system primitives.
- **Page-level divergence risk**: CookMode/Import URL/NotFound/MissionControl use different style dialect than core recipe pages.
- **Maintainability risk**: inline style overrides (`borderRadius`, `boxShadow`) bypass primitive contract and encourage per-component customization.
- **Regression risk**: no guardrails prevent new raw palette utilities in future UI work.
---
## 6) First conversion targets (for stabilization wave)
1. `src/App.tsx` (global shell contract)
2. `src/components/MissionControlPanel.tsx` (small, easy, high-visibility drift)
3. `src/pages/CookModePage.tsx` (largest isolated drift surface)
4. `src/pages/ImportUrlPage.tsx` (same drift pattern as CookMode)
5. `src/components/Toast.tsx` (inline-style dependency cleanup)
---
## 7) Acceptance criteria for stabilization work (T02+)
A stabilization pass is accepted when all are true:
1. **Single token authority**
- CSS vars in `tokens.css` are canonical.
- `theme.ts` no longer independently defines conflicting visual token values.
2. **Primitive contract adoption**
- app shell + major pages use shared `ui-*` primitives (or wrapper components built from them).
- new features avoid ad-hoc color utilities outside tokenized scheme.
3. **Inline-style reduction**
- no routine radius/shadow/color inline overrides where equivalent primitive/token class exists.
4. **Legacy debt disposition**
- `App.css` removed or explicitly retained with documented rationale.
- overlapping legacy helpers (`.card`, `.shadow-card`) either standardized or deprecated.
5. **Governance check**
- lightweight styling checklist/doc exists and is referenced in workflow.
---
## 8) Suggested next task after T01
**T02 — Canonical Token Source Lock**
- lock CSS tokens as source of truth
- redefine `theme.ts` as typed accessor only (or trim to non-visual helpers)
- document token contract before broad conversion starts

View File

@ -0,0 +1,132 @@
# T07 — Styling Stabilization QA Pass
Date: 2026-03-28
Task: **T07 — Stabilization QA Pass**
Scope: QA review of T02T06 outcomes in `recipe-manager/frontend`
## What was reviewed
Read docs:
- `.harness/styling-stabilization-execution-board.md`
- `.harness/docs/styling-inventory.md`
- `.harness/docs/styling-token-contract.md`
- `.harness/docs/ui-primitive-contract.md`
- `.harness/docs/styling-governance.md`
- `.harness/docs/t05-legacy-style-debt-cleanup.md`
- `docs/visual-audit/after/homepage-qa-note-t04.md`
Inspected implementation surfaces:
- `src/styles/tokens.css`
- `src/theme.ts`
- `src/index.css`
- `src/components/ui/primitives.tsx`
- `src/App.tsx`
- `src/components/MissionControlPanel.tsx`
- `src/pages/RecipeListPage.tsx`
- `src/pages/CookModePage.tsx`
- `src/pages/ImportUrlPage.tsx`
- `src/components/RecipeCard.tsx`
- `src/components/Toast.tsx`
- `src/components/ErrorBoundary.tsx`
- `src/pages/NotFoundPage.tsx`
- `scripts/style-guardrails.mjs`
- `frontend/package.json`
Validation commands:
- `npm run style:guardrails` ✅ pass
- `npm run build` ✅ pass
- `npm run lint` ❌ fails (pre-existing non-styling issues; see findings)
- grep scan for raw palette utilities in app/page/component surfaces
---
## QA findings by severity
### High
1. **Known drift still present in unguarded surfaces (`ErrorBoundary`, `NotFoundPage`)**
- `src/components/ErrorBoundary.tsx` still uses raw Tailwind palette utilities (`bg-gray-*`, `text-gray-*`, `bg-blue-*`, `text-red-*`).
- `src/pages/NotFoundPage.tsx` still uses raw palette (`text-gray-*`, `bg-blue-*`).
- Risk: these are user-visible shell/error states and create immediate visual inconsistency with tokenized surfaces.
### Medium
2. **Guardrail scope is still too narrow for current stabilized claims**
- Current `style:guardrails` only enforces:
- `src/App.tsx`
- `src/components/MissionControlPanel.tsx`
- `src/components/ui/primitives.tsx`
- High-drift but now partially stabilized files (e.g., `RecipeListPage`, `ImportUrlPage`, `CookModePage`, `RecipeCard`) are not protected yet.
3. **Residual non-token palette usage remains in otherwise stabilized pages/components**
- `src/pages/RecipeListPage.tsx`: hero overlay gradient uses `from-slate-900...` / `via-slate-900...`.
- `src/components/RecipeCard.tsx`: card image overlay uses `from-slate-950...` / `via-slate-900...`.
- `src/components/Toast.tsx`: close button hover uses `hover:text-gray-200`.
- These are limited, but they are still direct palette leakage.
4. **Release hygiene risk: lint gate is red**
- `npm run lint` fails due to existing React/TS lint errors in `App.tsx`, `CookModePage.tsx`, `RecipeDetailPage.tsx`, `api.ts`, `types/api-aux.ts`.
- Not strictly a styling blocker, but it weakens governance confidence and CI-readiness.
### Low
5. **`theme.ts` still contains non-token hex values in `recipeAccentPalette`**
- Most token drift was eliminated correctly, but this transitional array still includes raw hex values.
- This is documented as an exception in T02; low-risk but still outside ideal contract purity.
---
## What is now considered stabilized
- **Token source authority:** `tokens.css` is effectively canonical; `theme.ts` now primarily references CSS vars instead of duplicating hardcoded token values.
- **Primitive layer exists and is adopted:** `UiPage`, `UiSection`, `UiCard`, `UiButton`, `UiChip`, `UiBadge`, `cn()` are implemented and used in key shell/surface paths.
- **App shell conversion landed:** `App.tsx` now follows tokenized/primitive-aligned styling patterns.
- **Mission Control conversion landed:** `MissionControlPanel.tsx` moved onto tokenized primitive usage.
- **Legacy cleanup landed:** `src/App.css` removed; legacy `.card` alias removed from `index.css`.
- **Governance baseline landed:** written governance + runnable guardrail command in package scripts.
---
## What still needs work
1. **Close remaining obvious drift surfaces**
- Convert `ErrorBoundary` and `NotFoundPage` to tokenized/primitive patterns.
2. **Finish palette cleanup on media overlays/edge controls**
- Replace remaining `slate-*`/`gray-*` utility references in `RecipeListPage`, `RecipeCard`, and `Toast` with semantic tokens.
3. **Expand guardrail coverage in phases**
- Add stabilized pages/components to `scripts/style-guardrails.mjs` scope incrementally (start with `NotFoundPage`, `ErrorBoundary`, `RecipeListPage`, `RecipeCard`, `Toast`).
4. **Address lint debt to improve release confidence**
- Resolve outstanding lint errors (especially `react-hooks/set-state-in-effect` and explicit `any` violations) so governance checks can become stronger CI gates.
5. **Optional contract hardening**
- Replace non-token hexes in `recipeAccentPalette` with token references.
---
## Recommended next execution-board task
**T08 (new): Guardrail Scope Expansion + Residual Drift Cleanup**
Proposed scope:
- Convert `ErrorBoundary` + `NotFoundPage` to tokenized primitives.
- Remove remaining raw palette classes in `RecipeListPage`, `RecipeCard`, `Toast`.
- Expand `style:guardrails` file scope to include these surfaces.
- Keep changes low-risk and styling-only.
If no new task id is allowed, treat this as **T07 follow-on patch set** before final signoff.
---
## Release gate status (from execution board)
- [x] One canonical token source with no major CSS/TS drift
- [x] Base primitives centralized and used across high-traffic surfaces
- [~] No new ad-hoc color classes outside tokenized set (mostly true, residual exceptions remain)
- [x] Legacy scaffold artifacts removed/documented
- [x] Governance doc + guardrail command exist
Overall: **Near-stable, not fully closed** due to residual palette drift and narrow guardrail scope.
## Ready for review
**Yes (with follow-on cleanup required before final “fully stabilized” signoff).**

View File

@ -0,0 +1,50 @@
# Styling Token Contract (T02)
Date: 2026-03-27
Scope: `recipe-manager/frontend`
## Canonical token source
**Single source of truth:**
- `src/styles/tokens.css`
All design-token values (color, typography scales, spacing, radius, elevation/focus) must be authored in `tokens.css` first.
## Allowed token consumption paths
1. **Preferred in UI markup:** tokenized classes and `ui-*` primitives from `src/index.css`
- Examples: `ui-card`, `ui-btn`, `text-[var(--text)]`, `border-[var(--border)]`
2. **Tailwind theme keys** mapped to CSS vars in `tailwind.config.js`
- Examples: `bg-surface`, `text-primary`, `shadow-card`
3. **TS access layer:** `src/theme.ts`
- Must expose `var(--...)` references only.
- Must not hardcode competing token values.
## Explicit non-contract patterns
- Do **not** introduce new hardcoded design-token values in `theme.ts`.
- Do **not** define duplicate token constants in TS that can drift from `tokens.css`.
- Avoid ad-hoc palette classes (`bg-blue-*`, `text-slate-*`, etc.) in shared shell/feature UI when tokenized equivalents exist.
## `theme.ts` role after T02
`theme.ts` is now a **typed token accessor/compatibility layer**, not an independent token definition file.
- ✅ Allowed: `colors.primary = 'var(--color-primary)'`
- ❌ Not allowed: `colors.primary = '#ea580c'`
If a token does not exist yet:
1. Add it to `tokens.css`
2. (Optional) Map it in `tailwind.config.js` if utility-class access is needed
3. Expose accessor in `theme.ts` only as `var(--token-name)`
## Practical authoring guide (for future tasks)
- Use `ui-*` classes first for common controls/layout shells.
- Use tokenized Tailwind utilities or `var(--...)` references for one-off styling.
- Keep inline `style={{ ... }}` for runtime/dynamic values only (e.g., tag color from DB), not for static design tokens.
## Current known exceptions (post-T02)
- Some components/pages still import `radius`/`colors` from `theme.ts` for inline styling; values are now token references, so no hardcoded drift remains.
- `recipeAccentPalette` still includes a few non-tokenized accent hexes pending a future semantic palette pass.

View File

@ -0,0 +1,40 @@
# T05 — Legacy Style Debt Cleanup
Date: 2026-03-27
Project: `recipe-manager/frontend`
## Scope
Conservative cleanup of clearly obsolete style artifacts after token/primitive contract stabilization.
## Changes made
### 1) Removed unused legacy scaffold stylesheet
- Deleted: `frontend/src/App.css`
- Rationale: file contained Vite starter/demo selectors (`.hero`, `.counter`, `#next-steps`, etc.) and is not imported by `main.tsx` or `App.tsx`.
- Risk: low (no import path, no app references).
### 2) Removed dead legacy helper selector from global stylesheet
- Updated: `frontend/src/index.css`
- Removed legacy `.card` selector alias block and kept `.shadow-card` (still actively used).
- Before: `.card, .shadow-card { ... }` plus `.shadow-card { ... }`
- After: `.shadow-card { ... }` only.
- Rationale: avoid duplicate/ambiguous card styling pathways now that `ui-card` is the primitive contract.
## Validation
- Verified no `App.css` imports in current frontend source.
- Verified no `className="card"` usage in current frontend source.
- Build check: `npm run build` passes.
## Deferred legacy debt (intentionally not removed)
1. **`.shadow-card` utility** in `index.css` — still used across pages/components (`RecipeForm`, `RecipeListPage`, `RecipeDetailPage`), so not safe to remove in T05.
2. **`.animate-slide-in` + `@keyframes slide-in`** — used by `components/Toast.tsx`; retained.
3. **Residual inline style usage from `theme.ts`** (e.g., `Toast`, `RecipeCard`) — not removed here to avoid broader UI behavior changes; belongs to follow-up conversion task.
4. **Responsive global utility overrides in `index.css` (`.max-w-*`, `.p-*`)** — potentially legacy/scaffold-ish but high blast-radius; defer pending design QA.
## Recommendation (next task)
Proceed with **T06 — Guardrails + Lightweight Governance**:
- codify that new UI must use `ui-*`/primitives and tokenized vars,
- add a checklist to prevent re-introducing dead aliases and raw palette drift.
## Ready for review
**Yes**

View File

@ -0,0 +1,91 @@
# T03 — UI Primitive Contract (Recipe Manager UI Kit)
Date: 2026-03-27
Scope: `recipe-manager/frontend`
## Goal
Stabilize core UI primitives so pages stop re-implementing button/card/chip/layout class strings.
This is a **low-risk contract layer**, not a redesign system.
---
## Contract surface (v1)
Implemented in:
- `frontend/src/components/ui/cn.ts`
- `frontend/src/components/ui/primitives.tsx`
### `cn(...values)`
- Lightweight class composer used by primitives and page-level variant helpers.
- Avoids repetitive template-string class logic.
### `UiPage`
- Canonical page shell wrapper (`ui-page` + optional additions).
- Use for top-level layout regions (header container, footer container, page root if needed).
### `UiSection`
- Canonical section shell (`ui-section`) with controlled padding options:
- `padding="md"` (default)
- `padding="lg"`
- `padding="none"`
- Use for grouped content blocks and major page sections.
### `UiCard`
- Canonical card surface (`ui-card`) with optional tone:
- `tone="default"`
- `tone="muted"`
- Use for repeatable content cards and skeleton containers.
### `UiButton`
- Canonical button primitive (`ui-btn` + variant mapping):
- `variant="primary"`
- `variant="secondary"` (default)
- Use for interactive `<button>` actions.
### `UiLinkButton`
- Anchor-style button for actual links (kept for future use where semantic `<a>` is needed).
### `UiChip` / `UiBadge`
- Canonical chip and badge wrappers around `ui-chip` / `ui-badge`.
- Use for compact status pills/filter tokens.
---
## Representative adoption in T03
### 1) App shell (`src/App.tsx`)
- Header and footer wrappers now use `UiPage`.
- Navigation active/inactive styles now use a `cn(...)` helper contract rather than bespoke duplicated class strings.
- Shell color treatment shifted to tokenized variables (`var(--...)`) instead of slate/blue freelancing.
### 2) Mission Control (`src/components/MissionControlPanel.tsx`)
- Converted from gray utility block to tokenized contract primitives:
- `UiSection` for panel shell
- `UiChip` for status pills
- Removes bespoke gray class styling drift from this shared panel.
### 3) Recipe list page (`src/pages/RecipeListPage.tsx`)
- Core repeated shells now use primitives:
- hero wrapper and filter blocks via `UiSection`
- feature cards and loading skeleton via `UiCard`
- key actions (`Search`, `Load More`, `Clear all filters`) via `UiButton`
- This proves the contract on a high-traffic page without broad redesign.
---
## Usage guidance for follow-on tasks
1. Prefer `UiSection`/`UiCard`/`UiButton` before writing new utility-heavy class blocks.
2. Use tokenized vars (`var(--...)`) for one-off values; avoid raw palette classes in shared surfaces.
3. Keep inline `style={{ ... }}` for runtime values only (e.g., tag color from data), not static radius/shadow/colors.
4. When classes need conditional composition, use `cn(...)` in one helper instead of many inlined strings.
---
## Intentionally deferred (not in T03)
- Broad conversion of `CookModePage`, `ImportUrlPage`, and full `RecipeDetailPage`.
- Full button/link semantic normalization (e.g., dedicated router-aware LinkButton primitive).
- Removal of all legacy one-off class usage in older components.
These are better handled in T04/T05 to keep T03 low-risk.

View File

@ -0,0 +1,234 @@
# Recipe Manager — Image Import & Backend Hardening Execution Board
Created: 2026-03-27
Owner: Main Orchestrator
Status: READY
## Objective
Ship a reliable URL import pipeline that returns high-quality `image_url` values and harden backend safety/perf around migrations, search, and TypeScript correctness.
## Current Repo Reality (Baseline)
- `POST /api/import/url` is currently a stub returning `{ draft_recipe: null }` (`src/backend/routes/import.ts`).
- URL fetch + JSON-LD extraction foundation exists (`src/backend/services/UrlImportService.ts`) but is not wired to route.
- Schema.org/heuristic parser services are minimal and weakly typed (`any` heavy).
- `image_url` exists in schema/repository/routes/seed (`schema.sql`, `RecipeRepository`, `recipes.ts`), but no explicit image validation/fallback policy.
- Runtime migration helper exists (`applyRuntimeMigrations`) with no direct migration tests.
- Search currently uses `%LIKE%` joins across recipes/ingredients/tags; indexes are limited and query plan unverified.
- TS strict mode is on, but `any` and contract drift remain in backend/frontend types.
## Release Gate (Done Definition)
- [ ] `/api/import/url` performs real fetch/parse flow and returns non-empty `draft_recipe` for supported pages.
- [ ] Image extraction ranking + URL validation policy is implemented and tested.
- [ ] Runtime migration behavior is covered by tests for pre/post `image_url` schemas.
- [ ] Search performance/query behavior validated with index and query-shape improvements.
- [ ] TS hygiene pass removes prioritized unsafe `any` in import/search pathways.
---
## Task Backlog
### T01 — Import Route Orchestration (Wire Real Pipeline)
Priority: P0
Owner: agent-import-core
Dependencies: none
Deliverables:
- Replace stub in `src/backend/routes/import.ts` with real pipeline:
- validate URL input via zod
- call `UrlImportService.fetchFromUrl`
- parse JSON-LD blocks and select recipe candidate
- fallback to heuristic parser when schema parse fails
- return structured `UrlImportResult` payload expected by frontend (`draft_recipe`, `source_url`, parse metadata)
- Error mapping from `UrlImportError` to stable HTTP/API errors for UI (`timeout`, `network`, `unsupported content`, parse failure).
Acceptance:
- [ ] Import route no longer returns hardcoded null draft.
- [ ] `src/backend/tests/import.test.ts` includes success + invalid URL + timeout/network/content-type failures.
- [ ] Frontend import page can reach “review” stage from at least one fixture HTML sample.
---
### T02 — Schema.org Image Extraction Quality Pass
Priority: P0
Owner: agent-import-parser
Dependencies: T01
Deliverables:
- Refactor `SchemaOrgRecipeParserService` to support common image variants:
- string URL
- array of URLs
- array/object entries with `url`, `contentUrl`, `thumbnailUrl`
- `@graph` recipe object selection when JSON-LD block is graph-shaped
- Add image candidate ranking (prefer HTTPS, largest/default image over tiny thumbnails when width/height available).
- Return normalized draft with stable `image_url` candidate.
Acceptance:
- [ ] Parser tests added with fixtures for at least 5 JSON-LD image shapes.
- [ ] For each fixture, expected top `image_url` is asserted.
- [ ] No parser path throws on malformed/partial image fields; degrades gracefully.
---
### T03 — Image URL Validation & Fallback Policy
Priority: P0
Owner: agent-import-hardening
Dependencies: T02
Deliverables:
- Implement central image URL sanitizer/validator utility (new service module):
- allow `http/https` only (or strict `https` with optional downgrade rule)
- reject `data:`, `javascript:`, blob/non-web URLs
- trim + normalize empty values to `null`
- optional host allow/deny controls (documented default policy)
- Integrate validator in:
- import parsing output
- recipe create/update flows (`routes/recipes.ts` + repository normalization layer)
- Define fallback order for import draft image:
1) validated schema.org image
2) validated heuristic image
3) null (no placeholder persisted)
Acceptance:
- [ ] Unit tests cover allow/reject matrix for image URLs.
- [ ] Creating/updating recipe with invalid image URL is predictably rejected or nulled per policy.
- [ ] Policy documented in `docs/api.md` (import + recipe payload behavior).
---
### T04 — Migration Coverage for `recipes.image_url`
Priority: P1
Owner: agent-db-safety
Dependencies: none (can run parallel to T02/T03)
Deliverables:
- Add migration tests for `applyRuntimeMigrations`:
- DB with no `recipes` table (no-op)
- DB with `recipes` but no `image_url` (column added)
- DB already containing `image_url` (idempotent)
- Add integration check around `migrate.ts`/`database.ts` startup path to ensure migration executes before repository usage.
Acceptance:
- [ ] Dedicated test file exists under `src/backend/tests` (or `src/backend/db/__tests__`).
- [ ] Tests assert column presence via `PRAGMA table_info(recipes)`.
- [ ] Re-running migration tests proves idempotence.
---
### T05 — Import Contract Alignment (Frontend/Backend Types)
Priority: P1
Owner: agent-contracts
Dependencies: T01
Deliverables:
- Define shared import response contract (backend + frontend) for:
- `draft_recipe`
- `source_url`
- parse provenance (`schema_org_used`, `heuristic_used`, warning list)
- Align frontend `UrlImportResult` and backend route payload to avoid required-field mismatch.
- Ensure `image_url` is represented consistently in frontend recipe interfaces where used by UI components.
Acceptance:
- [ ] TypeScript build/test passes without contract casts for import payload.
- [ ] Import UI displays parse metadata without runtime undefined errors.
- [ ] `frontend/src/types/*` and backend response shape are synchronized.
---
### T06 — Search Query + Index Optimization
Priority: P1
Owner: agent-data-perf
Dependencies: none
Deliverables:
- Review/adjust recipe search query shape in `RecipeRepository.findAll/count` to reduce costly wide joins where possible.
- Add/validate indexes to support current search/filter path (evaluate at minimum):
- `recipes(created_at)` for default order
- `recipe_tags(recipe_id)` complementing existing `recipe_tags(tag_id)`
- optional composite/index refinements based on `EXPLAIN QUERY PLAN`
- Capture query plan before/after on seeded dataset.
Acceptance:
- [ ] Query plan evidence saved under `docs/perf/search-query-plan.md`.
- [ ] Search + tag filter behavior unchanged functionally (existing tests still pass).
- [ ] Measured improvement or justified no-op documented.
---
### T07 — TS Hygiene Pass (Import/Data Path First)
Priority: P2
Owner: agent-ts-hygiene
Dependencies: T01, T05
Deliverables:
- Remove high-risk `any` from import/parser/repository hot paths:
- `SchemaOrgRecipeParserService`
- `RecipeRepository` filter destructuring
- import-related tests/types
- Introduce narrow helper types/guards for JSON-LD blocks instead of raw `any`.
- Keep broader orchestrator generics untouched unless directly impacted.
Acceptance:
- [ ] No `any` remains in import route + parser services.
- [ ] Repository filter casts no longer use `filters as any`.
- [ ] `npm run build` passes with strict mode unchanged.
---
### T08 — Docs + Runbook Truth Sync
Priority: P2
Owner: agent-docs-sync
Dependencies: T01, T03, T05
Deliverables:
- Update stale API/import docs to match actual payloads and behavior (`docs/api.md`, relevant README sections).
- Document known import limits and expected failure messages.
- Add “how to add parser fixture” note for future maintainers.
Acceptance:
- [ ] Docs describe real import endpoint behavior (not aspirational).
- [ ] Example request/response includes `image_url` handling rules.
- [ ] No references to removed/incorrect field names in import examples.
---
## Wave Plan (Execution Order)
### Wave 1 — Restore Functional Import Core
- T01 (route orchestration)
### Wave 2 — Image Quality + Safety
- T02 (schema image extraction)
- T03 (validation/fallback policy)
- T05 (contract alignment)
### Wave 3 — Backend Hardening in Parallel
- T04 (migration coverage)
- T06 (search/index optimization)
### Wave 4 — Cleanup + Stability
- T07 (TS hygiene)
- T08 (docs truth sync)
## Dependency Notes
- T03 depends on T02 because sanitizer policy should apply to ranked image candidates.
- T05 depends on T01 because real response shape must exist before contract lock.
- T07 should start after T01/T05 to avoid churn from contract refactors.
## Recommended Starting Order (Concrete)
1. **T01** — unblock real import behavior and expose true integration issues.
2. T02 — improve image extraction quality where import currently underperforms.
3. T03 — enforce URL safety/normalization policy.
4. T05 — lock backend/frontend import contract and image fields.
5. Parallel: T04 + T06.
6. T07 then T08.
## First Task to Launch
**Launch T01 — Import Route Orchestration (Wire Real Pipeline).**
Reason: it converts a stubbed endpoint into executable behavior and creates the integration baseline needed by every downstream image-quality and hardening task.
## Reporting Protocol (for each task)
1) task id
2) files changed
3) tests added/updated + command output
4) blockers/risks
5) ready-for-review flag

View File

@ -0,0 +1,190 @@
# Local File Import — Execution Board
**Created:** 2026-03-28
**Owner:** Cleo (orchestrator)
**Goal:** Implement functionality to import recipes from local .txt and .html files exported from CopyMeThat
**Status:** 🚧 In Progress
---
## Context
Paul has exported recipes from CopyMeThat in both .txt and .html formats. The Recipe Manager currently lacks functionality to import local files. This execution board tracks the implementation of this feature from file parsing through UI integration.
**Critical constraint:** Sub-agent spawning is currently blocked, so all work will be done in the main thread by Cleo.
---
## Task Breakdown
### Phase 1: Analysis & Design ✅
**Status:** Complete
**Tasks:**
- [x] T1.1: Analyze CopyMeThat export file formats (.txt and .html)
- [x] T1.2: Design import data pipeline (parse → validate → insert)
- [x] T1.3: Define backend API endpoint contract
- [x] T1.4: Define frontend UI flow (file selection → preview → confirm)
**Deliverables:**
- ✅ File format specification document (`.harness/local-file-import-phase1-analysis.md`)
- ✅ Import service architecture design
- ✅ API endpoint spec
- ✅ UI wireframe/flow diagram
---
### Phase 2: Backend Implementation 🚧
**Status:** In Progress (90% complete)
**Tasks:**
- [x] T2.0: Extend database schema with `made`, `rating`, `notes` fields
- [x] T2.1: Create file parser for .txt format (`CopyMeThatTxtParser.ts`)
- [x] T2.2: Create file parser for .html format (`CopyMeThatHtmlParser.ts`)
- [x] T2.3: Implement import service with duplicate detection (`CopyMeThatImportService.ts`)
- [x] T2.4: Create POST `/api/import/local` endpoint with multer file upload
- [x] T2.5: Register route in Express app
- [x] T2.6: Install multer dependency
- [ ] T2.7: Add unit tests for parsers
- [ ] T2.8: Add integration tests for import endpoint
**Deliverables:**
- ✅ `src/backend/services/CopyMeThatHtmlParser.ts`
- ✅ `src/backend/services/CopyMeThatTxtParser.ts`
- ✅ `src/backend/services/CopyMeThatImportService.ts`
- ✅ `src/backend/routes/importLocal.ts`
- ✅ Schema migration (`migrations/2026-03-28-add-user-metadata-fields.md`)
- ⏳ Test coverage (pending)
---
### Phase 3: Frontend Implementation
**Status:** Pending
**Tasks:**
- [ ] T3.1: Create file upload component with drag-and-drop
- [ ] T3.2: Build import preview screen (show parsed recipes before commit)
- [ ] T3.3: Add progress indicator for batch imports
- [ ] T3.4: Implement error handling & user feedback
- [ ] T3.5: Add import page to navigation
**Deliverables:**
- `src/frontend/components/ImportRecipes.tsx`
- `src/frontend/components/ImportPreview.tsx`
- `src/frontend/pages/ImportPage.tsx`
- Styling consistent with existing UI
---
### Phase 4: Testing & Polish
**Status:** Pending
**Tasks:**
- [ ] T4.1: End-to-end test: import single .txt file
- [ ] T4.2: End-to-end test: import single .html file
- [ ] T4.3: End-to-end test: batch import multiple files
- [ ] T4.4: Manual testing with Paul's actual CopyMeThat exports
- [ ] T4.5: Performance testing (100+ recipes)
- [ ] T4.6: Update user documentation
**Deliverables:**
- E2E test suite
- Performance benchmarks
- Updated `docs/user-guide.md` with import instructions
---
### Phase 5: Deployment
**Status:** Pending
**Tasks:**
- [ ] T5.1: Build and test Docker image
- [ ] T5.2: Deploy to paje.ca staging
- [ ] T5.3: User acceptance testing (Anne & Elizabeth)
- [ ] T5.4: Production deployment
- [ ] T5.5: Data migration (import Paul's CopyMeThat exports)
**Deliverables:**
- Deployed feature on recipes.paje.ca
- Migrated recipe data
- Release notes
---
## Dependencies
- CopyMeThat export sample files (need from Paul)
- Existing Recipe schema/validation (already implemented)
- Current backend API structure
- Frontend routing and navigation patterns
---
## Technical Decisions
### File Format Handling
- **Decision:** Support both .txt and .html, with .html as primary (more structured data)
- **Rationale:** .txt is human-readable backup, .html likely has better metadata
- **Risk:** Unknown format specifics until we see sample files
### Duplicate Detection
- **Decision:** Check for exact title + ingredients match
- **Rationale:** Simple, fast, low false-positive rate
- **Alternative:** Could add fuzzy matching later (v1.1)
### Batch Import Strategy
- **Decision:** Parse all files first, show preview, then bulk insert
- **Rationale:** Gives user control, prevents partial imports on error
- **Trade-off:** Higher memory usage for large batches (acceptable for <1000 recipes)
### Error Recovery
- **Decision:** Log failures, continue processing remaining files
- **Rationale:** One bad file shouldn't block entire import
- **UX:** Show summary with success/failure counts
---
## Risks & Mitigation
| Risk | Impact | Mitigation |
|------|--------|------------|
| Unknown file format structure | High | Request sample files from Paul immediately |
| Large file performance | Medium | Implement streaming parser if needed |
| Encoding issues (UTF-8, special chars) | Medium | Use robust parser library, test with international recipes |
| Duplicate recipe handling | Low | Clear UI messaging, offer merge/skip options |
---
## Acceptance Criteria
✅ **Must Have:**
1. User can upload .txt or .html files via UI
2. System correctly parses CopyMeThat export formats
3. User sees preview of recipes before import
4. Duplicate detection prevents accidental re-imports
5. Batch import works for 100+ files
6. Clear error messages for invalid files
7. Documentation updated with import instructions
🎯 **Nice to Have:**
- Drag-and-drop file upload
- Progress bar for large batches
- Import history log
- Undo/rollback option
---
## Progress Log
**2026-03-28 23:46 EDT — Kickoff**
- Execution board created
- Awaiting CopyMeThat export sample files from Paul
- Next step: Analyze file formats and begin Phase 1
---
## Notes
- Paul has files ready in Downloads folder (.txt and .html)
- Sub-agent spawning currently broken, all work in main thread
- Target completion: 24-48 hours (depending on file format complexity)
- This feature unblocks Anne & Elizabeth from migrating to Recipe Manager
---
_Last updated: 2026-03-28 23:50 EDT by Cleo_

View File

@ -0,0 +1,243 @@
# Phase 1: CopyMeThat Format Analysis
**Completed:** 2026-03-28 23:52 EDT
**Analyzed by:** Cleo
---
## File Structure
### TXT Export
- **Location:** `data/exports/Copy_Me_That_TXT_20260328_58775_z1p5lpjsgz/`
- **Format:** One `.txt` file per recipe
- **Naming:** Snake-case recipe names (e.g., `apple_crowned_coffee_cake.txt`)
- **Estimated count:** ~50-100+ recipes
### HTML Export
- **Location:** `data/exports/Copy_Me_That_HTML_20260328_58775_z1p5lpjsgz/`
- **Format:** Single `recipes.html` file containing ALL recipes
- **Images:** `/images/` subfolder with recipe photos
- **Structure:** Semantic HTML with consistent IDs
---
## TXT Format Specification
### Structure
```
[Recipe Title]
Adapted from [URL]
tags: [Tag1], [Tag2], [Tag3]
[Optional: "I made this."]
Servings: [serving info]
INGREDIENTS
[ingredient 1]
[ingredient 2]
...
STEPS
1) [step 1]
2) [step 2]
...
NOTES
[optional notes]
```
### Example
```
Apple-Crowned Coffee Cake
Adapted from http://www.kraftcanada.com/recipes/apple-crowned-coffee-cake-191423
tags: Cake, Dessert
I made this.
Servings: 16 servings, 1 piece (76 g) each
INGREDIENTS
2 cups flour
2 Tbsp. granulated sugar
...
STEPS
1) Heat oven to 375°F.
2) Combine flour...
NOTES
If the glaze is too thick...
```
### Key Fields
- **Title:** First line
- **Source URL:** "Adapted from [URL]"
- **Tags:** Comma-separated after "tags:"
- **Made flag:** Presence of "I made this."
- **Servings:** After "Servings:"
- **Ingredients:** Plain list between "INGREDIENTS" and "STEPS"
- **Instructions:** Numbered list after "STEPS"
- **Notes:** Optional, after "NOTES"
---
## HTML Format Specification
### Structure
Single HTML file with repeated `.recipe` div blocks:
```html
<div class="recipe">
<div id="name">Recipe Title</div>
<div id="link">
Adapted from <a id="original_link" href="...">URL</a>
</div>
<img class="recipeImage" src="images/filename.jpg"/>
<div id="categories">
<span class="recipeCategory">Tag1</span>
<span class="recipeCategory">Tag2</span>
</div>
<div id="description">Description text</div>
<div id="extra_info">
<span id="made_this">I made this.</span>
<span id="rating">Rated <span id="ratingValue">3</span>/5</span>
</div>
<div id="servings">
Servings: <a id="recipeYield">8 servings...</a>
</div>
<ul id="recipeIngredients">
<li class="recipeIngredient">ingredient 1</li>
...
</ul>
<ol id="recipeInstructions">
<li class="instruction" value="1">step 1</li>
...
</ol>
<div id="recipeNotes">
<div class="recipeNote">note text</div>
</div>
</div>
```
### Key Selectors
- `.recipe` — Recipe container
- `#name` — Title
- `#original_link` — Source URL
- `.recipeImage` — Image path
- `.recipeCategory` — Tags
- `#description` — Description
- `#made_this` — Made flag
- `#ratingValue` — Rating (1-5)
- `#recipeYield` — Servings
- `.recipeIngredient` — Ingredients (list items)
- `.instruction` — Steps (ordered list items)
- `.recipeNote` — Notes
---
## Implementation Strategy
### Recommended Approach: HTML Parser Primary
**Rationale:**
- HTML has MORE data (images, ratings, descriptions)
- Single file = easier batch import
- Well-structured semantic markup
- Images already linked
**Fallback:** TXT parser for edge cases
### Parser Architecture
```
ImportService
├── CopyMeThatHtmlParser
│ ├── parseRecipes(html: string): Recipe[]
│ ├── extractRecipeBlocks(html: string): HTMLElement[]
│ └── parseRecipeBlock(block: HTMLElement): Recipe
└── CopyMeThatTxtParser (optional fallback)
└── parseTxtFile(content: string): Recipe
```
### API Endpoint Design
```
POST /api/recipes/import/copyme that
Content-Type: multipart/form-data
Request:
- file: recipes.html OR multiple .txt files
- options: { skipDuplicates: boolean, importImages: boolean }
Response:
{
success: true,
data: {
imported: 45,
skipped: 3,
failed: 2,
recipes: [...] // preview
}
}
```
---
## Data Mapping
| CopyMeThat Field | Recipe Schema Field | Notes |
|------------------|---------------------|-------|
| `#name` | `title` | Direct mapping |
| `#original_link` | `source_url` | Direct mapping |
| `#description` | `description` | Direct mapping |
| `.recipeCategory` | `tags` | Parse into tag array |
| `#recipeYield` | `servings` | Extract number if possible |
| `.recipeIngredient` | `ingredients[].item` | Plain text list |
| `.instruction` | `steps[].instruction` | Numbered list |
| `.recipeNote` | Notes field? | May need schema extension |
| `.recipeImage` | `image_url` | Copy to app storage |
| `#made_this` | Custom field? | Boolean flag |
| `#ratingValue` | Custom field? | 1-5 rating |
### Schema Extensions Needed
- `made: boolean` — User has cooked this
- `rating: number` — 1-5 stars
- `notes: string` — General notes field
---
## Edge Cases to Handle
1. **Duplicate detection** — Match on title + source_url
2. **Missing fields** — Title/ingredients/steps are required
3. **Image handling** — Copy images or store paths?
4. **Encoding** — UTF-8 special characters
5. **HTML entities**`&amp;`, `&quot;`, etc.
6. **Large batches** — Memory limits for 100+ recipes
7. **Malformed HTML** — Graceful degradation
---
## Next Steps (Phase 2)
1. Extend Recipe schema with `made`, `rating`, `notes` fields
2. Implement `CopyMeThatHtmlParser` service
3. Create `POST /api/recipes/import/file` endpoint
4. Add multipart file upload handler
5. Unit tests for parser
6. Integration tests for endpoint
---
**Status:** ✅ Analysis complete, ready for implementation

View File

@ -0,0 +1,225 @@
# Recipe Manager — Styling Stabilization Execution Board
Created: 2026-03-27
Owner: Main Orchestrator
Status: READY
## Objective
Stabilize frontend styling so recipe-manager has a single, predictable styling system (tokens + reusable UI primitives + clear usage rules) before any further visual redesign work.
## Current Repo Reality (Baseline)
### recipe-manager (frontend)
- Stack is **React + Vite + Tailwind v4** (`frontend/package.json`).
- There is an existing token layer in CSS variables (`src/styles/tokens.css`) and Tailwind mapping (`tailwind.config.js`).
- A reusable utility-class layer exists in `src/index.css` (`.ui-btn`, `.ui-card`, `.ui-input`, `.ui-chip`, `.ui-page`, `.ui-section`).
- A parallel TS token file exists (`src/theme.ts`) with overlapping values (colors/radius/shadows/typography) duplicated from CSS tokens.
- Styling is currently mixed:
- some components use the `ui-*` classes and CSS variable tokens,
- some use Tailwind utility colors directly (`bg-blue-*`, `text-slate-*`, `border-gray-*` etc.),
- some use inline style objects from `theme.ts` (`style={{ boxShadow: shadows.card, borderRadius: radius.lg }}`),
- `src/App.css` still contains legacy scaffold/demo styles not aligned with the current design system,
- `MissionControlPanel.tsx` uses older gray utility styling and does not follow `ui-*` primitives.
- Result: design drift risk is high because there is no enforced “one true path” for styling.
### Fintrove (packages/fintrove-app)
- Uses **Next.js + Tailwind v3 + shadcn/ui pattern**.
- Evidence:
- `components.json` configured for shadcn (`style: "new-york"`, aliases, cssVariables true).
- `src/components/ui/*` contains shadcn-style primitives (Button, Card, Input, etc.).
- `@radix-ui/*`, `class-variance-authority`, `clsx`, `tailwind-merge` are installed and actively used.
- `src/lib/utils.ts` exposes `cn()` pattern (`twMerge(clsx(...))`).
- App pages/modules heavily import `@/components/ui/*` components.
- Practical note: Fintrove still has some custom CSS for layout edge cases (sidebar), but **core UI is standardized through primitives**.
## Release Gate (Done Definition)
- [ ] recipe-manager has one canonical token source (CSS variables) with no drift between CSS and TS token constants.
- [ ] Base UI primitives are centralized and used across pages/components for buttons, cards, inputs, chips/badges, section shells.
- [ ] No new feature UI uses raw ad-hoc color classes outside tokenized/theme-approved set.
- [ ] Legacy/unused styling artifacts are removed or documented as intentionally retained.
- [ ] A lightweight styling governance doc/checklist exists so future changes remain consistent.
---
## Recommendation Decision
### Recommended direction
**Keep current Tailwind + tokenized CSS variables + shared UI components (Fintrove-inspired discipline), optionally adopting selective shadcn patterns later.**
### Why (repo-specific)
1. recipe-manager already has a strong token foundation (`tokens.css` + `ui-*` classes) and visual language in flight.
2. A full migration to Mantine/Chakra now would add heavy churn and force broad rewrites for limited short-term value.
3. Full shadcn adoption now is possible, but it also introduces migration overhead (Radix + CVA component rewiring) while the current app can be stabilized faster by consolidating what already exists.
4. Best immediate leverage from Fintrove is **process and component architecture**, not a wholesale framework switch:
- centralized primitives,
- strict use of shared components,
- `cn()` utility + variant patterns where helpful.
### Option stance
- **Keep Tailwind + shared UI components:** ✅ **Recommended now**
- **Adopt shadcn/ui immediately:** ⚠️ optional phase-2 enhancement, not first move
- **Adopt Mantine/Chakra:** ❌ not recommended for current phase
- **Borrow Fintrove patterns:** ✅ yes — especially primitives + usage discipline
---
## Task Backlog
### T01 — Styling Inventory + Drift Map
Priority: P0
Owner: agent-style-audit
Dependencies: none
Deliverables:
- Component/page inventory tagged by styling mode:
- `ui-*` primitives,
- raw Tailwind utilities,
- inline `theme.ts` styles,
- legacy CSS (`App.css`) usage.
- Drift report listing top inconsistency hotspots (starting with `App.tsx`, `MissionControlPanel.tsx`, major pages).
Acceptance:
- [ ] Written inventory committed under `.harness/docs/styling-inventory.md`.
- [ ] Every top-level page and shared component classified.
- [ ] Explicit “first conversion targets” list produced.
---
### T02 — Canonical Token Source Lock
Priority: P0
Owner: agent-design-system
Dependencies: T01
Deliverables:
- Decide and document canonical token authority (CSS variable tokens in `tokens.css`).
- Align/trim `theme.ts` so it becomes a typed accessor/mirror (or remove overlapping hardcoded values where possible).
- Add a short token usage guide (when to use class vs CSS var vs helper constant).
Acceptance:
- [ ] No conflicting duplicate token values between CSS and TS token surfaces.
- [ ] Token mapping doc exists (`.harness/docs/styling-token-contract.md`).
- [ ] New token additions have one defined source-of-truth path.
---
### T03 — UI Primitive Contract (Recipe Manager UI Kit)
Priority: P0
Owner: agent-ui-foundation
Dependencies: T02
Deliverables:
- Formalize minimal primitives (Button/Card/Input/Badge/Section/Page shell) using existing `ui-*` base.
- Add composable wrappers/components where needed to reduce repeated class strings.
- Introduce `cn()` utility (Fintrove pattern) if class composition complexity requires it.
Acceptance:
- [ ] Shared primitive surface exists and is documented.
- [ ] At least 3 high-traffic screens consume primitives instead of duplicated class blocks.
- [ ] No regression in behavior/accessibility for converted areas.
---
### T04 — High-Drift Screen Conversions
Priority: P1
Owner: agent-core-ui
Dependencies: T03
Deliverables:
- Convert highest inconsistency files first:
- `src/App.tsx` nav/header/footer,
- `src/components/MissionControlPanel.tsx`,
- one primary page (`RecipeListPage` or `RecipeDetailPage`) where mixed style patterns are most visible.
- Replace raw color utility classes with token-aware variants/primitives where practical.
Acceptance:
- [ ] Converted files show clear reduction in ad-hoc style usage.
- [ ] Visual parity maintained (or intentionally improved with notes).
- [ ] No TypeScript/runtime regressions.
---
### T05 — Legacy Style Debt Cleanup
Priority: P1
Owner: agent-style-cleanup
Dependencies: T04
Deliverables:
- Remove or quarantine stale scaffold styles in `src/App.css`.
- Remove dead class names/selectors no longer referenced.
- Add comments for any intentionally retained exceptions.
Acceptance:
- [ ] `App.css` no longer contains unrelated starter/template styling.
- [ ] Dead style selectors removed.
- [ ] Build output CSS size/change reviewed and documented.
---
### T06 — Guardrails + Lightweight Governance
Priority: P1
Owner: agent-qa-polish
Dependencies: T04, T05
Deliverables:
- Add a concise styling contribution checklist (`.harness/docs/styling-governance.md`).
- Add lightweight guardrail command in frontend (`npm run style:guardrails`) for scoped app-shell/primitive checks.
- PR checklist items for token/primitive usage and accessibility checks.
Acceptance:
- [ ] Governance doc exists and is referenced by board/task workflow.
- [ ] At least one automated or semi-automated guardrail in place.
- [ ] Team can explain “how to style new UI here” in <2 minutes.
---
### T07 — Stabilization QA Pass
Priority: P1
Owner: agent-qa-polish
Dependencies: T06
Deliverables:
- Before/after screenshots of converted surfaces.
- Keyboard focus + contrast spot-check on converted components.
- Regression notes and signoff summary.
Acceptance:
- [ ] QA notes committed under `.harness/docs/styling-stabilization-qa.md`.
- [ ] No critical visual regressions on mobile/desktop.
- [ ] Release gate checklist completed.
---
## Wave Plan (Execution Order)
### Wave 1 — Discovery + Rules
- T01 (inventory/drift map)
- T02 (token source lock)
### Wave 2 — Foundation
- T03 (primitive contract)
### Wave 3 — Conversion + Cleanup
- T04 (high-drift screens)
- T05 (legacy style debt)
### Wave 4 — Prevent Regressions + Signoff
- T06 (guardrails/governance)
- T07 (stabilization QA)
## Dependencies Summary
- T02 depends on T01 so token decisions are based on real usage.
- T03 depends on T02 to avoid rebuilding primitives on unstable tokens.
- T04/T05 follow T03 for consistent rollout.
- T06/T07 close the loop and keep drift from reappearing.
## First Task to Launch
**Launch T01 — Styling Inventory + Drift Map.**
Reason: this repo already has multiple parallel styling paths. Without an explicit inventory, refactor work will be noisy and may increase inconsistency. T01 gives the concrete map needed to sequence low-risk, high-impact stabilization.
## Reporting Protocol (for each task)
1) task id
2) files changed
3) before/after evidence (screenshots or diffs)
4) blockers/risks
5) ready-for-review flag

View File

@ -0,0 +1,70 @@
# Recipe Manager — Visual Refresh Task Plan (Agentic Harness)
## Goal
Transform the site from plain white UI into a polished, appetizing product experience with consistent styling, imagery, and modern visual hierarchy.
## Task 1 — Design Tokens + Global Theme Foundation
**Scope**
- Establish color palette, typography scale, spacing/radius/shadow tokens
- Add global background treatment (subtle gradient/texture)
- Normalize button/input/card styles
- Ensure contrast/accessibility baseline
**Success Criteria**
- Theme variables centrally defined and used globally
- App no longer appears “flat white”
- Build/tests pass
---
## Task 2 — Homepage + Recipe List Visual Upgrade
**Scope**
- Add hero section with food imagery
- Improve recipe cards with thumbnails/placeholders, depth, and metadata chips
- Better empty/loading states with visuals
- Keep layout responsive
**Success Criteria**
- Homepage/list feels branded and visual
- Cards look rich and scannable on desktop/mobile
- Build/tests pass
---
## Task 3 — Recipe Detail + Import Flow Polish
**Scope**
- Visual hierarchy for recipe detail sections (ingredients, steps, tags)
- Add image block/header treatment
- Improve import screens with progress visuals and status indicators
**Success Criteria**
- Detail/import pages match refreshed style
- Clear phase/status UX
- Build/tests pass
---
## Task 4 — Micro-interactions + Motion + Iconography
**Scope**
- Add subtle transitions, hover/focus states, and motion consistency
- Introduce icon accents where useful (not noisy)
- Polish nav/header interactions
**Success Criteria**
- UI feels alive but not distracting
- Keyboard focus remains strong
- Build/tests pass
---
## Task 5 — Asset Strategy + Final QA Sweep
**Scope**
- Add/organize image assets and fallback strategy
- Verify no broken images/layout regressions
- Run full verification (`npm test`, app build/start command if available)
- Update docs with visual system notes
**Success Criteria**
- End-to-end visual refresh stable
- No obvious regressions
- Final commit + summary with before/after notes

View File

@ -0,0 +1,73 @@
# Recipe Manager — Agentic Harness Task Plan (Workflow Stabilization)
## Goal
Implement and validate a reliable automated workflow run + reporting + failure alert loop, in small isolated chunks.
## Task 1 — Add scheduler script + npm script
**Scope**
- Create `scripts/schedule-workflow.ts` that:
- Runs workflow in resume mode
- Generates morning report afterward
- Exits non-zero on hard failure
- Add npm script in `package.json`:
- `workflow:schedule`: `ts-node scripts/schedule-workflow.ts`
**Constraints**
- Keep script focused (no external integrations yet)
- Reuse existing runner/report code
**Success criteria**
- `npm run workflow:schedule` executes without TypeScript/runtime errors
- Commit with conventional commit message
---
## Task 2 — Add failure-state detector utility
**Scope**
- Create `scripts/check-workflow-health.ts` that reads status file and exits:
- `0` for healthy (`running|completed|idle`)
- `1` for unhealthy (`failed|blocked`)
- Print concise machine-readable output for automations
**Success criteria**
- Script behavior validated by direct execution + simple tests (or assertions)
- Conventional commit
---
## Task 3 — Add tests for schedule + health check scripts
**Scope**
- Add tests under `scripts/__tests__/`:
- scheduler happy path
- scheduler failure propagation
- health check status mapping
**Success criteria**
- `npm test` passes
- Conventional commit
---
## Task 4 — Wire lightweight local automation docs
**Scope**
- Update README/RUNBOOK with exact commands for periodic execution (cron/systemd example)
- Include troubleshooting for failed/blocked status
**Success criteria**
- Docs are accurate to current scripts
- Conventional commit
---
## Task 5 — End-to-end verification pass
**Scope**
- Run:
- `npm run workflow:run -- --mode restart`
- `npm run workflow:schedule`
- `npm test`
- Capture outputs in final summary
**Success criteria**
- All commands succeed
- Final conventional commit if needed

View File

@ -60,6 +60,10 @@ cd /home/paulh/.openclaw/workspace/projects/recipe-manager
# Install dependencies # Install dependencies
npm install npm install
# Initialize local database schema + starter sample recipes
npm run migrate
npm run seed
# Start backend (development mode) # Start backend (development mode)
npm run dev:backend npm run dev:backend
@ -162,6 +166,7 @@ recipe-manager/
- [Project Vision](PROJECT.md) — Why we're building this - [Project Vision](PROJECT.md) — Why we're building this
- [Architecture Guide](ARCHITECTURE.md) — Technical decisions & patterns - [Architecture Guide](ARCHITECTURE.md) — Technical decisions & patterns
- [Roadmap](ROADMAP.md) — Planned features & milestones - [Roadmap](ROADMAP.md) — Planned features & milestones
- [Planning Terminology](docs/planning-terminology.md) — Execution Board vs Roadmap vs Backlog
- [Agent Instructions](AGENT_INSTRUCTIONS.md) — How AI agents contribute - [Agent Instructions](AGENT_INSTRUCTIONS.md) — How AI agents contribute
- [API Docs](docs/api.md) — Endpoint reference - [API Docs](docs/api.md) — Endpoint reference
- [User Guide](docs/user-guide.md) — How to use the app - [User Guide](docs/user-guide.md) — How to use the app

View File

@ -3,6 +3,8 @@
**Last Updated:** 2026-03-23 **Last Updated:** 2026-03-23
**Approach:** Agent-driven incremental releases **Approach:** Agent-driven incremental releases
**Planning terminology:** This file is the strategic **Roadmap**. Active coordinated delivery work belongs in `.harness/*-execution-board.md` (**Execution Boards**). Unscheduled ideas stay in **Backlog** sections.
--- ---
## Philosophy ## Philosophy

41
TODO.md
View File

@ -48,6 +48,47 @@ MVP is functionally complete (core app + docs + tests).
--- ---
## 🚀 High Priority Improvements (Post-code-review)
### Phase 1: Configuration & Reliability
- [x] Add environment-based configuration (dotenv, env vars for PORT, DB_PATH, CORS_ORIGIN)
- [x] Wrap RecipeRepository.create in database transaction (BEGIN/COMMIT)
- [x] Wrap RecipeRepository.update in database transaction
- [x] Optimize duplicate detection in import (use Set for O(1) lookup)
- [x] Add health check endpoint (/api/health)
- [x] Add dirty flag to avoid unnecessary periodic database saves
- [x] Enable foreign key constraints (PRAGMA foreign_keys = ON)
### Phase 2: Security
- [x] Add API key authentication middleware (shared secret for write endpoints)
- [x] Add rate limiting to import endpoints (express-rate-limit)
- [x] Update CORS to be configurable (wildcard only in development)
- [x] Fix image URL handling: ensure relative paths converted to /images/ absolute
- [ ] (Optional) Restrict harness routes to localhost or add auth
### Phase 3: Testing
- [x] Add tests for PUT /api/recipes/:id
- [x] Add tests for DELETE /api/recipes/:id
- [x] Add tests for tag routes (GET/POST/PUT/DELETE) plus assignment/removal
- [ ] Add unit tests for CopyMeThatHtmlParser (edge cases, malformed HTML)
- [ ] Add unit tests for CopyMeThatTxtParser
- [ ] Add unit tests for CopyMeThatImportService (duplicate detection, error handling)
- [ ] Add integration tests for file upload endpoint (POST /api/import/local)
### Phase 4: Code Quality & Observability
- [ ] Extract asyncHandler middleware to reduce route boilerplate
- [ ] Add request logging (morgan)
- [ ] Replace console.log with proper logger (debug module)
- [ ] Add pagination links to recipe list response
- [ ] Add full-text search (FTS5) for title/description/ingredients/tags (defer if time)
## ✅ Completed in this session (2026-03-29)
- Implemented all Phase 1 & 2 tasks
- Added comprehensive tests for recipes (PUT/DELETE) and tags (update/delete/assignment)
- Fixed critical bug in tag assignment routes (parameter order)
- Enabled foreign key constraints for data integrity
- All backend tests passing (46 tests)
## 📋 Backlog (Post-v1) ## 📋 Backlog (Post-v1)
### v1.1 ### v1.1

75
audit-fix-images.js Normal file
View File

@ -0,0 +1,75 @@
#!/usr/bin/env node
import initSqlJs from 'sql.js';
import fs from 'fs';
import path from 'path';
const DB_PATH = 'data/recipes.db';
const IMAGES_DIR = 'data/images';
async function auditAndFixImages() {
const SQL = await initSqlJs();
const dbBuffer = fs.readFileSync(DB_PATH);
const db = new SQL.Database(dbBuffer);
// Get all recipes
const recipes = db.exec(`SELECT id, title, image_url FROM recipes ORDER BY id`);
if (recipes.length === 0) {
console.log('No recipes found');
return;
}
// Get all image files in data/images
const imageFiles = fs.readdirSync(IMAGES_DIR).filter(f => f.endsWith('.jpg'));
console.log(`\n📂 Found ${imageFiles.length} images in ${IMAGES_DIR}/`);
console.log(`📊 Found ${recipes[0].values.length} recipes in database\n`);
let withImages = 0;
let withoutImages = 0;
let fixed = 0;
recipes[0].values.forEach(([id, title, imageUrl]) => {
if (imageUrl && imageUrl.trim().length > 0) {
withImages++;
return;
}
// Recipe has no image — try to find one by matching filename
const titleSlug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
// Look for matching image
const matchingImage = imageFiles.find(filename => {
const baseName = filename.replace(/_.{5}\.jpg$/, ''); // Remove random suffix
return baseName === titleSlug || filename.startsWith(titleSlug);
});
if (matchingImage) {
const newUrl = `/images/${matchingImage}`;
db.run(`UPDATE recipes SET image_url = ? WHERE id = ?`, [newUrl, id]);
console.log(`✓ Fixed: ${id}. ${title}${newUrl}`);
fixed++;
} else {
console.log(`✗ Missing: ${id}. ${title}`);
withoutImages++;
}
});
// Save database
if (fixed > 0) {
const data = db.export();
fs.writeFileSync(DB_PATH, data);
console.log(`\n✅ Updated ${fixed} recipes with images`);
}
db.close();
console.log(`\n📊 Summary:`);
console.log(` - Recipes with images: ${withImages}`);
console.log(` - Recipes fixed: ${fixed}`);
console.log(` - Still missing images: ${withoutImages}`);
}
auditAndFixImages().catch(console.error);

View File

@ -0,0 +1,16 @@
# Planning Terminology
Use these terms consistently across this repo:
- **Execution Board**: Active orchestrated work (waves, owners, dependencies, acceptance criteria).
- Location: `.harness/*-execution-board.md`
- **Roadmap**: Strategic direction and future milestones.
- Location: [`ROADMAP.md`](../ROADMAP.md)
- **Backlog**: Unscheduled ideas/tasks not currently orchestrated.
- Location: `TODO.md` and "Future Ideas (Backlog)" in `ROADMAP.md`
## Conventions
- Start active multi-task efforts as an **Execution Board** in `.harness/`.
- Use filename pattern: `topic-execution-board.md` (e.g., `image-import-hardening-execution-board.md`).
- Keep the Roadmap high-level; move execution detail into Execution Boards.

View File

@ -195,4 +195,57 @@ MVP will reuse existing architecture and avoid stack churn.
--- ---
## 10) Visual Overhaul Task List (UI/UX Execution — Added 2026-03-27)
This list is now the visual execution source-of-truth. Continue these tasks in order.
1. [ ] **UI Audit vs Plan**
- Inventory current pages/components/styles in frontend.
- Mark what is complete, partial, or missing.
- Confirm why color/theme is not visible in current build.
2. [ ] **Design System Baseline**
- Define color tokens (primary/secondary/accent/surface/text/muted/success/warning/error).
- Define typography scale (H1-H6/body/caption), spacing scale, radius, shadow levels.
- Add focus-ring + interactive states (hover/active/disabled).
3. [ ] **App Shell Polish**
- Upgrade header/nav/sidebar layout and visual hierarchy.
- Apply page background + card surface contrast.
- Standardize container widths and vertical rhythm.
4. [ ] **Core Component Restyle**
- Buttons, inputs, selects, textareas, chips/tags, badges.
- Card/list row/table visual consistency.
- Form field spacing, labels, helper/error message styling.
5. [ ] **Key Page Visual Pass**
- Recipe list page (search/filter strip + cards/list state).
- Recipe detail page (metadata, ingredients, steps sections).
- Recipe create/edit page (clean form hierarchy).
- Tag manager + import/export panels.
6. [ ] **Empty / Loading / Error States**
- Branded empty states with clear CTAs.
- Loading skeleton/spinner consistency.
- Error states with recovery actions.
7. [ ] **Responsive Tuning**
- Mobile-first checks for list, detail, form, and filters.
- Tablet breakpoints and spacing adjustments.
- Desktop density polish.
8. [ ] **Accessibility Pass**
- Contrast checks for text/surfaces/buttons.
- Keyboard navigation + visible focus.
- Semantic labels/aria for core interactions.
9. [ ] **Visual QA + Release Prep**
- Screenshot pass of all key views.
- Fix visual regressions.
- Update docs with theme/design decisions.
- Commit + changelog note.
---
If scope changes are requested, update this file first and treat it as the source of truth for implementation sequencing. If scope changes are requested, update this file first and treat it as the source of truth for implementation sequencing.

View File

@ -0,0 +1,25 @@
# T04 Homepage QA Note
Date: 2026-03-27
Task: **T04 — Homepage Redesign**
## Scope checked
- Route `/` (`RecipeListPage`) only
- No redesign changes applied to detail/add-edit/import pages in this task
## Visual checklist (home)
- [x] Hero section added with T03 imagery and clear CTA pair (Add Recipe / Browse Library)
- [x] Stronger visual hierarchy (eyebrow, headline, supportive copy, trust pills, metric overlays)
- [x] Feature/benefit blocks with icon graphics
- [x] Footer-style polished home closing section with final CTAs
- [x] Search/filter/library behavior preserved
- [x] Empty/loading/error states preserved and visually consistent
- [x] Responsive behavior verified in code:
- Hero/content stacks at mobile widths
- Features: 1 col mobile → 3 cols md+
- Recipe grid unchanged: 1/2/3 columns at existing breakpoints
- [x] Uses established tokenized classes (`ui-section`, `ui-card`, `ui-btn`, `ui-chip`, `ui-input`) and T03 assets
## Notes
- Build command run: `npm run build` (frontend) — fails due to pre-existing TypeScript unused-import/unused-arg errors outside homepage scope.
- No homepage-specific TypeScript errors introduced.

View File

@ -1,78 +1,40 @@
# Visual Asset Audit — T03 (Asset Pack) # Visual Asset Pack — T03 + Photo-First Correction
Date: 2026-03-26 Date: 2026-03-27
Task: T03 — Visual Asset Pack
Owner: agent-assets Owner: agent-assets
## Summary ## Status summary
Implemented a legal-safe asset pack under `frontend/public/assets/` to support the redesign with: T03 successfully produced a legal-safe original SVG asset pack.
However, art direction has now shifted to **photo-first** to improve perceived quality and appetite appeal.
1. Food imagery placeholders This file records what remains useful from T03 and what is now deprecated as primary art.
2. Category illustrations/icons
3. Empty-state graphics
4. Placeholder strategy for missing images
All new visual files are simple, in-repo SVG illustrations generated specifically for this project. ## Keep vs deprecate
No copyrighted third-party photos or icons were imported.
## Asset Inventory ### Keep (active)
- Category utility icons: `/assets/category/icon-*.svg`
- Placeholder fallbacks: `/assets/food/placeholder-recipe*.svg`
- Empty-state illustrations (temporary): `/assets/empty-state/*.svg`
### Food placeholders ### Deprecate as primary storytelling assets
- `/assets/food/placeholder-recipe.svg` (wide hero fallback) - Hero illustrations: `/assets/hero/*.svg`
- `/assets/food/placeholder-recipe-4x3.svg` (list/card ratio fallback) - Curated illustrated dishes: `/assets/food/curated/*.svg`
- `/assets/food/placeholder-recipe-1x1.svg` (square thumb fallback) - Category illustration set: `/assets/category/illustrations/*.svg`
- `/assets/food/placeholder-upload-dropzone.svg` (upload UI placeholder)
### Category icons > Deprecated means: not preferred for homepage hero, recipe cards, or detail hero going forward.
- `/assets/category/icon-breakfast.svg`
- `/assets/category/icon-lunch.svg`
- `/assets/category/icon-dinner.svg`
- `/assets/category/icon-dessert.svg`
- `/assets/category/icon-snack.svg`
### Empty-state graphics ## New source of truth (photo-first)
- `/assets/empty-state/no-recipes.svg`
- `/assets/empty-state/no-favorites.svg`
- `/assets/empty-state/no-results-search.svg`
## Source & Attribution Notes - Strategy doc: `docs/visual-audit/photo-first-assets.md`
- Asset pipeline: `frontend/public/assets/photos/README.md`
- Slot manifest: `frontend/public/assets/photos/manifest.json`
- **Source:** Created in-house (manual SVG composition) ## Licensing note
- **Attribution required:** None
- **Third-party dependencies:** None for these files
- **Legal status:** Safe to use and modify within this repository
## Icon Strategy - Legacy T03 SVG assets remain repo-owned and safe.
- New primary visuals should be real photos from approved free/licensable sources, tracked per-asset in the photo manifest.
Current strategy for redesign wave: ## Icon direction
- Use lightweight, project-owned SVG icons for category visuals and illustrative states Adopt `lucide-react` for modern, restrained UI iconography and retire emoji/mixed icon styling over time.
- Keep semantic UI icons in code (emoji/text) where already present to avoid functional churn during visual phase
- Optionally standardize on one icon library in a later task (e.g., Lucide React) if/when design system tokenization introduces centralized icon components
## Placeholder Strategy (Missing Images)
When recipe images are missing or fail to load:
1. **Primary display:** Use `/assets/food/placeholder-recipe-4x3.svg` for list cards
2. **Detail hero fallback:** Use `/assets/food/placeholder-recipe.svg`
3. **Square contexts (avatars/thumbs):** Use `/assets/food/placeholder-recipe-1x1.svg`
4. **Upload workflows:** Show `/assets/food/placeholder-upload-dropzone.svg` before image selection
5. **Error fallback:** On image load error, swap `src` to matching placeholder and set descriptive `alt` text (e.g., `"Recipe image placeholder"`)
Recommended accessibility behavior:
- Keep `alt` text contextual to the recipe title when available
- For decorative empty-state graphics, use empty alt (`alt=""`) plus nearby descriptive copy
## Usage Guidance
- Public URL pattern: `/assets/<group>/<file>.svg`
- Prefer SVG scaling with CSS `object-fit: cover` for card contexts
- Keep placeholders as deterministic defaults so UI is never image-empty
## Follow-up Suggestions
- Wire fallback logic in card/detail components (T04T06)
- Add visual regression screenshots once integrated
- If future photography is required, only use assets with explicit commercial licenses and record attribution here

View File

@ -0,0 +1,28 @@
# Visual Audit — Baseline Screenshots (BEFORE Redesign)
## Missing Screenshots
Screenshot tooling is currently **unavailable in this environment** (no browser control, no headless preview/capture). Screenshots for the following pages and viewports **still need to be captured** and added here for a complete before/after comparison:
### Primary Screens (Desktop + Mobile):
- Home / Recipe List page
- Recipe Detail page (for at least one real recipe)
- Add/Edit Recipe form (blank and with data)
- Import from URL page
- Cook Mode page
**What remains to capture:**
- For each page above, both **desktop-size** (min 1200px wide) and **mobile-size** (≤375px wide, e.g., iPhone 12/13/14 Pro) screenshots are needed.
- Best practice: show both empty states (no recipes, no tags) and with recipes populated if possible
- Screenshots should reflect the current main branch before any redesign commit
## If Updating This File
When screenshots become available, add them here in subfolders:
- `/home` — Home/list landing
- `/detail` — Recipe detail
- `/add-edit` — Add/Edit form
- `/import-url` — Import URL
- `/cook-mode` — Cook Mode
---
_Last updated: 2026-03-27_

View File

@ -0,0 +1,27 @@
# Visual Audit: Acceptance Checklist (Baseline)
_Baseline requirements for before/after acceptance testing_
- [ ] Screenshots for all key pages (desktop/mobile)
- Home/Recipe List
- Recipe Detail
- Add/Edit form
- Import from URL
- Cook Mode
- [x] Placeholder README if screenshots cannot be captured
- [x] UI/UX audit doc with all issues prioritized by severity and page
- [x] List of key flows/pages
- [x] Accessibility and imagery notes
- [x] Checklist committed for baseline review
## Pass Criteria (for BEFORE state):
- This checklist, audit, and placeholder README present in `docs/visual-audit/before/`
- Screenshots folder structure ready for population (see README)
## Next (AFTER state):
- Repeat this checklist after redesign
- Visually compare all key screens against baseline findings
---
_Last updated: 2026-03-27_

View File

@ -0,0 +1,87 @@
# Recipe Manager UI/UX Audit — Baseline Findings (Pre-Redesign)
_All findings are based on code and asset inspection of the existing main branch as of 2026-03-27._
---
## Key Page Coverage
- **Home / Recipe List**: `/` (RecipeListPage)
- **Recipe Detail**: `/recipe/:id` (RecipeDetailPage)
- **Add / Edit form**: `/recipe/new` and `/recipe/:id` (edit mode in RecipeDetailPage)
- **Import from URL**: `/import/url` (ImportUrlPage)
- **Cook Mode**: `/recipe/:id/cook` (CookModePage)
- **Not Found**: fallback route
---
## Issues & Opportunities — By Page
### 1. Home / Recipe List Page
- **Severity: HIGH**
- No clear visual separation between recipe library and feature highlights sections (hierarchy muddled)
- Search and filter tag UI lack sticky/top persistence for long lists (usability friction)
- **Severity: MEDIUM**
- Tag color accessibility not enforced; tag backgrounds may contrast poorly with white text
- "No recipes" empty state lacks illustration/invitation for mobile
- **Severity: LOW**
- Visual spacing between cards is adequate, but elevation/shadow separation could be stronger
- Edge-case: very large recipe titles can overflow
### 2. Recipe Detail Page
- **Severity: HIGH**
- No image/hero area for recipes — makes page visually flat
- Action buttons (Edit, Cook Mode, Delete) overwhelm above-the-fold space, all equally visually weighted
- Tag badges: insufficient color contrast, risk of not being distinguishable
- **Severity: MEDIUM**
- Stats grid (prep time/servings/cook time) sometimes missing if fields absent, leading to awkward negative space
- **Severity: LOW**
- Additional Information section can be overlooked (visual hierarchy weak)
### 3. Add/Edit Recipe Form
- **Severity: HIGH**
- Long single form, minimal grouping/hierarchy; no inline field validation or visual feedback on invalid states
- No photo upload/preview capability
- **Severity: MEDIUM**
- Lack of step-by-step clarity for ingredient vs instructions entry
- **Severity: LOW**
- No help text/examples for field format (e.g. "1 cup flour")
### 4. Import from URL
- **Severity: MEDIUM**
- Progress indicators use color only (AA contrast edge-cases possible, e.g. blue-on-white)
- **Severity: LOW**
- Errors are shown contextually, but do not visually block/disable further input
### 5. Cook Mode
- **Severity: HIGH**
- No sticky/fixed controls for moving between ingredients/steps (usability on mobile)
- Progress bars for steps/ingredients provide good feedback but blending color could limit clarity for colorblind users
- **Severity: LOW**
- Success/checkoff state lacks distinct visual reward (just celebratory emoji and card)
---
## Cross-cutting / General Issues
- **Accessibility:**
- Some color choices insufficient for AA contrast in tag buttons/badges
- Keyboard navigation generally present but focus rings partly overridden (potential risk)
- **Imagery:**
- Strong reliance on fallback illustration — only 1 bundled hero image (`hero.png`) for all recipes
- No per-recipe image or placeholder; flat visual identity
---
## Acceptance Checklist — Baseline
- [ ] Screenshots for all primary pages (desktop/mobile)
- [x] Placeholder README in `docs/visual-audit/before/` (documenting screenshot gap)
- [x] List of all key pages and views
- [x] Audit notes: all UX/UI issues prioritized and categorized by page
- [x] Accessibility/opportunity notes captured
- [x] General imagery and identity state described
- [x] This audit document committed for baseline comparison
_For later: Repeat all QA for new visual implementation, providing strict before/after comparisons for each line item._
---
_Last updated: 2026-03-27_

View File

@ -0,0 +1,87 @@
# Photo-First Asset Strategy (Correction Wave)
Date: 2026-03-27
Scope: Art direction + asset structure only (no page redesign)
## 1) Decision
Move Recipe Manager from **illustration-first** to **photo-first** for food storytelling.
- Primary storytelling media: real food photography
- Secondary/support media: restrained UI icons
- Legacy custom illustrations: fallback/decorative only
## 2) Deprecation matrix (from T03)
### Deprecate as primary visuals
- `/assets/hero/hero-kitchen-light.svg`
- `/assets/hero/hero-fresh-produce.svg`
- `/assets/food/curated/*`
- `/assets/category/illustrations/*`
These are visually clean but feel stylized/childlike relative to modern recipe products.
### Retain
- `/assets/category/icon-*.svg` (small utility icons)
- `/assets/food/placeholder-recipe*.svg` (safe local fallback)
- `/assets/empty-state/*.svg` (temporary until photo-backed empty states are added)
## 3) Page-level photo strategy
### Homepage
- Use one high-quality hero photo (landscape, 16:9 or 3:2) with warm food context.
- Optional supporting strip of 23 photos for social proof/variety.
- Avoid illustrated hero artwork for main CTA section.
### Recipe list/grid
- Recipe cards should show photo thumbnails (4:3 preferred).
- If recipe has no image, use deterministic placeholder fallback.
- Keep overlays minimal (chip badges + subtle gradient for text legibility).
### Recipe detail
- Hero image near top (16:9 preferred) with metadata chips.
- Support additional gallery images only if available; do not require for MVP.
- Fallback to placeholder for legacy/imported entries without images.
## 4) Asset architecture
New canonical path for photo-first pipeline:
- `frontend/public/assets/photos/manifest.json`
- `frontend/public/assets/photos/{hero,list,detail,category,empty-state,credits}/`
Manifest-first model:
- UI consumes slots by semantic keys, not hardcoded filenames.
- Each slot can specify:
- `localPath` (vendored file in repo)
- `remoteCandidate` (approved source candidate)
- `license`
- `attributionRequired`
- `status`
## 5) Sourcing and licensing policy
Recommended source order:
1. **Pexels** (free commercial use, attribution generally not required)
2. **Unsplash** (free use under Unsplash License; attribution recommended)
3. **Owned/internal photos** (best legal clarity)
Guardrails:
- Validate license terms at download time and record snapshot metadata.
- Avoid editorial-only assets and recognizable private persons unless releases are clear.
- Keep a source record in manifest and, when required, add attribution notes in `assets/photos/credits/`.
## 6) Icon direction (explicit)
- Standardize on `lucide-react` for UI actions/navigation.
- Icon role: functional affordance only (filter, search, timer, servings, favorite).
- Style: 1820px for controls, 24px section accents, stroke 1.752.0, no emoji, no decorative food doodles.
## 7) Implementation readiness
This correction wave is implementation-ready for Wave 2.5 UI workers because:
- deprecations are explicit,
- photo slots are defined via manifest,
- legal/source workflow is documented,
- fallback behavior is preserved.
Ready-for-implementation: **YES**

View File

@ -0,0 +1,95 @@
# Visual Style Guide
Status: T02 baseline complete
Scope: Global design tokens + component baseline only (no full page redesign)
## 1) Design intent
A warm, appetizing visual language for a modern recipe app:
- **Warm brand hue:** orange/amber (food-forward, inviting)
- **Clean surfaces:** soft cream background + white cards
- **Readable typography:** high contrast body text, expressive heading serif
- **Low-friction interactions:** consistent focus ring, elevation, and control sizing
## 2) Token sources
- **TS token source:** `frontend/src/theme.ts`
- **Global CSS token source:** `frontend/src/styles/tokens.css`
- **Baseline component classes:** `frontend/src/index.css` (`@layer components`)
## 3) Core tokens
### Color palette
- Brand scale: `brand50``brand800`
- Semantic:
- `primary` / `primaryDark` / `primaryLight`
- `accent`
- `success`, `warning`, `error` (+ light variants)
- Surfaces/text:
- `bg`, `bgAlt`, `surface`, `surfaceMuted`, `border`
- `text`, `textHeading`, `textDim`
### Typography scale
- Families:
- Sans: `Inter, Manrope, system-ui...`
- Heading: `Fraunces, Inter, Georgia, serif`
- Mono: `ui-monospace...`
- Sizes: `xs`, `sm`, `base`, `lg`, `xl`, `2xl`, `3xl`, `4xl`
- Line heights: `tight`, `normal`, `relaxed`
### Layout primitives
- Spacing: `xxs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl`
- Radius: `xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `full`
- Elevation:
- `subtle`
- `card`
- `hover`
- Focus:
- `focus` token in TS
- `--focus-ring` token in CSS
## 4) Component baseline classes
Defined in `frontend/src/index.css`:
- **Page container:** `.ui-page`
- **Section container:** `.ui-section`
- **Card container:** `.ui-card`
- **Buttons:**
- base `.ui-btn`
- variants `.ui-btn-primary`, `.ui-btn-secondary`
- **Inputs:** `.ui-input`, `.ui-textarea`, `.ui-select`
- **Chips/Badges:** `.ui-chip`, `.ui-badge`
All of these share tokenized radius, border, spacing, shadows, and focus behavior.
## 5) Adoption in current baseline
Initial integration applied to reduce disruption:
- `RecipeListPage` page/section/button/input/chip/badge usage
- `RecipeForm` input/textarea/button usage
- `TagSelector` chip/input/button usage
- `RecipeCard` card/chip usage
## 6) Usage pattern
Prefer semantic baseline classes + utility classes for layout only.
Example:
```tsx
<button className="ui-btn ui-btn-primary px-5">Save Recipe</button>
<input className="ui-input" />
<span className="ui-chip">Vegetarian</span>
```
## 7) Notes for next tasks (T04+)
- Reuse these primitives before introducing one-off visual patterns.
- Keep all new colors mapped to semantic tokens.
- Preserve AA contrast and focus visibility for every interactive control.
## 8) Imagery and icon direction update (Photo-First)
- Food storytelling should use real photography as the default medium.
- Legacy custom illustrations are fallback/decorative only.
- Prefer photographic aspect ratios:
- list cards: 4:3
- detail hero: 16:9 (or 3:2)
- square thumbnails: 1:1
- Keep icons modern and restrained; standardize on `lucide-react` for action/navigation icons.
- Avoid emoji icons in production UI where a system icon exists.
See also: `docs/visual-audit/photo-first-assets.md` and `frontend/public/assets/photos/manifest.json`.

43
fix-image-paths.js Normal file
View File

@ -0,0 +1,43 @@
#!/usr/bin/env node
import initSqlJs from 'sql.js';
import fs from 'fs';
import path from 'path';
const DB_PATH = 'data/recipes.db';
async function fixImagePaths() {
const SQL = await initSqlJs();
const dbBuffer = fs.readFileSync(DB_PATH);
const db = new SQL.Database(dbBuffer);
// Get all recipes with image_url starting with 'images/'
const recipes = db.exec(`
SELECT id, title, image_url
FROM recipes
WHERE image_url LIKE 'images/%'
`);
if (recipes.length === 0 || recipes[0].values.length === 0) {
console.log('✓ No image paths to update');
return;
}
let updated = 0;
recipes[0].values.forEach(([id, title, imageUrl]) => {
// Change 'images/file.jpg' to '/images/file.jpg'
const newUrl = '/' + imageUrl;
db.run(`UPDATE recipes SET image_url = ? WHERE id = ?`, [newUrl, id]);
console.log(`✓ Updated recipe ${id} (${title}): ${imageUrl}${newUrl}`);
updated++;
});
// Save the database
const data = db.export();
fs.writeFileSync(DB_PATH, data);
db.close();
console.log(`\n✓ Fixed ${updated} image paths`);
}
fixImagePaths().catch(console.error);

View File

@ -26,6 +26,10 @@ npm run preview
# Lint code # Lint code
npm run lint npm run lint
# Style governance checks (token/primitive drift guardrails)
npm run style:guardrails
npm run style:check
``` ```
## Project Structure ## Project Structure
@ -60,3 +64,13 @@ See `/ARCHITECTURE.md` for full system architecture and patterns.
- The homepage hero uses a fallback chain: bundled `hero.png` first, then `/images/hero-fallback.svg`. - The homepage hero uses a fallback chain: bundled `hero.png` first, then `/images/hero-fallback.svg`.
- Keep fallback assets lightweight (SVG preferred) and store browser-served fallbacks under `public/images/`. - Keep fallback assets lightweight (SVG preferred) and store browser-served fallbacks under `public/images/`.
- Any new UI-critical image should follow the same fallback pattern to avoid broken-image regressions in production. - Any new UI-critical image should follow the same fallback pattern to avoid broken-image regressions in production.
## Styling Guardrails
This frontend uses a token + primitive styling contract.
- Governance doc: `../.harness/docs/styling-governance.md`
- Token contract: `../.harness/docs/styling-token-contract.md`
- Primitive contract: `../.harness/docs/ui-primitive-contract.md`
Use `npm run style:guardrails` before PRs touching app shell/shared UI surfaces.

View File

@ -7,7 +7,9 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"style:guardrails": "node ./scripts/style-guardrails.mjs",
"style:check": "npm run style:guardrails && npm run lint"
}, },
"dependencies": { "dependencies": {
"react": "^19.2.4", "react": "^19.2.4",

View File

@ -1,21 +1,28 @@
# Visual Asset Pack (T03) # Frontend Visual Assets
This directory contains **copyright-safe, generated-in-project assets** for the visual redesign. This folder contains visual assets for Recipe Manager.
## Structure ## Directory map
- `food/` — recipe/photo placeholders for cards, detail hero, and upload dropzone states - `photos/`**new photo-first asset surface** (manifest + future vendored photos)
- `category/` — category icon SVGs (breakfast, lunch, dinner, dessert, snack) - `hero/` — legacy illustration hero assets (deprecated for primary storytelling)
- `empty-state/` — illustrations for no recipes, no favorites, and no search results - `food/` — placeholders and legacy illustrated food assets
- `ui/` — reserved for additional decorative UI assets - `food/curated/` — legacy illustrated recipe art (deprecated for cards)
- `category/` — category icons (retain)
- `category/illustrations/` — legacy illustrated category art (deprecated)
- `empty-state/` — empty-state illustrations (retain temporarily)
- `ui/` — shared decorative UI assets
## Usage Notes ## Direction update
- Reference from frontend as absolute public URLs, e.g. `/assets/food/placeholder-recipe.svg` As of the photo-first correction wave, **real food photography is the primary visual direction**.
- Prefer SVG placeholders over external stock images until approved imagery is available Illustrations are now fallback/support-only unless explicitly approved.
- Keep files lightweight and editable (plain SVG)
## License Safety ## Source and licensing
All assets in this folder were authored for this project and are safe for internal/commercial use under repository ownership. - Legacy SVG pack: original repository-owned artwork, no attribution required.
No third-party copyrighted graphics are included. - Photo-first pipeline: see `photos/README.md` and `photos/manifest.json`.
Docs:
- `docs/visual-audit/assets.md`
- `docs/visual-audit/photo-first-assets.md`

View File

@ -0,0 +1 @@
<svg width="512" height="384" viewBox="0 0 512 384" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">Breakfast category illustration</title><desc id="d">Original in-project breakfast illustration with sun and plate.</desc><rect width="512" height="384" fill="#FFF7ED"/><circle cx="408" cy="88" r="44" fill="#FCD34D"/><ellipse cx="256" cy="246" rx="148" ry="54" fill="#FFFFFF" stroke="#FDBA74" stroke-width="4"/><circle cx="256" cy="242" r="36" fill="#FBBF24"/></svg>

After

Width:  |  Height:  |  Size: 499 B

View File

@ -0,0 +1 @@
<svg width="512" height="384" viewBox="0 0 512 384" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">Dessert category illustration</title><desc id="d">Original in-project dessert illustration with cupcake silhouette.</desc><rect width="512" height="384" fill="#FDF2F8"/><path d="M180 252H332L314 306H198L180 252Z" fill="#F472B6"/><path d="M188 252C188 212 218 178 256 178C294 178 324 212 324 252H188Z" fill="#FB7185"/><circle cx="256" cy="168" r="12" fill="#EF4444"/></svg>

After

Width:  |  Height:  |  Size: 506 B

View File

@ -0,0 +1 @@
<svg width="512" height="384" viewBox="0 0 512 384" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">Dinner category illustration</title><desc id="d">Original in-project dinner illustration with cloche and stars.</desc><rect width="512" height="384" fill="#EEF2FF"/><circle cx="106" cy="90" r="6" fill="#C4B5FD"/><circle cx="146" cy="70" r="4" fill="#C4B5FD"/><circle cx="166" cy="106" r="5" fill="#C4B5FD"/><path d="M130 252C130 184 186 132 256 132C326 132 382 184 382 252H130Z" fill="#A5B4FC"/><rect x="112" y="252" width="288" height="28" rx="14" fill="#6366F1"/></svg>

After

Width:  |  Height:  |  Size: 605 B

View File

@ -0,0 +1 @@
<svg width="800" height="600" viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">No cooking history illustration</title><desc id="d">Original in-project empty-state illustration for no recent cooking activity.</desc><rect width="800" height="600" fill="#F8FAFC"/><rect x="220" y="150" width="360" height="320" rx="24" fill="#FFFFFF" stroke="#CBD5E1" stroke-width="4"/><rect x="280" y="220" width="240" height="18" rx="9" fill="#E2E8F0"/><rect x="280" y="260" width="180" height="18" rx="9" fill="#E2E8F0"/><rect x="280" y="300" width="220" height="18" rx="9" fill="#E2E8F0"/><circle cx="400" cy="410" r="42" fill="#DBEAFE"/><path d="M400 392V418L418 428" stroke="#2563EB" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 796 B

View File

@ -0,0 +1 @@
<svg width="1200" height="900" viewBox="0 0 1200 900" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">Salad bowl placeholder</title><desc id="d">Original in-project illustration representing a fresh salad bowl.</desc><rect width="1200" height="900" fill="#F0FDF4"/><ellipse cx="600" cy="590" rx="280" ry="120" fill="#DCFCE7"/><path d="M320 560C320 470 445 400 600 400C755 400 880 470 880 560C880 650 755 720 600 720C445 720 320 650 320 560Z" fill="#FFFFFF" stroke="#86EFAC" stroke-width="6"/><circle cx="510" cy="540" r="42" fill="#34D399"/><circle cx="585" cy="510" r="36" fill="#F59E0B"/><circle cx="655" cy="550" r="44" fill="#22C55E"/><circle cx="730" cy="515" r="34" fill="#F97316"/><circle cx="600" cy="575" r="40" fill="#10B981"/></svg>

After

Width:  |  Height:  |  Size: 777 B

View File

@ -0,0 +1 @@
<svg width="1200" height="900" viewBox="0 0 1200 900" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">Breakfast toast placeholder</title><desc id="d">Original in-project illustration of toast and egg breakfast.</desc><rect width="1200" height="900" fill="#EFF6FF"/><rect x="360" y="380" width="480" height="300" rx="40" fill="#FFFFFF" stroke="#BFDBFE" stroke-width="6"/><rect x="440" y="450" width="180" height="170" rx="26" fill="#D97706"/><rect x="460" y="470" width="140" height="130" rx="20" fill="#FCD34D"/><ellipse cx="700" cy="530" rx="90" ry="70" fill="#FFFFFF" stroke="#E5E7EB" stroke-width="4"/><circle cx="700" cy="530" r="30" fill="#FBBF24"/><circle cx="570" cy="420" r="18" fill="#F59E0B"/><circle cx="620" cy="410" r="14" fill="#F59E0B"/></svg>

After

Width:  |  Height:  |  Size: 792 B

View File

@ -0,0 +1 @@
<svg width="1200" height="900" viewBox="0 0 1200 900" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">Dessert cake placeholder</title><desc id="d">Original in-project illustration of cake slice and berry garnish.</desc><rect width="1200" height="900" fill="#FDF2F8"/><ellipse cx="600" cy="620" rx="320" ry="120" fill="#FBCFE8"/><path d="M430 630L570 420H780L640 630Z" fill="#F9A8D4" stroke="#EC4899" stroke-width="6"/><path d="M570 420H780L740 490H530Z" fill="#FDE68A"/><circle cx="705" cy="500" r="18" fill="#EF4444"/><circle cx="665" cy="515" r="16" fill="#EF4444"/><rect x="430" y="630" width="210" height="24" rx="12" fill="#EC4899"/></svg>

After

Width:  |  Height:  |  Size: 678 B

View File

@ -0,0 +1 @@
<svg width="1200" height="900" viewBox="0 0 1200 900" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">Pasta plate placeholder</title><desc id="d">Original in-project illustration representing pasta dish.</desc><rect width="1200" height="900" fill="#FFF7ED"/><ellipse cx="600" cy="560" rx="320" ry="140" fill="#FED7AA"/><ellipse cx="600" cy="550" rx="280" ry="110" fill="#FFFFFF" stroke="#FDBA74" stroke-width="6"/><path d="M430 530C520 470 680 470 770 530" stroke="#F59E0B" stroke-width="20" stroke-linecap="round"/><path d="M420 565C515 505 685 505 780 565" stroke="#FBBF24" stroke-width="20" stroke-linecap="round"/><path d="M450 600C535 550 665 550 750 600" stroke="#F59E0B" stroke-width="20" stroke-linecap="round"/><circle cx="500" cy="490" r="16" fill="#EF4444"/><circle cx="700" cy="500" r="14" fill="#EF4444"/><circle cx="620" cy="615" r="14" fill="#16A34A"/></svg>

After

Width:  |  Height:  |  Size: 907 B

View File

@ -0,0 +1 @@
<svg width="1200" height="900" viewBox="0 0 1200 900" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">Smoothie glass placeholder</title><desc id="d">Original in-project illustration of fruit smoothie drink.</desc><rect width="1200" height="900" fill="#ECFEFF"/><rect x="500" y="280" width="200" height="430" rx="28" fill="#FFFFFF" stroke="#67E8F9" stroke-width="6"/><rect x="520" y="370" width="160" height="300" rx="20" fill="#22D3EE"/><path d="M600 280V180" stroke="#0891B2" stroke-width="10" stroke-linecap="round"/><path d="M600 180L670 120" stroke="#0891B2" stroke-width="10" stroke-linecap="round"/><circle cx="560" cy="340" r="22" fill="#F87171"/><circle cx="640" cy="330" r="20" fill="#A3E635"/><circle cx="700" cy="680" r="26" fill="#06B6D4" opacity="0.4"/><circle cx="480" cy="690" r="24" fill="#06B6D4" opacity="0.3"/></svg>

After

Width:  |  Height:  |  Size: 869 B

View File

@ -0,0 +1 @@
<svg width="1200" height="900" viewBox="0 0 1200 900" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">Soup bowl placeholder</title><desc id="d">Original in-project illustration of warm soup bowl.</desc><rect width="1200" height="900" fill="#FFF1F2"/><ellipse cx="600" cy="610" rx="300" ry="110" fill="#FECDD3"/><path d="M360 560C360 470 467 400 600 400C733 400 840 470 840 560C840 650 733 720 600 720C467 720 360 650 360 560Z" fill="#FFFFFF" stroke="#FDA4AF" stroke-width="6"/><ellipse cx="600" cy="560" rx="210" ry="80" fill="#FB923C"/><path d="M500 470C485 430 510 395 545 380" stroke="#FCA5A5" stroke-width="12" stroke-linecap="round"/><path d="M600 470C585 430 610 395 645 380" stroke="#FCA5A5" stroke-width="12" stroke-linecap="round"/><path d="M700 470C685 430 710 395 745 380" stroke="#FCA5A5" stroke-width="12" stroke-linecap="round"/></svg>

After

Width:  |  Height:  |  Size: 883 B

View File

@ -0,0 +1,25 @@
<svg width="1600" height="900" viewBox="0 0 1600 900" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">Fresh produce hero illustration</title>
<desc id="desc">Original abstract produce composition with leafy accents and platter for recipe app hero usage.</desc>
<defs>
<linearGradient id="bg2" x1="0" y1="0" x2="1600" y2="900" gradientUnits="userSpaceOnUse">
<stop stop-color="#ECFDF3"/>
<stop offset="1" stop-color="#DCFCE7"/>
</linearGradient>
</defs>
<rect width="1600" height="900" fill="url(#bg2)"/>
<ellipse cx="800" cy="640" rx="500" ry="150" fill="#BBF7D0"/>
<ellipse cx="800" cy="630" rx="420" ry="120" fill="#FFFFFF" stroke="#A7F3D0" stroke-width="6"/>
<path d="M300 370C370 300 430 300 500 370" stroke="#16A34A" stroke-width="28" stroke-linecap="round"/>
<path d="M1100 370C1170 300 1230 300 1300 370" stroke="#16A34A" stroke-width="28" stroke-linecap="round"/>
<circle cx="640" cy="590" r="56" fill="#F97316"/>
<circle cx="740" cy="565" r="46" fill="#FB7185"/>
<circle cx="840" cy="598" r="52" fill="#FACC15"/>
<circle cx="945" cy="560" r="48" fill="#A3E635"/>
<circle cx="1040" cy="592" r="50" fill="#34D399"/>
<ellipse cx="640" cy="515" rx="26" ry="10" fill="#22C55E"/>
<ellipse cx="740" cy="498" rx="22" ry="9" fill="#22C55E"/>
<ellipse cx="840" cy="520" rx="24" ry="10" fill="#22C55E"/>
<ellipse cx="945" cy="493" rx="22" ry="9" fill="#22C55E"/>
<ellipse cx="1040" cy="523" rx="24" ry="10" fill="#22C55E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,28 @@
<svg width="1600" height="900" viewBox="0 0 1600 900" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">Kitchen prep hero illustration</title>
<desc id="desc">Original abstract kitchen-themed hero illustration with produce, bowl and utensils.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1600" y2="900" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF8F1"/>
<stop offset="1" stop-color="#FEEDE2"/>
</linearGradient>
<linearGradient id="plate" x1="520" y1="340" x2="1040" y2="700" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#F5F7FF"/>
</linearGradient>
</defs>
<rect width="1600" height="900" fill="url(#bg)"/>
<circle cx="1230" cy="210" r="120" fill="#FFE4B8" opacity="0.7"/>
<circle cx="240" cy="180" r="90" fill="#DDF4FF" opacity="0.7"/>
<rect x="160" y="560" width="1280" height="190" rx="24" fill="#F0D7C1"/>
<ellipse cx="800" cy="560" rx="360" ry="120" fill="url(#plate)" stroke="#E5E7EB" stroke-width="6"/>
<ellipse cx="800" cy="560" rx="250" ry="76" fill="#F7FAFC"/>
<circle cx="700" cy="530" r="38" fill="#FF6B6B"/>
<circle cx="760" cy="576" r="32" fill="#4CAF50"/>
<circle cx="855" cy="520" r="34" fill="#F59E0B"/>
<circle cx="910" cy="582" r="30" fill="#22C55E"/>
<rect x="470" y="410" width="58" height="180" rx="28" fill="#8B5E3C"/>
<rect x="1072" y="408" width="58" height="180" rx="28" fill="#8B5E3C"/>
<path d="M499 390C499 354 526 328 560 328C594 328 621 354 621 390V420H499V390Z" fill="#B08968"/>
<path d="M1099 390C1099 354 1126 328 1160 328C1194 328 1221 354 1221 390V420H1099V390Z" fill="#B08968"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,34 @@
# Photo-First Assets (Wave 2.5)
This directory is the handoff surface for migrating from illustration-first to photo-first art direction.
## Current status
- `manifest.json` is the source of truth for photo slots and candidate sources.
- No third-party photos are vendored yet in this repo.
- Existing SVG placeholders remain as safe local fallbacks until approved photos are added.
## Folder intent
- `hero/` — homepage/detail hero photos (`.jpg`/`.webp`)
- `list/` — 4:3 and 1:1 card/list photos
- `detail/` — 16:9 or 3:2 detail hero photos
- `category/` — optional category cover photos
- `empty-state/` — optional minimal photographic empty-state support
- `credits/` — attribution and source snapshots when required
## Licensing guardrails
Preferred source order:
1. **Pexels** (free commercial use, attribution typically not required)
2. **Unsplash** (free use under Unsplash License, attribution appreciated)
3. **Owned/internal photos** (full control)
Rules:
- Verify license at time of download and record it in `manifest.json`.
- Never ingest images marked editorial-only or with recognizable private persons without release confidence.
- Keep `sourceUrl`, `license`, and `downloadedAt` metadata for every vendored photo.
## Integration
UI workers should consume `manifest.json` and gracefully fallback to local placeholders when `localPath` is null.

View File

@ -0,0 +1,76 @@
{
"version": 1,
"updatedAt": "2026-03-27",
"notes": "Photo-first manifest for Wave 2.5 UI integration. Do not assume listed external photos are vendored until localPath exists.",
"usage": {
"homepageHero": "assets.photos.hero.homepage.primary",
"listCardFallback": "assets.photos.list.default4x3",
"detailHeroFallback": "assets.photos.detail.default16x9",
"emptyStates": "assets.photos.emptyState"
},
"assets": {
"photos": {
"hero": {
"homepage": {
"primary": {
"id": "hero-market-produce-01",
"localPath": null,
"remoteCandidate": "https://images.pexels.com/photos/1640774/pexels-photo-1640774.jpeg",
"license": "Pexels License",
"attributionRequired": false,
"status": "candidate-not-vendored"
},
"fallback": [
"/assets/food/placeholder-recipe.svg"
]
}
},
"list": {
"default4x3": "/assets/food/placeholder-recipe-4x3.svg",
"default1x1": "/assets/food/placeholder-recipe-1x1.svg"
},
"detail": {
"default16x9": "/assets/food/placeholder-recipe.svg"
},
"category": {
"breakfast": {
"localPath": null,
"remoteCandidate": "https://images.pexels.com/photos/70497/pexels-photo-70497.jpeg",
"license": "Pexels License",
"attributionRequired": false,
"status": "candidate-not-vendored"
},
"dinner": {
"localPath": null,
"remoteCandidate": "https://images.pexels.com/photos/1279330/pexels-photo-1279330.jpeg",
"license": "Pexels License",
"attributionRequired": false,
"status": "candidate-not-vendored"
},
"dessert": {
"localPath": null,
"remoteCandidate": "https://images.pexels.com/photos/291528/pexels-photo-291528.jpeg",
"license": "Pexels License",
"attributionRequired": false,
"status": "candidate-not-vendored"
}
},
"emptyState": {
"noRecipes": "/assets/empty-state/no-recipes.svg",
"noResults": "/assets/empty-state/no-results-search.svg",
"noFavorites": "/assets/empty-state/no-favorites.svg"
}
},
"icons": {
"recommendedLibrary": "lucide-react",
"size": {
"control": 18,
"section": 24
},
"strokeWidth": {
"default": 1.75
},
"policy": "Use icons for navigation/actions only. Do not use illustrated iconography for food storytelling."
}
}
}

View File

@ -0,0 +1,76 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const projectRoot = process.cwd();
const scopedFiles = [
'src/App.tsx',
'src/components/MissionControlPanel.tsx',
'src/components/ui/primitives.tsx',
'src/components/ErrorBoundary.tsx',
'src/pages/NotFoundPage.tsx',
'src/pages/RecipeListPage.tsx',
'src/components/RecipeCard.tsx',
'src/components/Toast.tsx',
].map((file) => path.resolve(projectRoot, file));
const bannedPatterns = [
{
name: 'raw-tailwind-palette',
regex:
/\b(?:bg|text|border|ring|from|to|via|outline|decoration)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}\b/g,
message:
'Use tokenized classes/variables instead of direct Tailwind palette utilities (e.g. bg-slate-200, text-blue-600).',
},
{
name: 'hex-in-arbitrary-class',
regex: /\b(?:bg|text|border|ring|from|to|via)-\[#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\]\b/g,
message:
'Avoid hardcoded hex colors in className. Add/consume a semantic token in tokens.css instead.',
},
];
function readFile(filePath) {
return fs.readFileSync(filePath, 'utf8');
}
function lineNumberAt(text, index) {
return text.slice(0, index).split('\n').length;
}
const findings = [];
for (const filePath of scopedFiles) {
if (!fs.existsSync(filePath)) continue;
const content = readFile(filePath);
for (const rule of bannedPatterns) {
for (const match of content.matchAll(rule.regex)) {
findings.push({
filePath,
line: lineNumberAt(content, match.index ?? 0),
value: match[0],
rule: rule.name,
message: rule.message,
});
}
}
}
if (findings.length === 0) {
console.log('✅ style-guardrails: no blocked raw palette patterns found in scoped files.');
process.exit(0);
}
console.error('❌ style-guardrails: found styling drift patterns in guarded files:\n');
for (const finding of findings) {
const relativePath = path.relative(projectRoot, finding.filePath);
console.error(`- ${relativePath}:${finding.line} [${finding.rule}] ${finding.value}`);
console.error(` ${finding.message}`);
}
console.error('\nSee .harness/docs/styling-governance.md for the contract and escalation path.');
process.exit(1);

View File

@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View File

@ -8,6 +8,8 @@ import { ErrorBoundary } from './components/ErrorBoundary';
import { ToastContainer } from './components/Toast'; import { ToastContainer } from './components/Toast';
import { useToast } from './hooks/useToast'; import { useToast } from './hooks/useToast';
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
import { UiPage } from './components/ui/primitives';
import { cn } from './components/ui/cn';
interface ToastContextType { interface ToastContextType {
success: (message: string, duration?: number) => string; success: (message: string, duration?: number) => string;
@ -63,30 +65,30 @@ function App() {
return false; return false;
}; };
const linkClass = (path: string) => { const linkClass = (path: string) =>
const base = cn(
'group/nav relative inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold transition-all duration-200 ease-out outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 focus-visible:ring-offset-white motion-safe:hover:-translate-y-0.5'; 'ui-btn min-h-0 rounded-full border border-transparent px-4 py-2 text-sm font-semibold shadow-none',
return isActive(path) isActive(path)
? `${base} bg-blue-100 text-blue-700 shadow-sm` ? 'bg-[var(--color-primary-light)] text-[var(--color-primary-dark)]'
: `${base} text-slate-700 hover:bg-slate-100 hover:text-slate-900`; : 'bg-transparent text-[var(--text-dim)] hover:bg-[var(--surface-muted)] hover:text-[var(--text)]',
}; );
return ( return (
<ErrorBoundary> <ErrorBoundary>
<ToastContext.Provider value={toast}> <ToastContext.Provider value={toast}>
<div className="min-h-screen"> <div className="min-h-screen text-[var(--text)]">
<ToastContainer messages={toast.messages} onClose={toast.removeToast} /> <ToastContainer messages={toast.messages} onClose={toast.removeToast} />
<header className="sticky top-0 z-20 border-b border-slate-200/70 bg-white/85 shadow-sm backdrop-blur-md dark:border-slate-700/60 dark:bg-slate-900/70"> <header className="sticky top-0 z-20 border-b border-[var(--border)]/70 bg-[var(--surface)]/85 shadow-sm backdrop-blur-md">
<div className="mx-auto max-w-7xl px-4"> <UiPage className="px-4 py-0">
<div className="flex h-16 items-center justify-between gap-3"> <div className="flex h-16 items-center justify-between gap-3">
<div className="flex items-center"> <div className="flex items-center">
<Link <Link
to="/" to="/"
className="group inline-flex items-center gap-2 rounded-lg px-2 py-1.5 outline-none transition-colors duration-200 hover:text-blue-700 focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2" className="group inline-flex items-center gap-2 rounded-lg px-2 py-1.5 outline-none transition-colors duration-200 hover:text-[var(--color-primary-dark)]"
> >
<span className="text-xl" aria-hidden="true">🍽</span> <span className="text-xl" aria-hidden="true">🍽</span>
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">Recipe Manager</h1> <h1 className="text-xl font-bold tracking-tight text-[var(--text-h)] sm:text-2xl">Recipe Manager</h1>
</Link> </Link>
</div> </div>
<nav aria-label="Primary" className="flex flex-wrap items-center justify-end gap-2 sm:gap-3"> <nav aria-label="Primary" className="flex flex-wrap items-center justify-end gap-2 sm:gap-3">
@ -104,10 +106,10 @@ function App() {
</Link> </Link>
</nav> </nav>
</div> </div>
</div> </UiPage>
</header> </header>
<main className="mx-auto min-h-[70vh] max-w-7xl px-4 py-8"> <main className="ui-page min-h-[70vh] px-4 py-8">
<Routes> <Routes>
<Route path="/" element={<RecipeListPage />} /> <Route path="/" element={<RecipeListPage />} />
<Route path="/recipe/new" element={<RecipeDetailPage />} /> <Route path="/recipe/new" element={<RecipeDetailPage />} />
@ -118,25 +120,25 @@ function App() {
</Routes> </Routes>
</main> </main>
<footer className="mt-10 border-t border-slate-200/80 bg-gradient-to-br from-white/90 to-slate-100/90 backdrop-blur-sm dark:border-slate-700/60 dark:from-slate-900/60 dark:to-slate-900/30"> <footer className="mt-10 border-t border-[var(--border)]/80 bg-gradient-to-br from-[var(--surface)]/95 to-[var(--surface-muted)]/90 backdrop-blur-sm">
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-6 px-4 py-8 sm:grid-cols-2 lg:grid-cols-3"> <UiPage className="grid grid-cols-1 gap-6 px-4 py-8 sm:grid-cols-2 lg:grid-cols-3">
<div> <div>
<p className="text-sm font-semibold uppercase tracking-wide text-blue-700">Recipe Manager</p> <p className="text-sm font-semibold uppercase tracking-wide text-[var(--color-primary-dark)]">Recipe Manager</p>
<p className="mt-2 text-sm text-slate-600">Save recipes, organize by tags, and keep your kitchen workflow simple.</p> <p className="mt-2 text-sm text-[var(--text-dim)]">Save recipes, organize by tags, and keep your kitchen workflow simple.</p>
</div> </div>
<div> <div>
<p className="text-sm font-semibold uppercase tracking-wide text-slate-700">Quick Links</p> <p className="text-sm font-semibold uppercase tracking-wide text-[var(--text)]">Quick Links</p>
<div className="mt-2 flex flex-wrap gap-2 text-sm text-slate-600"> <div className="mt-2 flex flex-wrap gap-2 text-sm text-[var(--text-dim)]">
<Link to="/" className="rounded-full bg-white px-3 py-1 hover:bg-slate-100">Browse</Link> <Link to="/" className="ui-chip bg-[var(--surface)] px-3 py-1 hover:bg-[var(--surface-muted)]">Browse</Link>
<Link to="/recipe/new" className="rounded-full bg-white px-3 py-1 hover:bg-slate-100">Add Recipe</Link> <Link to="/recipe/new" className="ui-chip bg-[var(--surface)] px-3 py-1 hover:bg-[var(--surface-muted)]">Add Recipe</Link>
<Link to="/import/url" className="rounded-full bg-white px-3 py-1 hover:bg-slate-100">Import URL</Link> <Link to="/import/url" className="ui-chip bg-[var(--surface)] px-3 py-1 hover:bg-[var(--surface-muted)]">Import URL</Link>
</div> </div>
</div> </div>
<div className="sm:col-span-2 lg:col-span-1"> <div className="sm:col-span-2 lg:col-span-1">
<p className="text-sm font-semibold uppercase tracking-wide text-slate-700">Built for everyday cooking</p> <p className="text-sm font-semibold uppercase tracking-wide text-[var(--text)]">Built for everyday cooking</p>
<p className="mt-2 text-sm text-slate-600">React + Vite + TypeScript · Visual redesign in progress</p> <p className="mt-2 text-sm text-[var(--text-dim)]">React + Vite + TypeScript · Visual redesign in progress</p>
</div>
</div> </div>
</UiPage>
</footer> </footer>
</div> </div>
</ToastContext.Provider> </ToastContext.Provider>

View File

@ -0,0 +1,42 @@
export const PHOTO_MANIFEST_PATH = '/assets/photos/manifest.json';
export type HomepageHeroPhotoSlot = {
localPath?: string | null;
remoteCandidate?: string | null;
fallback?: string[];
};
export type PhotoManifest = {
assets?: {
photos?: {
hero?: {
homepage?: {
primary?: HomepageHeroPhotoSlot;
};
};
};
};
};
/**
* Wave 2.5 helper:
* Fetch manifest and resolve semantic photo slots with local placeholder fallback.
*/
export async function loadPhotoManifest<T = unknown>(): Promise<T> {
const response = await fetch(PHOTO_MANIFEST_PATH);
if (!response.ok) {
throw new Error(`Failed to load photo manifest: ${response.status}`);
}
return (await response.json()) as T;
}
export function resolveHomepageHeroCandidates(
manifest: PhotoManifest,
defaultFallbacks: string[] = ['/assets/food/placeholder-recipe.svg']
): string[] {
const slot = manifest.assets?.photos?.hero?.homepage?.primary;
const resolved = [slot?.localPath, slot?.remoteCandidate, ...(slot?.fallback ?? []), ...defaultFallbacks]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
return Array.from(new Set(resolved));
}

View File

@ -1,9 +1,17 @@
import heroImage from './hero.png'; import legacyHeroImage from './hero.png';
export const visualAssets = { export const visualAssets = {
hero: { hero: {
primary: heroImage, /**
fallbacks: ['/images/hero-fallback.svg'], * Use T03 public hero assets first so runtime matches the new visual pack.
* Keep legacy/src fallback as a last resort to avoid broken hero rendering.
*/
primary: '/assets/hero/hero-kitchen-light.svg',
fallbacks: [
'/assets/hero/hero-fresh-produce.svg',
legacyHeroImage,
'/assets/food/placeholder-recipe.svg',
],
alt: 'Fresh ingredients and plated food', alt: 'Fresh ingredients and plated food',
}, },
} as const; } as const;

View File

@ -3,6 +3,7 @@
*/ */
import { Component, type ReactNode } from 'react'; import { Component, type ReactNode } from 'react';
import { UiButton, UiCard, UiPage } from './ui/primitives';
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
children: ReactNode; children: ReactNode;
@ -51,42 +52,43 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4"> <UiPage className="flex min-h-screen items-center justify-center px-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-6"> <UiCard className="w-full max-w-md p-6">
<div className="text-center mb-4"> <div className="mb-4 text-center">
<div className="text-6xl mb-4"></div> <div className="mb-4 text-6xl"></div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Something went wrong</h2> <h2 className="mb-2 text-2xl font-bold text-[var(--text-h)]">Something went wrong</h2>
<p className="text-gray-600 mb-4"> <p className="mb-4 text-[var(--text-dim)]">
An unexpected error occurred. Please try refreshing the page. An unexpected error occurred. Please try refreshing the page.
</p> </p>
</div> </div>
<details className="mb-4"> <details className="mb-4">
<summary className="cursor-pointer text-sm text-gray-600 hover:text-gray-800 font-medium"> <summary className="cursor-pointer text-sm font-medium text-[var(--text-dim)] transition-colors hover:text-[var(--text)]">
Error details Error details
</summary> </summary>
<pre className="mt-2 p-3 bg-gray-50 rounded text-xs text-red-600 overflow-auto"> <pre className="mt-2 overflow-auto rounded bg-[var(--surface-muted)] p-3 text-xs text-[var(--color-error)]">
{this.state.error.toString()} {this.state.error.toString()}
{this.state.error.stack && `\n\n${this.state.error.stack}`} {this.state.error.stack && `\n\n${this.state.error.stack}`}
</pre> </pre>
</details> </details>
<div className="flex gap-3"> <div className="flex gap-3">
<button <UiButton
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
className="flex-1 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 font-medium" variant="primary"
className="flex-1"
> >
Refresh Page Refresh Page
</button> </UiButton>
<button <UiButton
onClick={this.resetError} onClick={this.resetError}
className="flex-1 bg-gray-200 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-300 font-medium" className="flex-1"
> >
Try Again Try Again
</button> </UiButton>
</div>
</div>
</div> </div>
</UiCard>
</UiPage>
); );
} }

View File

@ -1,37 +1,35 @@
import type { HarnessStatus } from '../types/recipe'; import type { HarnessStatus } from '../types/recipe';
import { UiSection, UiChip } from './ui/primitives';
function getRecentCommit(status: HarnessStatus) { function getRecentCommit(status: HarnessStatus) {
return status.commit?.relative || status.commit?.hash || ''; return status.commit?.relative || status.commit?.hash || '';
} }
export function MissionControlPanel({ status }: { status: HarnessStatus }) { export function MissionControlPanel({ status }: { status: HarnessStatus }) {
// Defensive for possibly undefined fields
const keepalive = status.keepalive || {}; const keepalive = status.keepalive || {};
const todo = status.todo || { checked: 0, unchecked: 0, nextTask: undefined }; const todo = status.todo || { checked: 0, unchecked: 0, nextTask: undefined };
const heartbeat = status.workerHeartbeatHistory || []; const heartbeat = status.workerHeartbeatHistory || [];
return ( return (
<div className="bg-gray-50 border-b p-4 flex flex-col gap-2"> <UiSection className="mt-4 flex flex-col gap-3 border-[var(--border)]/70 bg-[var(--surface-muted)]/35 p-4" padding="none">
<div className="flex justify-between items-center"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<div className="font-semibold text-lg text-gray-700">Mission Control</div> <div className="text-lg font-semibold text-[var(--text-h)]">Mission Control</div>
<div className="text-xs text-gray-500">Version: {status.version}</div> <div className="text-xs text-[var(--text-dim)]">Version: {status.version}</div>
</div> </div>
<div className="flex gap-4"> <UiChip className="bg-[var(--surface)] text-xs text-[var(--text)]">Git: {getRecentCommit(status) || 'n/a'}</UiChip>
<span className="text-xs text-gray-700">Git: {getRecentCommit(status)}</span>
</div> </div>
<div className="flex flex-wrap gap-2">
<UiChip className="text-xs">Keepalive: {keepalive.status || 'n/a'} ({keepalive.activeSessionLabel || 'none'})</UiChip>
<UiChip className="text-xs">Heartbeat: {keepalive.heartbeatAgeSeconds != null ? `${keepalive.heartbeatAgeSeconds}s ago` : 'n/a'}</UiChip>
<UiChip className="text-xs">Todo: checked {todo.checked ?? 0}/unchecked {todo.unchecked ?? 0}</UiChip>
<UiChip className="text-xs">Next: {todo.nextTask || 'n/a'}</UiChip>
</div> </div>
<div className="flex flex-wrap gap-4 mt-2">
<div className="text-xs">Keepalive: {keepalive.status || 'n/a'} ({keepalive.activeSessionLabel || 'none'})</div>
<div className="text-xs">Heartbeat: {keepalive.heartbeatAgeSeconds != null ? `${keepalive.heartbeatAgeSeconds}s ago` : 'n/a'}</div>
<div className="text-xs">Todo: checked {todo.checked ?? 0}/unchecked {todo.unchecked ?? 0}</div>
<div className="text-xs">Next: {todo.nextTask || 'n/a'}</div>
</div>
{!!heartbeat.length && ( {!!heartbeat.length && (
<div className="text-xs mt-2"> <div className="text-xs text-[var(--text-dim)]">Worker events: {heartbeat.length} ({heartbeat[0]?.timestamp})</div>
Worker events: {heartbeat.length} ({heartbeat[0]?.timestamp})
</div>
)} )}
</div> </UiSection>
); );
} }

View File

@ -1,13 +1,31 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { Recipe, Tag } from '../types/recipe'; import type { Recipe, Tag } from '../types/recipe';
import { colors, radius, recipeAccentPalette, shadows, typography } from '../theme'; import { colors, radius, shadows, typography } from '../theme';
interface PhotoManifestCategoryAsset {
localPath?: string | null;
remoteCandidate?: string | null;
}
interface PhotoManifest {
assets?: {
photos?: {
list?: {
default4x3?: string;
};
category?: Record<string, PhotoManifestCategoryAsset>;
};
};
}
interface RecipeCardProps { interface RecipeCardProps {
recipe: Recipe; recipe: Recipe;
tags?: Tag[]; tags?: Tag[];
viewMode?: 'grid' | 'list';
photoManifest?: PhotoManifest | null;
} }
const foodEmojis = ['🥗', '🍲', '🍝', '🍜', '🍛', '🥘', '🍗', '🍤', '🍕', '🥪', '🍳', '🍱']; const DEFAULT_LIST_FALLBACK = '/assets/food/placeholder-recipe-4x3.svg';
function formatTime(minutes?: number): string { function formatTime(minutes?: number): string {
if (!minutes) return ''; if (!minutes) return '';
@ -17,95 +35,284 @@ function formatTime(minutes?: number): string {
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
} }
function formatDate(timestamp?: number): string { function formatDate(timestamp?: number | null): string {
if (!timestamp) return ''; if (!timestamp) return '';
const date = new Date(timestamp * 1000); const date = new Date(timestamp * 1000);
return date.toLocaleDateString(); return date.toLocaleDateString();
} }
function accentForRecipe(recipe: Recipe, tags: Tag[]) { function inferDifficulty(recipe: Recipe): 'Easy' | 'Medium' | 'Advanced' {
if (tags[0]?.color) return tags[0].color; const totalTime = (recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0);
return recipeAccentPalette[recipe.id % recipeAccentPalette.length]; const ingredientCount = recipe.ingredients.length;
const stepCount = recipe.steps.length;
const complexityScore = totalTime / 20 + ingredientCount / 5 + stepCount / 4;
if (complexityScore < 4) return 'Easy';
if (complexityScore < 8) return 'Medium';
return 'Advanced';
} }
function emojiForRecipe(recipe: Recipe) { function categoryForRecipe(tags: Tag[]): string {
return foodEmojis[recipe.id % foodEmojis.length]; return tags[0]?.name ?? 'Uncategorized';
} }
function MetaChip({ label, value, emoji }: { label: string; value: string; emoji: string }) { function getRecipeImageUrl(recipe: Recipe): string | null {
const candidateKeys = [
'image_url',
'imageUrl',
'photo_url',
'photoUrl',
'thumbnail_url',
'thumbnailUrl',
'image',
'photo',
] as const;
const recipeRecord = recipe as unknown as Record<string, unknown>;
for (const key of candidateKeys) {
const value = recipeRecord[key];
if (typeof value !== 'string') continue;
const trimmed = value.trim();
if (!trimmed) continue;
if (
trimmed.startsWith('http://') ||
trimmed.startsWith('https://') ||
trimmed.startsWith('/') ||
trimmed.startsWith('data:image/') ||
trimmed.startsWith('blob:')
) {
return trimmed;
}
}
return null;
}
function recipeCategoryKey(recipe: Recipe, tags: Tag[]): 'breakfast' | 'dinner' | 'dessert' | null {
const haystack = `${recipe.title} ${tags.map((tag) => tag.name).join(' ')}`.toLowerCase();
if (haystack.includes('dessert') || haystack.includes('cake') || haystack.includes('cookie') || haystack.includes('sweet')) {
return 'dessert';
}
if (haystack.includes('breakfast') || haystack.includes('toast') || haystack.includes('egg')) {
return 'breakfast';
}
if (
haystack.includes('dinner') ||
haystack.includes('main') ||
haystack.includes('pasta') ||
haystack.includes('stew') ||
haystack.includes('soup')
) {
return 'dinner';
}
return null;
}
function listFallbackFromManifest(photoManifest?: PhotoManifest | null): string {
return photoManifest?.assets?.photos?.list?.default4x3 || DEFAULT_LIST_FALLBACK;
}
function categoryImageFromManifest(
key: 'breakfast' | 'dinner' | 'dessert',
photoManifest?: PhotoManifest | null,
): string | null {
const categoryAsset = photoManifest?.assets?.photos?.category?.[key];
if (!categoryAsset) return null;
if (categoryAsset.localPath && categoryAsset.localPath.trim().length > 0) {
return categoryAsset.localPath;
}
if (categoryAsset.remoteCandidate && categoryAsset.remoteCandidate.trim().length > 0) {
return categoryAsset.remoteCandidate;
}
return null;
}
function imageForRecipe(recipe: Recipe, tags: Tag[], photoManifest?: PhotoManifest | null): string {
const directRecipeImage = getRecipeImageUrl(recipe);
if (directRecipeImage) return directRecipeImage;
const categoryKey = recipeCategoryKey(recipe, tags);
if (categoryKey) {
const categoryImage = categoryImageFromManifest(categoryKey, photoManifest);
if (categoryImage) return categoryImage;
}
return listFallbackFromManifest(photoManifest);
}
function Badge({ label, value }: { label: string; value: string }) {
return ( return (
<div className="inline-flex items-center gap-1.5 rounded-full border border-slate-200/80 bg-slate-50 px-2.5 py-1 text-xs text-slate-700 transition-all duration-200 group-hover:border-slate-300 group-hover:bg-white"> <span className="ui-chip border-[var(--border)]/80 bg-white/90 px-2.5 py-1 text-xs font-semibold text-[var(--text)]">
<span aria-hidden="true">{emoji}</span> <span className="text-[var(--text-dim)]">{label}</span>
<span className="font-medium">{value}</span> <span>{value}</span>
<span className="text-slate-500">{label}</span> </span>
</div>
); );
} }
export function RecipeCard({ recipe, tags = [] }: RecipeCardProps) { export function RecipeCard({ recipe, tags = [], viewMode = 'grid', photoManifest = null }: RecipeCardProps) {
const totalTime = (recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0); const totalTime = (recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0);
const accent = accentForRecipe(recipe, tags); const difficulty = inferDifficulty(recipe);
const category = categoryForRecipe(tags);
const imageSrc = imageForRecipe(recipe, tags, photoManifest);
const listFallback = listFallbackFromManifest(photoManifest);
if (viewMode === 'list') {
return (
<Link
to={`/recipe/${recipe.id}`}
className="ui-card group grid grid-cols-1 gap-3 overflow-hidden border-[var(--border)]/85 bg-[var(--surface)]/95 p-3 outline-none transition-all duration-200 hover:border-[var(--color-primary)]/35 hover:[box-shadow:var(--shadow-hover)] md:grid-cols-[minmax(0,2.2fr)_minmax(0,1.4fr)_minmax(0,1fr)_auto] md:items-center md:gap-4 md:p-4"
style={{ boxShadow: shadows.card, borderRadius: radius.lg }}
>
<div className="min-w-0 md:pr-2">
<p className="mb-1 hidden text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--text-dim)]/85 md:block">Title</p>
<div className="flex min-w-0 items-start gap-2.5">
<div className="mt-0.5 hidden h-10 w-10 shrink-0 overflow-hidden rounded-md border border-[var(--border)]/70 bg-[var(--surface-muted)] lg:block">
<img
src={imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
onError={(e) => {
e.currentTarget.src = listFallback;
}}
/>
</div>
<div className="min-w-0">
<h3
className="line-clamp-1 text-base font-bold text-[var(--text-h)] transition-colors duration-200 group-hover:text-[var(--color-primary-dark)] sm:text-lg"
style={{ fontSize: typography.fontSize.lg }}
>
{recipe.title}
</h3>
{recipe.description ? (
<p className="mt-1 line-clamp-2 text-sm text-[var(--text-dim)] md:line-clamp-1">{recipe.description}</p>
) : (
<p className="mt-1 text-sm italic text-[var(--text-dim)]/70">No description yet</p>
)}
</div>
</div>
</div>
<div className="min-w-0">
<p className="mb-1 hidden text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--text-dim)]/85 md:block">Tags</p>
<div className="flex flex-wrap gap-1.5">
{tags.slice(0, 3).map((tag) => (
<span
key={tag.id}
className="ui-chip px-2 py-0.5 text-[11px] font-semibold text-white shadow-sm"
style={{ backgroundColor: tag.color || colors.primary, borderRadius: radius.full }}
>
{tag.name}
</span>
))}
{tags.length > 3 ? (
<span className="ui-chip bg-[var(--surface-muted)] px-2 py-0.5 text-[11px] font-medium text-[var(--text-dim)]">
+{tags.length - 3}
</span>
) : null}
{tags.length === 0 ? <span className="text-sm text-[var(--text-dim)]/80">No tags</span> : null}
</div>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-[var(--text-dim)] sm:text-sm md:grid-cols-1 md:gap-y-1.5">
<p className="mb-0.5 col-span-2 hidden text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--text-dim)]/85 md:block">Details</p>
<div>
<span className="font-semibold text-[var(--text)]">Time:</span> {totalTime > 0 ? formatTime(totalTime) : 'Flexible'}
</div>
<div>
<span className="font-semibold text-[var(--text)]">Difficulty:</span> {difficulty}
</div>
<div>
<span className="font-semibold text-[var(--text)]">Category:</span> {category}
</div>
<div>
<span className="font-semibold text-[var(--text)]">Last:</span> {recipe.last_cooked_at ? formatDate(recipe.last_cooked_at) : 'Never'}
</div>
</div>
<div className="hidden self-center text-right text-sm font-semibold text-[var(--color-primary)] md:block">
View
</div>
</Link>
);
}
return ( return (
<Link <Link
to={`/recipe/${recipe.id}`} to={`/recipe/${recipe.id}`}
className="group block overflow-hidden border border-slate-200/80 bg-white/95 outline-none transition-all duration-200 hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2" className="ui-card group flex h-full flex-col overflow-hidden bg-[var(--surface)]/95 outline-none transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--color-primary)]/35 hover:[box-shadow:var(--shadow-hover)]"
style={{ boxShadow: shadows.card, borderRadius: radius.lg }} style={{ boxShadow: shadows.card, borderRadius: radius.lg }}
> >
<div <div className="relative h-48 overflow-hidden border-[var(--border)] border-b">
className="relative h-40 border-b border-slate-200/70" <img
style={{ src={imageSrc}
background: `linear-gradient(135deg, color-mix(in srgb, ${accent} 30%, white) 0%, color-mix(in srgb, ${accent} 14%, #f8fafc) 55%, #ffffff 100%)`, alt=""
aria-hidden="true"
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
onError={(e) => {
e.currentTarget.src = listFallback;
}} }}
> />
<div className="absolute left-4 top-4 rounded-full border border-white/70 bg-white/80 px-3 py-1 text-xs font-semibold text-slate-700 backdrop-blur-sm transition-transform duration-200 group-hover:scale-[1.02]"> <div
{tags[0]?.name ?? 'Homemade'} className="pointer-events-none absolute inset-0"
</div> style={{ background: 'linear-gradient(to top, rgba(28, 25, 23, 0.45), rgba(28, 25, 23, 0.1), transparent)' }}
<div className="absolute inset-0 flex items-center justify-center text-6xl opacity-90 transition-transform duration-300 group-hover:scale-105" aria-hidden="true"> />
{emojiForRecipe(recipe)} <div className="absolute bottom-3 left-3 right-3 flex flex-wrap gap-1.5">
{totalTime > 0 ? <Badge label="Time" value={formatTime(totalTime)} /> : <Badge label="Time" value="Flexible" />}
<Badge label="Category" value={category} />
<Badge label="Difficulty" value={difficulty} />
</div> </div>
</div> </div>
<div className="flex h-[calc(100%-10rem)] min-h-[210px] flex-col p-5"> <div className="flex min-h-[220px] flex-1 flex-col p-5">
<h3 <h3
className="mb-1 line-clamp-2 text-lg font-bold text-gray-900 transition-colors duration-200 group-hover:text-blue-700" className="mb-2 line-clamp-2 text-lg font-bold text-[var(--text-h)] transition-colors duration-200 group-hover:text-[var(--color-primary-dark)]"
style={{ fontSize: typography.fontSize.lg }} style={{ fontSize: typography.fontSize.lg }}
> >
{recipe.title} {recipe.title}
</h3> </h3>
{recipe.description ? ( {recipe.description ? (
<p className="mb-3 line-clamp-2 text-sm text-gray-600">{recipe.description}</p> <p className="mb-3 line-clamp-2 text-sm text-[var(--text-dim)]">{recipe.description}</p>
) : ( ) : (
<p className="mb-3 text-sm italic text-gray-400">No description yet</p> <p className="mb-3 text-sm italic text-[var(--text-dim)]/70">No description yet</p>
)} )}
<div className="mb-3 flex flex-wrap gap-2">
{recipe.servings ? <MetaChip emoji="🍽️" value={`${recipe.servings}`} label="servings" /> : null}
{totalTime > 0 ? <MetaChip emoji="⏱️" value={formatTime(totalTime)} label="total" /> : null}
<MetaChip emoji="🥄" value={`${recipe.ingredients.length}`} label="ingredients" />
</div>
{tags.length > 0 && (
<div className="mb-3 flex flex-wrap gap-1.5"> <div className="mb-3 flex flex-wrap gap-1.5">
{tags.slice(0, 4).map((tag) => ( {tags.slice(0, 4).map((tag) => (
<span <span
key={tag.id} key={tag.id}
className="rounded-full px-2.5 py-1 text-xs font-semibold text-white shadow-sm transition-transform duration-200 group-hover:scale-[1.02]" className="ui-chip px-2.5 py-1 text-xs font-semibold text-white shadow-sm"
style={{ backgroundColor: tag.color || colors.primary, borderRadius: radius.full }} style={{ backgroundColor: tag.color || colors.primary, borderRadius: radius.full }}
> >
{tag.name} {tag.name}
</span> </span>
))} ))}
{tags.length > 4 ? <span className="rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600">+{tags.length - 4}</span> : null} {tags.length > 4 ? (
<span className="ui-chip bg-[var(--surface-muted)] px-2 py-1 text-xs font-medium text-[var(--text-dim)]">
+{tags.length - 4}
</span>
) : null}
</div> </div>
)}
<div className="mt-auto flex items-center justify-between border-t border-slate-100 pt-3 text-xs text-gray-500"> <div className="mt-auto flex items-center justify-between border-t border-[var(--border)]/70 pt-3 text-xs text-[var(--text-dim)]">
<span>{recipe.last_cooked_at ? `Cooked ${formatDate(recipe.last_cooked_at)}` : 'Not cooked yet'}</span> <span>{recipe.last_cooked_at ? `Cooked ${formatDate(recipe.last_cooked_at)}` : 'Not cooked yet'}</span>
<span className="inline-flex items-center gap-1 font-medium text-blue-600"> <span className="inline-flex items-center gap-1 font-medium text-[var(--color-primary)]">
View recipe View recipe
<span className="transition-transform duration-200 group-hover:translate-x-0.5" aria-hidden="true"></span> <span className="transition-transform duration-200 group-hover:translate-x-0.5" aria-hidden="true">
</span>
</span> </span>
</div> </div>
</div> </div>

View File

@ -119,26 +119,26 @@ export function RecipeForm({
return ( return (
<form onSubmit={handleSubmit} className="space-y-8"> <form onSubmit={handleSubmit} className="space-y-8">
{error && ( {error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-5 py-3 rounded-lg shadow-card font-medium text-base"> <div className="rounded-lg border border-[var(--color-error)]/25 bg-[var(--color-error-light)] px-5 py-3 text-base font-medium text-[var(--color-error)] shadow-card">
{error} {error}
</div> </div>
)} )}
<div> <div>
<label htmlFor="title" className="block text-base font-semibold text-gray-700 mb-1"> <label htmlFor="title" className="block text-base font-semibold text-[var(--text)] mb-1">
Title <span className="text-error">*</span> Title <span className="text-[var(--color-error)]">*</span>
</label> </label>
<input <input
type="text" type="text"
id="title" id="title"
value={title} value={title}
onChange={e => setTitle(e.target.value)} onChange={e => setTitle(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-[17px] py-2 px-4 font-medium" className="ui-input text-[17px] font-medium"
placeholder="e.g., Chocolate Chip Cookies" placeholder="e.g., Chocolate Chip Cookies"
required required
/> />
</div> </div>
<div> <div>
<label htmlFor="description" className="block text-base font-semibold text-gray-700 mb-1"> <label htmlFor="description" className="block text-base font-semibold text-[var(--text)] mb-1">
Description Description
</label> </label>
<textarea <textarea
@ -146,103 +146,103 @@ export function RecipeForm({
value={description} value={description}
onChange={e => setDescription(e.target.value)} onChange={e => setDescription(e.target.value)}
rows={2} rows={2}
className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-base px-4 py-2" className="ui-textarea text-base"
placeholder="Brief description of the recipe..." placeholder="Brief description of the recipe..."
/> />
</div> </div>
<div> <div>
<label className="block text-base font-semibold text-gray-700 mb-1"> <label className="block text-base font-semibold text-[var(--text)] mb-1">
Tags Tags
</label> </label>
<TagSelector selectedTags={selectedTags} onTagsChange={setSelectedTags} /> <TagSelector selectedTags={selectedTags} onTagsChange={setSelectedTags} />
</div> </div>
<div> <div>
<label htmlFor="ingredients" className="block text-base font-semibold text-gray-700 mb-1"> <label htmlFor="ingredients" className="block text-base font-semibold text-[var(--text)] mb-1">
Ingredients <span className="text-error">*</span> Ingredients <span className="text-[var(--color-error)]">*</span>
</label> </label>
<p className="mt-0.5 text-sm text-gray-500 mb-2">One ingredient per line</p> <p className="mt-0.5 text-sm text-[var(--text-dim)] mb-2">One ingredient per line</p>
<textarea <textarea
id="ingredients" id="ingredients"
value={ingredientsText} value={ingredientsText}
onChange={e => setIngredientsText(e.target.value)} onChange={e => setIngredientsText(e.target.value)}
rows={7} rows={7}
className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary font-mono text-base px-4 py-2" className="ui-textarea font-mono text-base"
placeholder="2 cups all-purpose flour\n1 cup butter, softened\n3/4 cup sugar" placeholder="2 cups all-purpose flour\n1 cup butter, softened\n3/4 cup sugar"
required required
/> />
</div> </div>
<div> <div>
<label htmlFor="instructions" className="block text-base font-semibold text-gray-700 mb-1"> <label htmlFor="instructions" className="block text-base font-semibold text-[var(--text)] mb-1">
Instructions <span className="text-error">*</span> Instructions <span className="text-[var(--color-error)]">*</span>
</label> </label>
<p className="mt-0.5 text-sm text-gray-500 mb-2">One step per line</p> <p className="mt-0.5 text-sm text-[var(--text-dim)] mb-2">One step per line</p>
<textarea <textarea
id="instructions" id="instructions"
value={instructionsText} value={instructionsText}
onChange={e => setInstructionsText(e.target.value)} onChange={e => setInstructionsText(e.target.value)}
rows={8} rows={8}
className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary font-mono text-base px-4 py-2" className="ui-textarea font-mono text-base"
placeholder="Preheat oven to 350°F\nMix flour and baking soda\nCream butter and sugar" placeholder="Preheat oven to 350°F\nMix flour and baking soda\nCream butter and sugar"
required required
/> />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<label htmlFor="servings" className="block text-base font-semibold text-gray-700 mb-1">Servings</label> <label htmlFor="servings" className="block text-base font-semibold text-[var(--text)] mb-1">Servings</label>
<input <input
type="number" type="number"
id="servings" id="servings"
value={servings} value={servings}
onChange={e => setServings(e.target.value)} onChange={e => setServings(e.target.value)}
min="1" min="1"
className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-base px-4 py-2" className="ui-input text-base"
placeholder="4" placeholder="4"
/> />
</div> </div>
<div> <div>
<label htmlFor="prep_time" className="block text-base font-semibold text-gray-700 mb-1">Prep Time (min)</label> <label htmlFor="prep_time" className="block text-base font-semibold text-[var(--text)] mb-1">Prep Time (min)</label>
<input <input
type="number" type="number"
id="prep_time" id="prep_time"
value={prepTime} value={prepTime}
onChange={e => setPrepTime(e.target.value)} onChange={e => setPrepTime(e.target.value)}
min="0" min="0"
className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-base px-4 py-2" className="ui-input text-base"
placeholder="15" placeholder="15"
/> />
</div> </div>
<div> <div>
<label htmlFor="cook_time" className="block text-base font-semibold text-gray-700 mb-1">Cook Time (min)</label> <label htmlFor="cook_time" className="block text-base font-semibold text-[var(--text)] mb-1">Cook Time (min)</label>
<input <input
type="number" type="number"
id="cook_time" id="cook_time"
value={cookTime} value={cookTime}
onChange={e => setCookTime(e.target.value)} onChange={e => setCookTime(e.target.value)}
min="0" min="0"
className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-base px-4 py-2" className="ui-input text-base"
placeholder="30" placeholder="30"
/> />
</div> </div>
</div> </div>
<div> <div>
<label htmlFor="source_url" className="block text-base font-semibold text-gray-700 mb-1">Source URL</label> <label htmlFor="source_url" className="block text-base font-semibold text-[var(--text)] mb-1">Source URL</label>
<input <input
type="url" type="url"
id="source_url" id="source_url"
value={sourceUrl} value={sourceUrl}
onChange={e => setSourceUrl(e.target.value)} onChange={e => setSourceUrl(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-base px-4 py-2" className="ui-input text-base"
placeholder="https://example.com/recipe" placeholder="https://example.com/recipe"
/> />
</div> </div>
<div> <div>
<label htmlFor="notes" className="block text-base font-semibold text-gray-700 mb-1">Notes</label> <label htmlFor="notes" className="block text-base font-semibold text-[var(--text)] mb-1">Notes</label>
<textarea <textarea
id="notes" id="notes"
value={notes} value={notes}
onChange={e => setNotes(e.target.value)} onChange={e => setNotes(e.target.value)}
rows={3} rows={3}
className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-base px-4 py-2" className="ui-textarea text-base"
placeholder="Personal notes, substitutions, tips..." placeholder="Personal notes, substitutions, tips..."
/> />
</div> </div>
@ -250,7 +250,7 @@ export function RecipeForm({
<button <button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
className="flex-1 bg-primary text-white px-4 py-2 rounded-md shadow-card hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold transition-colors text-base" className="ui-btn ui-btn-primary flex-1 px-4 py-2 text-base disabled:cursor-not-allowed disabled:opacity-60"
> >
{isSubmitting ? 'Saving...' : submitLabel} {isSubmitting ? 'Saving...' : submitLabel}
</button> </button>
@ -258,7 +258,7 @@ export function RecipeForm({
type="button" type="button"
onClick={onCancel} onClick={onCancel}
disabled={isSubmitting} disabled={isSubmitting}
className="px-4 py-2 border border-gray-300 rounded-md shadow font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed text-base transition-colors" className="ui-btn ui-btn-secondary px-4 py-2 text-base disabled:opacity-50 disabled:cursor-not-allowed"
> >
Cancel Cancel
</button> </button>

View File

@ -53,13 +53,13 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
}; };
if (loading) { if (loading) {
return <div className="text-gray-600">Loading tags...</div>; return <div className="text-[var(--text-dim)]">Loading tags...</div>;
} }
if (error) { if (error) {
return ( return (
<div className="rounded-md border border-red-200 bg-red-50 p-3"> <div className="rounded-md border border-[var(--color-error)]/25 bg-[var(--color-error-light)] p-3">
<p className="text-sm text-red-700">Error loading tags: {error}</p> <p className="text-sm text-[var(--color-error)]">Error loading tags: {error}</p>
</div> </div>
); );
} }
@ -75,10 +75,10 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
type="button" type="button"
onClick={() => handleToggleTag(tag)} onClick={() => handleToggleTag(tag)}
className={` className={`
rounded-full px-3 py-1 text-sm font-medium transition-colors ui-chip px-3 py-1 text-sm font-medium transition-colors
${isSelected ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'} ${isSelected ? 'text-white' : 'text-[var(--text)] hover:bg-[var(--surface-muted)]'}
`} `}
style={isSelected && tag.color ? { backgroundColor: tag.color } : {}} style={isSelected ? { backgroundColor: tag.color || 'var(--color-primary)' } : {}}
> >
{tag.name} {tag.name}
</button> </button>
@ -90,7 +90,7 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
<button <button
type="button" type="button"
onClick={() => setShowNewTagForm(true)} onClick={() => setShowNewTagForm(true)}
className="text-sm font-medium text-blue-600 hover:text-blue-700" className="ui-btn ui-btn-secondary min-h-0 px-2 py-1 text-sm font-medium text-[var(--color-primary-dark)]"
> >
+ Create new tag + Create new tag
</button> </button>
@ -102,7 +102,7 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
value={newTagName} value={newTagName}
onChange={(e) => setNewTagName(e.target.value)} onChange={(e) => setNewTagName(e.target.value)}
placeholder="Tag name" placeholder="Tag name"
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-500" className="ui-input"
autoFocus autoFocus
/> />
</div> </div>
@ -111,14 +111,14 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
type="color" type="color"
value={newTagColor} value={newTagColor}
onChange={(e) => setNewTagColor(e.target.value)} onChange={(e) => setNewTagColor(e.target.value)}
className="h-10 w-16 cursor-pointer rounded-md border border-gray-300" className="h-10 w-16 cursor-pointer rounded-md border border-[var(--border)] bg-[var(--surface)]"
title="Tag color" title="Tag color"
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={creating || !newTagName.trim()} disabled={creating || !newTagName.trim()}
className="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400" className="ui-btn ui-btn-primary px-4 py-2 disabled:cursor-not-allowed disabled:opacity-60"
> >
{creating ? 'Creating...' : 'Add'} {creating ? 'Creating...' : 'Add'}
</button> </button>
@ -129,7 +129,7 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
setNewTagName(''); setNewTagName('');
setNewTagColor(colors.primary); setNewTagColor(colors.primary);
}} }}
className="rounded-md bg-gray-200 px-4 py-2 text-gray-700 hover:bg-gray-300" className="ui-btn ui-btn-secondary px-4 py-2"
> >
Cancel Cancel
</button> </button>

View File

@ -66,7 +66,7 @@ export function Toast({ message, onClose }: ToastProps) {
</div> </div>
<button <button
onClick={() => onClose(message.id)} onClick={() => onClose(message.id)}
className="text-xl font-bold leading-none text-white transition-colors hover:text-gray-200" className="text-xl font-bold leading-none text-white transition-colors hover:text-white/80"
style={{ marginInlineStart: spacing.xs }} style={{ marginInlineStart: spacing.xs }}
aria-label="Close" aria-label="Close"
> >

View File

@ -0,0 +1,5 @@
export type ClassValue = string | false | null | undefined;
export function cn(...values: ClassValue[]): string {
return values.filter(Boolean).join(' ');
}

View File

@ -0,0 +1,98 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import { cn } from './cn';
type UiTone = 'default' | 'subtle';
type UiButtonVariant = 'primary' | 'secondary';
type UiCardTone = 'default' | 'muted';
type UiSectionPadding = 'none' | 'md' | 'lg';
export function UiPage({ className, ...props }: ComponentPropsWithoutRef<'div'>) {
return <div className={cn('ui-page', className)} {...props} />;
}
export function UiSection({
className,
padding = 'md',
...props
}: ComponentPropsWithoutRef<'section'> & { padding?: UiSectionPadding }) {
const paddingClass =
padding === 'none' ? '' : padding === 'lg' ? 'px-5 py-6 md:px-6' : 'p-4 md:p-5';
return <section className={cn('ui-section', paddingClass, className)} {...props} />;
}
export function UiCard({
className,
tone = 'default',
...props
}: ComponentPropsWithoutRef<'article'> & { tone?: UiCardTone }) {
return (
<article
className={cn(
'ui-card',
tone === 'muted' ? 'bg-[var(--surface-muted)]/40' : '',
className,
)}
{...props}
/>
);
}
export function UiButton({
className,
variant = 'secondary',
type = 'button',
...props
}: ComponentPropsWithoutRef<'button'> & { variant?: UiButtonVariant }) {
return (
<button
type={type}
className={cn('ui-btn', variant === 'primary' ? 'ui-btn-primary' : 'ui-btn-secondary', className)}
{...props}
/>
);
}
export function UiLinkButton({
className,
variant = 'secondary',
...props
}: ComponentPropsWithoutRef<'a'> & { variant?: UiButtonVariant }) {
return (
<a
className={cn('ui-btn', variant === 'primary' ? 'ui-btn-primary' : 'ui-btn-secondary', className)}
{...props}
/>
);
}
export function UiChip({
className,
tone = 'default',
children,
...props
}: ComponentPropsWithoutRef<'span'> & { tone?: UiTone; children: ReactNode }) {
return (
<span
className={cn(
'ui-chip',
tone === 'subtle' ? 'bg-[var(--surface)] text-[var(--text-dim)]' : '',
className,
)}
{...props}
>
{children}
</span>
);
}
export function UiBadge({ className, children, ...props }: ComponentPropsWithoutRef<'span'> & { children: ReactNode }) {
return (
<span className={cn('ui-badge', className)} {...props}>
{children}
</span>
);
}

View File

@ -9,7 +9,7 @@ import type { Recipe } from '../types/recipe';
interface UseRecipesOptions { interface UseRecipesOptions {
search?: string; search?: string;
limit?: number; limit?: number;
tagId?: number | null; tagIds?: number[];
} }
interface UseRecipesResult { interface UseRecipesResult {
@ -22,13 +22,16 @@ interface UseRecipesResult {
} }
export function useRecipes(options: UseRecipesOptions = {}): UseRecipesResult { export function useRecipes(options: UseRecipesOptions = {}): UseRecipesResult {
const { search = '', limit = 20, tagId = null } = options; const { search = '', limit = 20, tagIds = [] } = options;
const [recipes, setRecipes] = useState<Recipe[]>([]); const [recipes, setRecipes] = useState<Recipe[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const normalizedTagIds = [...tagIds].sort((a, b) => a - b);
const tagIdsDependency = normalizedTagIds.join(',');
const loadRecipes = async (currentOffset: number, append: boolean = false) => { const loadRecipes = async (currentOffset: number, append: boolean = false) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -37,7 +40,7 @@ export function useRecipes(options: UseRecipesOptions = {}): UseRecipesResult {
search: search || undefined, search: search || undefined,
offset: currentOffset, offset: currentOffset,
limit, limit,
tagId, tagIds: normalizedTagIds.length > 0 ? normalizedTagIds : undefined,
}); });
if (append) { if (append) {
setRecipes(prev => [...prev, ...data]); setRecipes(prev => [...prev, ...data]);
@ -57,7 +60,7 @@ export function useRecipes(options: UseRecipesOptions = {}): UseRecipesResult {
setOffset(0); setOffset(0);
setHasMore(true); setHasMore(true);
loadRecipes(0, false); loadRecipes(0, false);
}, [search, tagId]); }, [search, tagIdsDependency]);
const loadMore = () => { const loadMore = () => {
if (!loading && hasMore) { if (!loading && hasMore) {

View File

@ -1,103 +1,9 @@
@import './styles/tokens.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root {
--color-primary: #2563eb;
--color-primary-dark: #1d4ed8;
--color-primary-light: #dbeafe;
--color-accent: #9333ea;
--color-success: #15803d;
--color-success-light: #dcfce7;
--color-warning: #ca8a04;
--color-warning-light: #fef3c7;
--color-error: #dc2626;
--color-error-light: #fee2e2;
--text: #1f2937;
--text-h: #0f172a;
--text-dim: #64748b;
--bg: #f4f7fb;
--bg-alt: #edf2f7;
--surface: #ffffff;
--surface-muted: #f8fafc;
--border: #dbe3ef;
--code-bg: #eef2f7;
--radius-xs: 0.375rem;
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--radius-xl: 1.25rem;
--space-xxs: 0.25rem;
--space-xs: 0.5rem;
--space-sm: 0.75rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 2.5rem;
--space-3xl: 3rem;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
--font-size-4xl: 2.25rem;
--line-height-tight: 1.2;
--line-height-normal: 1.5;
--line-height-relaxed: 1.65;
--shadow-subtle: 0 1px 2px rgba(15, 23, 42, 0.06);
--card-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
--shadow-hover: 0 14px 34px rgba(15, 23, 42, 0.12);
--focus-ring: 0 0 0 3px rgba(37, 99, 235, 0.25);
--surface-gradient:
radial-gradient(1200px 500px at -10% -10%, rgba(147, 51, 234, 0.1), transparent 60%),
radial-gradient(900px 420px at 115% -5%, rgba(37, 99, 235, 0.08), transparent 52%),
linear-gradient(180deg, #f8fbff 0%, #edf2f7 100%);
--sans: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--heading: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #e2e8f0;
--text-h: #f8fafc;
--text-dim: #94a3b8;
--bg: #0f172a;
--bg-alt: #111b2e;
--surface: #132136;
--surface-muted: #172842;
--border: #22334d;
--code-bg: #1a2942;
--shadow-subtle: 0 1px 2px rgba(2, 6, 23, 0.35);
--card-shadow: 0 10px 30px rgba(2, 6, 23, 0.45);
--shadow-hover: 0 14px 34px rgba(2, 6, 23, 0.55);
--surface-gradient:
radial-gradient(900px 400px at 0% -5%, rgba(147, 51, 234, 0.2), transparent 60%),
radial-gradient(800px 420px at 105% -10%, rgba(37, 99, 235, 0.18), transparent 55%),
linear-gradient(180deg, #0f172a 0%, #111b2e 100%);
}
}
body { body {
margin: 0; margin: 0;
font-family: var(--sans); font-family: var(--sans);
@ -133,24 +39,127 @@ a {
color: inherit; color: inherit;
} }
button, @layer components {
.button, .ui-page {
.btn { margin-left: auto;
margin-right: auto;
max-width: 72rem;
padding-left: 1rem;
padding-right: 1rem;
padding-bottom: 2rem;
}
@media (min-width: 768px) {
.ui-page {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
.ui-section {
border-radius: var(--radius-xl);
border: 1px solid var(--border);
background: color-mix(in srgb, var(--surface) 92%, transparent);
box-shadow: var(--card-shadow);
}
.ui-card {
border-radius: var(--radius-lg);
border: 1px solid var(--border);
background: var(--surface);
box-shadow: var(--card-shadow);
}
.ui-btn {
display: inline-flex;
min-height: 2.75rem;
align-items: center;
justify-content: center;
gap: var(--space-xs);
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid transparent; border: 1px solid transparent;
padding: 0.625rem 1rem;
font-size: var(--font-size-sm);
font-weight: 600;
line-height: 1.2;
text-decoration: none;
transition: background-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease, transition: background-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease,
transform 0.15s ease; transform 0.15s ease;
} }
button:hover, .ui-btn:hover {
.button:hover, transform: translateY(-1px);
.btn:hover {
box-shadow: var(--shadow-subtle); box-shadow: var(--shadow-subtle);
} }
.ui-btn-primary {
background: var(--color-primary);
color: #fff;
}
.ui-btn-primary:hover {
background: var(--color-primary-dark);
}
.ui-btn-secondary {
border-color: var(--border);
background: var(--surface);
color: var(--text);
}
.ui-btn-secondary:hover {
background: var(--surface-muted);
}
.ui-input,
.ui-textarea,
.ui-select {
width: 100%;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow-subtle);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.ui-input,
.ui-select {
min-height: 2.75rem;
padding: 0.625rem 0.875rem;
}
.ui-textarea {
padding: 0.625rem 0.875rem;
}
.ui-chip,
.ui-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border-radius: var(--radius-full);
border: 1px solid var(--border);
padding: 0.25rem 0.625rem;
font-size: var(--font-size-xs);
line-height: 1.3;
}
.ui-chip {
background: var(--surface-muted);
color: var(--text);
font-weight: 500;
}
.ui-badge {
background: var(--color-primary-light);
color: var(--color-primary-dark);
font-weight: 600;
}
}
button:focus-visible, button:focus-visible,
.button:focus-visible, .ui-btn:focus-visible,
.btn:focus-visible,
input:focus-visible, input:focus-visible,
textarea:focus-visible, textarea:focus-visible,
select:focus-visible, select:focus-visible,
@ -159,33 +168,18 @@ a:focus-visible {
box-shadow: var(--focus-ring); box-shadow: var(--focus-ring);
} }
input,
textarea,
select {
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
}
input::placeholder, input::placeholder,
textarea::placeholder { textarea::placeholder {
color: color-mix(in srgb, var(--text-dim) 70%, transparent); color: color-mix(in srgb, var(--text-dim) 72%, transparent);
} }
.card,
.shadow-card { .shadow-card {
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--surface); background: var(--surface);
box-shadow: var(--card-shadow);
}
.shadow-card {
box-shadow: var(--card-shadow) !important; box-shadow: var(--card-shadow) !important;
} }
/* Toast animation */
@keyframes slide-in { @keyframes slide-in {
from { from {
transform: translateX(100%); transform: translateX(100%);
@ -196,6 +190,7 @@ textarea::placeholder {
opacity: 1; opacity: 1;
} }
} }
.animate-slide-in { .animate-slide-in {
animation: slide-in 0.3s ease-out; animation: slide-in 0.3s ease-out;
} }
@ -204,6 +199,7 @@ textarea::placeholder {
width: 8px; width: 8px;
background: var(--surface-muted); background: var(--surface-muted);
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--border); background: var(--border);
border-radius: 7px; border-radius: 7px;
@ -231,6 +227,7 @@ textarea::placeholder {
.max-w-sm { .max-w-sm {
max-width: 100vw !important; max-width: 100vw !important;
} }
.p-8, .p-8,
.p-7, .p-7,
.p-6 { .p-6 {

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { useRecipe } from '../hooks/useRecipe'; import { useRecipe } from '../hooks/useRecipe';
import { colors, radius, spacing, shadows } from '../theme'; import { UiBadge, UiButton, UiPage, UiSection } from '../components/ui/primitives';
/** /**
* CookModePage - Hands-free cooking interface with wake lock * CookModePage - Hands-free cooking interface with wake lock
@ -11,20 +11,16 @@ export function CookModePage() {
const recipeId = id ? parseInt(id, 10) : null; const recipeId = id ? parseInt(id, 10) : null;
const { recipe, loading, error } = useRecipe(recipeId); const { recipe, loading, error } = useRecipe(recipeId);
// Track checked ingredients and steps
const [checkedIngredients, setCheckedIngredients] = useState<Set<number>>(new Set()); const [checkedIngredients, setCheckedIngredients] = useState<Set<number>>(new Set());
const [checkedSteps, setCheckedSteps] = useState<Set<number>>(new Set()); const [checkedSteps, setCheckedSteps] = useState<Set<number>>(new Set());
// Wake lock state
const [wakeLock, setWakeLock] = useState<WakeLockSentinel | null>(null); const [wakeLock, setWakeLock] = useState<WakeLockSentinel | null>(null);
const [wakeLockSupported, setWakeLockSupported] = useState(false); const [wakeLockSupported, setWakeLockSupported] = useState(false);
// Check if Wake Lock API is supported
useEffect(() => { useEffect(() => {
setWakeLockSupported('wakeLock' in navigator); setWakeLockSupported('wakeLock' in navigator);
}, []); }, []);
// Request wake lock
const requestWakeLock = async () => { const requestWakeLock = async () => {
if (!wakeLockSupported) return; if (!wakeLockSupported) return;
try { try {
@ -32,28 +28,38 @@ export function CookModePage() {
const lock = await navigator.wakeLock.request('screen'); const lock = await navigator.wakeLock.request('screen');
setWakeLock(lock); setWakeLock(lock);
lock.addEventListener('release', () => setWakeLock(null)); lock.addEventListener('release', () => setWakeLock(null));
} catch (err) { /* ignore */ } } catch {
// ignore
}
}; };
// Release wake lock
const releaseWakeLock = async () => { const releaseWakeLock = async () => {
if (wakeLock) { if (wakeLock) {
await wakeLock.release(); await wakeLock.release();
setWakeLock(null); setWakeLock(null);
} }
}; };
const toggleWakeLock = () => { (wakeLock ? releaseWakeLock() : requestWakeLock()); };
useEffect(() => () => { if (wakeLock) wakeLock.release(); }, [wakeLock]); const toggleWakeLock = () => {
wakeLock ? releaseWakeLock() : requestWakeLock();
};
useEffect(() => {
return () => {
if (wakeLock) wakeLock.release();
};
}, [wakeLock]);
const toggleIngredient = (index: number) => { const toggleIngredient = (index: number) => {
setCheckedIngredients(prev => { setCheckedIngredients((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.has(index) ? next.delete(index) : next.add(index); next.has(index) ? next.delete(index) : next.add(index);
return next; return next;
}); });
}; };
const toggleStep = (index: number) => { const toggleStep = (index: number) => {
setCheckedSteps(prev => { setCheckedSteps((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.has(index) ? next.delete(index) : next.add(index); next.has(index) ? next.delete(index) : next.add(index);
return next; return next;
@ -62,27 +68,32 @@ export function CookModePage() {
if (loading) { if (loading) {
return ( return (
<div className="flex justify-center items-center min-h-[50vh]"> <UiPage className="flex min-h-[50vh] items-center justify-center">
<div className="text-center"> <div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div> <div className="inline-block h-12 w-12 animate-spin rounded-full border-b-2 border-[var(--color-primary)]"></div>
<p className="mt-4 text-gray-600">Loading recipe...</p> <p className="mt-4 text-[var(--text-dim)]">Loading recipe...</p>
</div>
</div>
);
}
if (error || !recipe) {
return (
<div className="bg-red-50 border border-red-200 rounded-2xl p-8 max-w-md mx-auto shadow-card text-center">
<h2 className="text-2xl font-bold text-red-800 mb-2">Error Loading Recipe</h2>
<p className="text-red-600 mb-4">{error || 'Recipe not found'}</p>
<Link to="/" className="inline-block px-4 py-2 bg-red-600 text-white rounded-full hover:bg-red-700 transition-colors shadow">Back to Recipes</Link>
</div> </div>
</UiPage>
); );
} }
// Use fallback if recipe.instructions missing if (error || !recipe) {
const instructions: string[] = Array.isArray(recipe.instructions) ? recipe.instructions : recipe.steps?.map(s => s.instruction) || []; return (
<UiPage className="mx-auto max-w-md py-8">
<UiSection className="border-[var(--danger-border)] bg-[var(--danger-bg)] text-center">
<h2 className="mb-2 text-2xl font-bold text-[var(--danger-text)]">Error Loading Recipe</h2>
<p className="mb-4 text-[var(--danger-text)]/90">{error || 'Recipe not found'}</p>
<Link to="/" className="ui-btn ui-btn-secondary border-[var(--danger-border)] bg-white text-[var(--danger-text)] hover:bg-white/90">Back to Recipes</Link>
</UiSection>
</UiPage>
);
}
const instructions: string[] = Array.isArray(recipe.instructions)
? recipe.instructions
: recipe.steps?.map((s) => s.instruction) || [];
const ingredients = Array.isArray(recipe.ingredients) ? recipe.ingredients : []; const ingredients = Array.isArray(recipe.ingredients) ? recipe.ingredients : [];
const ingredientsTotal = ingredients.length; const ingredientsTotal = ingredients.length;
const stepsTotal = instructions.length; const stepsTotal = instructions.length;
const ingredientsChecked = checkedIngredients.size; const ingredientsChecked = checkedIngredients.size;
@ -90,65 +101,122 @@ export function CookModePage() {
const ingredientsProgress = ingredientsTotal > 0 ? (ingredientsChecked / ingredientsTotal) * 100 : 0; const ingredientsProgress = ingredientsTotal > 0 ? (ingredientsChecked / ingredientsTotal) * 100 : 0;
const stepsProgress = stepsTotal > 0 ? (stepsChecked / stepsTotal) * 100 : 0; const stepsProgress = stepsTotal > 0 ? (stepsChecked / stepsTotal) * 100 : 0;
const done = ingredientsChecked === ingredientsTotal && stepsChecked === stepsTotal;
return ( return (
<div className="max-w-3xl mx-auto py-7"> <UiPage className="mx-auto max-w-3xl py-7">
<div className="bg-white rounded-2xl shadow-card p-7 mb-8 border border-gray-100"> <UiSection className="mb-8" padding="lg">
<div className="flex items-start justify-between mb-4 gap-6 flex-wrap"> <div className="mb-4 flex flex-wrap items-start justify-between gap-6">
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<h1 className="text-3xl font-bold text-gray-900 mb-2 break-words">{recipe.title}</h1> <h1 className="mb-2 break-words text-3xl font-bold text-[var(--text-h)]">{recipe.title}</h1>
{recipe.description && (<p className="text-gray-600 text-base mb-1 break-words">{recipe.description}</p>)} {recipe.description && <p className="mb-1 break-words text-base text-[var(--text-dim)]">{recipe.description}</p>}
</div> </div>
<Link to={`/recipe/${recipe.id}`} className="ml-4 px-4 py-2 text-blue-600 hover:bg-blue-50 rounded-md border border-blue-100 transition-colors text-sm font-medium shadow-sm">Exit Cook Mode</Link> <Link to={`/recipe/${recipe.id}`} className="ui-btn ui-btn-secondary text-sm font-medium">Exit Cook Mode</Link>
</div> </div>
<div className="flex flex-wrap gap-5 text-sm text-gray-600 mb-4">
{recipe.servings && (<div className="bg-gray-50 rounded-md px-3 py-1 font-medium shadow-inner">Servings: <span className="ml-1">{recipe.servings}</span></div>)} <div className="mb-4 flex flex-wrap gap-3 text-sm">
{recipe.prep_time_minutes && (<div className="bg-gray-50 rounded-md px-3 py-1 font-medium shadow-inner">Prep: <span className="ml-1">{recipe.prep_time_minutes} min</span></div>)} {recipe.servings && <UiBadge>Servings: {recipe.servings}</UiBadge>}
{recipe.cook_time_minutes && (<div className="bg-gray-50 rounded-md px-3 py-1 font-medium shadow-inner">Cook: <span className="ml-1">{recipe.cook_time_minutes} min</span></div>)} {recipe.prep_time_minutes && <UiBadge>Prep: {recipe.prep_time_minutes} min</UiBadge>}
{recipe.cook_time_minutes && <UiBadge>Cook: {recipe.cook_time_minutes} min</UiBadge>}
</div> </div>
{wakeLockSupported && ( {wakeLockSupported && (
<div className="border-t pt-4 mt-4"> <div className="mt-4 border-t border-[var(--border)] pt-4">
<button onClick={toggleWakeLock} className={`w-full sm:w-auto px-6 py-3 rounded-lg font-medium transition-colors focus:outline-none shadow ${wakeLock ? 'bg-green-600 text-white hover:bg-green-700' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'}`}> <UiButton
onClick={toggleWakeLock}
variant={wakeLock ? 'primary' : 'secondary'}
className={wakeLock ? '' : 'text-[var(--text-dim)]'}
>
{wakeLock ? '🔒 Screen Locked (Stay Awake)' : '🔓 Screen Will Sleep (Tap to Lock)'} {wakeLock ? '🔒 Screen Locked (Stay Awake)' : '🔓 Screen Will Sleep (Tap to Lock)'}
</button> </UiButton>
<p className="mt-2 text-sm text-gray-500">{wakeLock ? 'Your screen will stay on while cooking' : 'Enable to prevent your screen from turning off'}</p> <p className="mt-2 text-sm text-[var(--text-muted)]">
</div> )} {wakeLock ? 'Your screen will stay on while cooking' : 'Enable to prevent your screen from turning off'}
</p>
</div> </div>
<div className="bg-white rounded-2xl shadow-card p-7 mb-8 border border-gray-100"> )}
<div className="flex items-center justify-between mb-4"><h2 className="text-2xl font-bold text-gray-900">Ingredients</h2><div className="text-sm font-medium text-gray-600">{ingredientsChecked} of {ingredientsTotal}</div></div> </UiSection>
<div className="mb-6 bg-gray-200 rounded-full h-2 overflow-hidden">
<div className="bg-green-600 h-full transition-all duration-300" style={{ width: `${ingredientsProgress}%` }} /> <UiSection className="mb-8" padding="lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-[var(--text-h)]">Ingredients</h2>
<div className="text-sm font-medium text-[var(--text-dim)]">{ingredientsChecked} of {ingredientsTotal}</div>
</div> </div>
<div className="mb-6 h-2 overflow-hidden rounded-full bg-[var(--border)]">
<div className="h-full bg-[var(--success)] transition-all duration-300" style={{ width: `${ingredientsProgress}%` }} />
</div>
<div className="space-y-3"> <div className="space-y-3">
{ingredients.map((ingredient: any, index: number) => ( {ingredients.map((ingredient: any, index: number) => (
<label key={index} className="flex items-start gap-3 p-3 rounded-lg border border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors shadow-sm"> <label
<input type="checkbox" checked={checkedIngredients.has(index)} onChange={() => toggleIngredient(index)} className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer" /> key={index}
<span className={`text-lg flex-1 ${checkedIngredients.has(index) ? 'line-through text-gray-500' : 'text-gray-900'}`}>{'item' in ingredient ? ingredient.item : typeof ingredient === 'string' ? ingredient : ''}</span> className="flex cursor-pointer items-start gap-3 rounded-xl border border-[var(--border)] bg-white p-3 transition-colors hover:bg-[var(--surface-muted)]"
>
<input
type="checkbox"
checked={checkedIngredients.has(index)}
onChange={() => toggleIngredient(index)}
className="mt-1 h-5 w-5 cursor-pointer rounded border-[var(--border-strong)] text-[var(--color-primary)] focus:ring-[var(--focus-ring)]"
/>
<span
className={`flex-1 text-lg ${checkedIngredients.has(index) ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}`}
>
{'item' in ingredient ? ingredient.item : typeof ingredient === 'string' ? ingredient : ''}
</span>
</label> </label>
))} ))}
</div> </div>
</UiSection>
<UiSection className="mb-8" padding="lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-[var(--text-h)]">Instructions</h2>
<div className="text-sm font-medium text-[var(--text-dim)]">{stepsChecked} of {stepsTotal}</div>
</div> </div>
<div className="bg-white rounded-2xl shadow-card p-7 mb-8 border border-gray-100">
<div className="flex items-center justify-between mb-4"><h2 className="text-2xl font-bold text-gray-900">Instructions</h2><div className="text-sm font-medium text-gray-600">{stepsChecked} of {stepsTotal}</div></div> <div className="mb-6 h-2 overflow-hidden rounded-full bg-[var(--border)]">
<div className="mb-6 bg-gray-200 rounded-full h-2 overflow-hidden"><div className="bg-blue-600 h-full transition-all duration-300" style={{ width: `${stepsProgress}%` }} /></div> <div className="h-full bg-[var(--color-primary)] transition-all duration-300" style={{ width: `${stepsProgress}%` }} />
</div>
<div className="space-y-4"> <div className="space-y-4">
{instructions.map((instruction, index) => ( {instructions.map((instruction, index) => (
<label key={index} className="flex items-start gap-4 p-4 border border-gray-100 rounded-xl hover:bg-gray-50 cursor-pointer transition-colors shadow-sm"> <label
key={index}
className="flex cursor-pointer items-start gap-4 rounded-xl border border-[var(--border)] bg-white p-4 transition-colors hover:bg-[var(--surface-muted)]"
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold ${checkedSteps.has(index) ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'}`}>{index + 1}</div> <div
<input type="checkbox" checked={checkedSteps.has(index)} onChange={() => toggleStep(index)} className="h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer" /> className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full font-bold ${
checkedSteps.has(index)
? 'bg-[var(--color-primary)] text-white'
: 'bg-[var(--surface-muted)] text-[var(--text-dim)]'
}`}
>
{index + 1}
</div> </div>
<span className={`text-lg flex-1 ${checkedSteps.has(index) ? 'line-through text-gray-500' : 'text-gray-900'}`}>{instruction}</span> <input
type="checkbox"
checked={checkedSteps.has(index)}
onChange={() => toggleStep(index)}
className="h-5 w-5 cursor-pointer rounded border-[var(--border-strong)] text-[var(--color-primary)] focus:ring-[var(--focus-ring)]"
/>
</div>
<span className={`flex-1 text-lg ${checkedSteps.has(index) ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}`}>
{instruction}
</span>
</label> </label>
))} ))}
</div> </div>
</div> </UiSection>
{ingredientsChecked === ingredientsTotal && stepsChecked === stepsTotal && (
<div className="bg-green-50 border-2 border-green-500 rounded-2xl p-7 mb-8 text-center shadow-card"> {done && (
<div className="text-4xl mb-3">🎉</div> <UiSection className="mb-8 border-[var(--success-border)] bg-[var(--success-bg)] text-center" padding="lg">
<h3 className="text-2xl font-bold text-green-800 mb-2">All Done!</h3> <div className="mb-3 text-4xl">🎉</div>
<p className="text-green-700 text-lg mb-4">You've completed all steps. Enjoy your meal!</p> <h3 className="mb-2 text-2xl font-bold text-[var(--success-text)]">All Done!</h3>
<Link to={`/recipe/${recipe.id}`} className="inline-block px-6 py-3 bg-green-600 text-white rounded-full hover:bg-green-700 transition-colors font-medium shadow">Back to Recipe</Link> <p className="mb-4 text-lg text-[var(--success-text)]/90">You've completed all steps. Enjoy your meal!</p>
</div> )} <Link to={`/recipe/${recipe.id}`} className="ui-btn ui-btn-primary">Back to Recipe</Link>
</div> </UiSection>
)}
</UiPage>
); );
} }

View File

@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { createRecipe, importRecipeFromUrl } from '../services/api'; import { createRecipe, importRecipeFromUrl } from '../services/api';
import type { RecipeDraft, UrlImportResult } from '../types/recipe'; import type { RecipeDraft, UrlImportResult } from '../types/recipe';
import { radius } from '../theme'; import { UiButton, UiCard, UiChip, UiPage, UiSection } from '../components/ui/primitives';
type ImportErrorType = 'invalid-url' | 'parse-failure' | 'timeout' | 'generic'; type ImportErrorType = 'invalid-url' | 'parse-failure' | 'timeout' | 'generic';
type ImportStage = 'idle' | 'fetching' | 'parsing' | 'review' | 'saving' | 'done' | 'error'; type ImportStage = 'idle' | 'fetching' | 'parsing' | 'review' | 'saving' | 'done' | 'error';
@ -10,9 +10,14 @@ type ImportStage = 'idle' | 'fetching' | 'parsing' | 'review' | 'saving' | 'done
function toTextBlock(items: string[]): string { function toTextBlock(items: string[]): string {
return items.join('\n'); return items.join('\n');
} }
function toList(text: string): string[] { function toList(text: string): string[] {
return text.split('\n').map((line) => line.trim()).filter((line) => line.length > 0); return text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
} }
function getImportErrorDetails(message: string): { type: ImportErrorType; message: string } { function getImportErrorDetails(message: string): { type: ImportErrorType; message: string } {
const normalized = message.toLowerCase(); const normalized = message.toLowerCase();
if (normalized.includes('valid url')) { if (normalized.includes('valid url')) {
@ -44,22 +49,32 @@ function getImportErrorDetails(message: string): { type: ImportErrorType; messag
message, message,
}; };
} }
function draftIngredientsToStringArray(ingredients: RecipeDraft['ingredients']): string[] { function draftIngredientsToStringArray(ingredients: RecipeDraft['ingredients']): string[] {
if (!Array.isArray(ingredients)) return []; if (!Array.isArray(ingredients)) return [];
return ingredients.map((x) => x && typeof x === 'object' && typeof x.item === 'string' ? x.item : String(x)); return ingredients.map((x) =>
x && typeof x === 'object' && typeof x.item === 'string' ? x.item : String(x),
);
} }
function ingredientStringsToDraftArray(strings: string[]): RecipeDraft['ingredients'] { function ingredientStringsToDraftArray(strings: string[]): RecipeDraft['ingredients'] {
return strings.map((s) => ({ item: s, quantity: null, unit: null, notes: null })); return strings.map((s) => ({ item: s, quantity: null, unit: null, notes: null }));
} }
function stageProgress(stage: ImportStage): number { function stageProgress(stage: ImportStage): number {
switch (stage) { switch (stage) {
case 'fetching': return 25; case 'fetching':
case 'parsing': return 60; return 25;
case 'review': return 80; case 'parsing':
case 'saving': return 95; return 60;
case 'done': return 100; case 'review':
default: return 0; return 80;
case 'saving':
return 95;
case 'done':
return 100;
default:
return 0;
} }
} }
@ -98,7 +113,6 @@ export function ImportUrlPage() {
setStage('fetching'); setStage('fetching');
try { try {
// brief staged transition for visual progress communication
setTimeout(() => setStage((current) => (current === 'fetching' ? 'parsing' : current)), 450); setTimeout(() => setStage((current) => (current === 'fetching' ? 'parsing' : current)), 450);
const imported: UrlImportResult = await importRecipeFromUrl(url); const imported: UrlImportResult = await importRecipeFromUrl(url);
setResult(imported); setResult(imported);
@ -156,31 +170,35 @@ export function ImportUrlPage() {
}; };
const progress = stageProgress(stage); const progress = stageProgress(stage);
const stageOrder: ImportStage[] = ['idle', 'fetching', 'parsing', 'review', 'saving', 'done', 'error'];
const activeIndex = stageOrder.indexOf(stage);
return ( return (
<div className="mx-auto max-w-3xl py-8"> <UiPage className="mx-auto max-w-3xl py-8">
<section <UiSection className="relative mb-8 overflow-hidden" padding="lg">
className="relative mb-8 overflow-hidden border border-slate-200/80 bg-white/90 shadow-card" <div className="absolute inset-x-0 top-0 h-24 bg-gradient-to-r from-[var(--color-primary-soft)] via-[var(--surface-muted)] to-[var(--surface-elevated)]" />
style={{ borderRadius: radius.lg }} <div className="relative">
>
<div className="absolute inset-x-0 top-0 h-24 bg-gradient-to-r from-blue-100 via-indigo-50 to-violet-100" />
<div className="relative px-6 pb-6 pt-5 md:px-7">
<div className="mb-5 flex items-start justify-between gap-4"> <div className="mb-5 flex items-start justify-between gap-4">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wider text-blue-700">Smart Import</p> <p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-primary)]">Smart Import</p>
<h2 className="text-3xl font-bold text-gray-900">Import from URL</h2> <h2 className="text-3xl font-bold text-[var(--text-h)]">Import from URL</h2>
<p className="mt-1 text-gray-600">Paste a recipe URL and we'll fetch, parse, and prep it for your cookbook.</p> <p className="mt-1 text-[var(--text-dim)]">
Paste a recipe URL and we'll fetch, parse, and prep it for your cookbook.
</p>
</div> </div>
<div className="hidden rounded-xl border border-white/80 bg-white/70 p-4 text-3xl md:block">🔎</div> <div className="hidden rounded-xl border border-[var(--border)] bg-white/70 p-4 text-3xl md:block">🔎</div>
</div> </div>
<div className="mb-5 rounded-lg border border-slate-200 bg-slate-50/80 p-3"> <UiCard tone="muted" className="mb-5 p-3">
<div className="mb-2 flex items-center justify-between text-sm"> <div className="mb-2 flex items-center justify-between text-sm">
<span className="font-semibold text-slate-700">Import Progress</span> <span className="font-semibold text-[var(--text)]">Import Progress</span>
<span className="text-slate-500">{progress}%</span> <span className="text-[var(--text-dim)]">{progress}%</span>
</div> </div>
<div className="h-2 overflow-hidden rounded-full bg-slate-200"> <div className="h-2 overflow-hidden rounded-full bg-[var(--border)]">
<div className="h-full rounded-full bg-gradient-to-r from-blue-500 to-indigo-500 transition-all duration-500" style={{ width: `${progress}%` }} /> <div
className="h-full rounded-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div> </div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4"> <div className="mt-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
{[ {[
@ -189,25 +207,33 @@ export function ImportUrlPage() {
{ key: 'review', label: 'Review draft' }, { key: 'review', label: 'Review draft' },
{ key: 'saving', label: 'Save recipe' }, { key: 'saving', label: 'Save recipe' },
].map((step, index) => { ].map((step, index) => {
const activeIndex = ['idle', 'fetching', 'parsing', 'review', 'saving', 'done', 'error'].indexOf(stage);
const stepIndex = index + 1; const stepIndex = index + 1;
const done = stage === 'done' || activeIndex > stepIndex; const done = stage === 'done' || activeIndex > stepIndex;
const active = activeIndex === stepIndex; const active = activeIndex === stepIndex;
return ( return (
<div <div
key={step.key} key={step.key}
className={`rounded-md border px-2 py-1.5 ${done ? 'border-green-200 bg-green-50 text-green-700' : active ? 'border-blue-200 bg-blue-50 text-blue-700' : 'border-slate-200 bg-white text-slate-500'}`} className={`rounded-md border px-2 py-1.5 ${
done
? 'border-[var(--success-border)] bg-[var(--success-bg)] text-[var(--success-text)]'
: active
? 'border-[var(--border-focus)] bg-[var(--color-primary-soft)] text-[var(--color-primary)]'
: 'border-[var(--border)] bg-white text-[var(--text-muted)]'
}`}
> >
{done ? '✓ ' : active ? '● ' : '○ '} {step.label} {done ? '✓ ' : active ? '● ' : '○ '} {step.label}
</div> </div>
); );
})} })}
</div> </div>
</div> </UiCard>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label htmlFor="import-url" className="mb-2 block text-sm font-medium text-gray-700">Recipe URL</label> <label htmlFor="import-url" className="mb-2 block text-sm font-medium text-[var(--text)]">
Recipe URL
</label>
<input <input
id="import-url" id="import-url"
type="url" type="url"
@ -215,69 +241,125 @@ export function ImportUrlPage() {
value={url} value={url}
onChange={(event) => setUrl(event.target.value)} onChange={(event) => setUrl(event.target.value)}
placeholder="https://example.com/my-recipe" placeholder="https://example.com/my-recipe"
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-base shadow-sm focus:border-transparent focus:ring-2 focus:ring-blue-500" className="ui-input"
/> />
</div> </div>
<button type="submit" disabled={loading} className="rounded-lg bg-blue-600 px-4 py-2.5 font-semibold text-white shadow transition-all duration-200 hover:-translate-y-0.5 hover:bg-blue-700 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"> <UiButton type="submit" variant="primary" disabled={loading} className="disabled:cursor-not-allowed disabled:opacity-50">
{loading ? 'Importing…' : '🔗 Import URL'} {loading ? 'Importing…' : '🔗 Import URL'}
</button> </UiButton>
</form> </form>
{error && ( {error && (
<div className={`mt-6 rounded-lg border p-4 ${errorType === 'parse-failure' ? 'border-amber-200 bg-amber-50' : 'border-red-200 bg-red-50'}`}> <div
<p className={errorType === 'parse-failure' ? 'text-amber-800' : 'text-red-800'}> className={`mt-6 rounded-lg border p-4 ${
<strong>{errorType === 'invalid-url' && 'Invalid URL:'}{errorType === 'timeout' && 'Import timed out:'}{errorType === 'parse-failure' && 'Parse failed:'}{errorType === 'generic' && 'Error:'}</strong> {error} errorType === 'parse-failure'
? 'border-[var(--warning-border)] bg-[var(--warning-bg)]'
: 'border-[var(--danger-border)] bg-[var(--danger-bg)]'
}`}
>
<p className={errorType === 'parse-failure' ? 'text-[var(--warning-text)]' : 'text-[var(--danger-text)]'}>
<strong>
{errorType === 'invalid-url' && 'Invalid URL:'}
{errorType === 'timeout' && 'Import timed out:'}
{errorType === 'parse-failure' && 'Parse failed:'}
{errorType === 'generic' && 'Error:'}
</strong>{' '}
{error}
</p> </p>
</div> </div>
)} )}
</div> </div>
</section> </UiSection>
{result && ( {result && (
<section className="mb-7 mt-1 space-y-5 rounded-2xl border border-gray-200 bg-white p-7 shadow-card"> <UiSection className="mb-7 mt-1 space-y-5" padding="lg">
<div className="rounded-xl border border-indigo-100 bg-gradient-to-r from-indigo-50 to-white px-4 py-3"> <UiCard className="bg-[var(--surface-muted)] p-4">
<h3 className="font-semibold text-gray-900">Parsed Preview</h3> <h3 className="font-semibold text-[var(--text-h)]">Parsed Preview</h3>
<p className="text-sm text-gray-600">Source: {result.source_url}</p> <p className="text-sm text-[var(--text-dim)]">Source: {result.source_url}</p>
<p className="text-sm text-gray-600">JSON-LD blocks found: {Array.isArray(result.json_ld_blocks) ? result.json_ld_blocks.length : 0}</p> <p className="text-sm text-[var(--text-dim)]">
</div> JSON-LD blocks found: {Array.isArray(result.json_ld_blocks) ? result.json_ld_blocks.length : 0}
</p>
</UiCard>
{draft ? ( {draft ? (
<form onSubmit={handleSave} className="space-y-5"> <form onSubmit={handleSave} className="space-y-5">
<p className="text-sm text-gray-600">Review and edit before saving.</p> <p className="text-sm text-[var(--text-dim)]">Review and edit before saving.</p>
{draftError && (<div className="mb-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">{draftError}</div>)} {draftError && (
<div className="mb-2 rounded-lg border border-[var(--danger-border)] bg-[var(--danger-bg)] p-3 text-sm text-[var(--danger-text)]">
{draftError}
</div>
)}
<div> <div>
<label htmlFor="draft-title" className="mb-1 block text-sm font-medium text-gray-700">Title</label> <label htmlFor="draft-title" className="mb-1 block text-sm font-medium text-[var(--text)]">Title</label>
<input id="draft-title" type="text" required value={draft.title} onChange={(event) => setDraft({ ...draft, title: event.target.value })} className="w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm" /> <input
id="draft-title"
type="text"
required
value={draft.title}
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
className="ui-input"
/>
</div> </div>
<div> <div>
<label htmlFor="draft-ingredients" className="mb-1 block text-sm font-medium text-gray-700">Ingredients (one per line)</label> <label htmlFor="draft-ingredients" className="mb-1 block text-sm font-medium text-[var(--text)]">
<textarea id="draft-ingredients" rows={8} value={toTextBlock(ingredientLines)} onChange={e => setIngredientLines(toList(e.target.value))} className="w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm" /> Ingredients (one per line)
</label>
<textarea
id="draft-ingredients"
rows={8}
value={toTextBlock(ingredientLines)}
onChange={(e) => setIngredientLines(toList(e.target.value))}
className="ui-textarea"
/>
</div> </div>
<div> <div>
<label htmlFor="draft-instructions" className="mb-1 block text-sm font-medium text-gray-700">Steps (one per line)</label> <label htmlFor="draft-instructions" className="mb-1 block text-sm font-medium text-[var(--text)]">
<textarea id="draft-instructions" rows={10} value={toTextBlock(instructionLines)} onChange={e => setInstructionLines(toList(e.target.value))} className="w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm" /> Steps (one per line)
</label>
<textarea
id="draft-instructions"
rows={10}
value={toTextBlock(instructionLines)}
onChange={(e) => setInstructionLines(toList(e.target.value))}
className="ui-textarea"
/>
</div> </div>
<div> <div>
<label htmlFor="draft-source-url" className="mb-1 block text-sm font-medium text-gray-700">Source URL</label> <label htmlFor="draft-source-url" className="mb-1 block text-sm font-medium text-[var(--text)]">
<input id="draft-source-url" type="url" value={draft.source_url ?? ''} onChange={(event) => setDraft({ ...draft, source_url: event.target.value.trim() ? event.target.value : undefined })} className="w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm" /> Source URL
</label>
<input
id="draft-source-url"
type="url"
value={draft.source_url ?? ''}
onChange={(event) =>
setDraft({
...draft,
source_url: event.target.value.trim() ? event.target.value : undefined,
})
}
className="ui-input"
/>
</div> </div>
<div className="mt-2 flex gap-3"> <div className="mt-2 flex flex-wrap gap-3">
<button type="submit" disabled={isSaving} className="rounded-lg bg-green-600 px-4 py-2 font-medium text-white shadow transition-all duration-200 hover:-translate-y-0.5 hover:bg-green-700 focus-visible:ring-2 focus-visible:ring-green-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"> <UiButton type="submit" variant="primary" disabled={isSaving} className="disabled:cursor-not-allowed disabled:opacity-50">
{isSaving ? 'Saving…' : '💾 Save Recipe'} {isSaving ? 'Saving…' : '💾 Save Recipe'}
</button> </UiButton>
<Link to="/recipe/new" className="rounded-lg border border-gray-300 px-4 py-2 font-medium text-gray-700 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:bg-gray-50 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2">📝 Open full editor</Link> <Link to="/recipe/new" className="ui-btn ui-btn-secondary">📝 Open full editor</Link>
</div> </div>
</form> </form>
) : ( ) : (
<p className="rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">Could not parse a recipe preview from this URL.</p> <UiChip className="border-[var(--warning-border)] bg-[var(--warning-bg)] text-[var(--warning-text)]">
Could not parse a recipe preview from this URL.
</UiChip>
)} )}
</section> </UiSection>
)} )}
</div> </UiPage>
); );
} }

View File

@ -1,19 +1,19 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { UiPage, UiSection } from '../components/ui/primitives';
/** /**
* NotFoundPage - 404 error page * NotFoundPage - 404 error page
*/ */
export function NotFoundPage() { export function NotFoundPage() {
return ( return (
<div className="text-center py-12"> <UiPage>
<h2 className="text-4xl font-bold text-gray-900 mb-4">404</h2> <UiSection className="py-12 text-center">
<p className="text-xl text-gray-600 mb-8">Page not found</p> <h2 className="mb-4 text-4xl font-bold text-[var(--text-h)]">404</h2>
<Link <p className="mb-8 text-xl text-[var(--text-dim)]">Page not found</p>
to="/" <Link to="/" className="ui-btn ui-btn-primary inline-flex items-center">
className="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
Back to Recipes Back to Recipes
</Link> </Link>
</div> </UiSection>
</UiPage>
); );
} }

View File

@ -4,23 +4,199 @@ import { useRecipe } from '../hooks/useRecipe';
import { useToastContext } from '../App'; import { useToastContext } from '../App';
import { RecipeForm, type RecipeFormData } from '../components/RecipeForm'; import { RecipeForm, type RecipeFormData } from '../components/RecipeForm';
import { createRecipe, updateRecipe, deleteRecipe, fetchRecipeTags, assignTagToRecipe, removeTagFromRecipe } from '../services/api'; import { createRecipe, updateRecipe, deleteRecipe, fetchRecipeTags, assignTagToRecipe, removeTagFromRecipe } from '../services/api';
import type { Tag, Ingredient } from '../types/recipe'; import type { Tag, Ingredient, Recipe } from '../types/recipe';
import { radius } from '../theme'; import { loadPhotoManifest } from '../assets/photoManifest';
import { UiButton, UiCard, UiChip, UiPage, UiSection } from '../components/ui/primitives';
interface PhotoManifest {
assets?: {
photos?: {
detail?: {
default16x9?: string;
};
list?: {
default4x3?: string;
};
category?: Record<string, { localPath?: string | null; remoteCandidate?: string | null }>;
};
};
}
type IconName =
| 'clock'
| 'users'
| 'chef-hat'
| 'list'
| 'book-open'
| 'edit'
| 'cook'
| 'print'
| 'share'
| 'trash'
| 'arrow-left'
| 'link'
| 'note'
| 'tag';
function Icon({ name, className = 'h-4 w-4', size = 16 }: { name: IconName; className?: string; size?: number }) {
const common = {
className,
viewBox: '0 0 24 24',
width: size,
height: size,
style: {
width: `${size}px`,
height: `${size}px`,
minWidth: `${size}px`,
minHeight: `${size}px`,
maxWidth: `${size}px`,
maxHeight: `${size}px`,
flexShrink: 0,
},
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
'aria-hidden': true,
};
switch (name) {
case 'clock':
return <svg {...common}><circle cx="12" cy="12" r="9" /><path d="M12 7v6l4 2" /></svg>;
case 'users':
return <svg {...common}><path d="M17 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg>;
case 'chef-hat':
return <svg {...common}><path d="M6 18h12" /><path d="M6 22h12" /><path d="M7 18v-7a4 4 0 1 1 7-2 3 3 0 1 1 4 3v6" /></svg>;
case 'list':
return <svg {...common}><path d="M8 6h13" /><path d="M8 12h13" /><path d="M8 18h13" /><path d="M3 6h.01" /><path d="M3 12h.01" /><path d="M3 18h.01" /></svg>;
case 'book-open':
return <svg {...common}><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" /><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" /></svg>;
case 'edit':
return <svg {...common}><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>;
case 'cook':
return <svg {...common}><path d="M4 12h16" /><path d="M6 12v6a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-6" /><path d="M8 12V8a4 4 0 1 1 8 0v4" /></svg>;
case 'print':
return <svg {...common}><path d="M6 9V2h12v7" /><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" /><path d="M6 14h12v8H6z" /></svg>;
case 'share':
return <svg {...common}><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="m8.6 13.5 6.8 4" /><path d="m15.4 6.5-6.8 4" /></svg>;
case 'trash':
return <svg {...common}><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" /><path d="M10 11v6" /><path d="M14 11v6" /></svg>;
case 'arrow-left':
return <svg {...common}><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></svg>;
case 'link':
return <svg {...common}><path d="M10 13a5 5 0 0 0 7.07 0l2.83-2.83a5 5 0 0 0-7.07-7.07L10 5" /><path d="M14 11a5 5 0 0 0-7.07 0L4.1 13.83a5 5 0 1 0 7.07 7.07L14 19" /></svg>;
case 'note':
return <svg {...common}><path d="M8 2h8l4 4v14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z" /><path d="M16 2v4h4" /><path d="M10 13h4" /><path d="M10 17h4" /><path d="M10 9h2" /></svg>;
case 'tag':
return <svg {...common}><path d="M20.59 13.41 11 3.83A2 2 0 0 0 9.59 3H4a1 1 0 0 0-1 1v5.59A2 2 0 0 0 3.83 11l9.58 9.59a2 2 0 0 0 2.83 0l4.35-4.35a2 2 0 0 0 0-2.83Z" /><circle cx="7.5" cy="7.5" r="1.1" /></svg>;
default:
return null;
}
}
function normalizeImageUrl(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
if (trimmed.startsWith('/') || trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return trimmed;
}
return null;
}
function extractRecipeImageUrl(recipe: Recipe | null): string | null {
if (!recipe) return null;
const dynamicRecipe = recipe as unknown as Record<string, unknown>;
const candidateKeys = [
'image_url',
'imageUrl',
'photo_url',
'photoUrl',
'hero_image_url',
'heroImageUrl',
'thumbnail_url',
'thumbnailUrl',
];
for (const key of candidateKeys) {
const normalized = normalizeImageUrl(dynamicRecipe[key]);
if (normalized) return normalized;
}
return null;
}
function inferCategoryKey(recipe: Recipe | null, tags: Tag[]): string | null {
const title = recipe?.title ?? '';
const haystack = `${title} ${tags.map((tag) => tag.name).join(' ')}`.toLowerCase();
if (haystack.includes('breakfast') || haystack.includes('egg') || haystack.includes('toast')) return 'breakfast';
if (haystack.includes('dessert') || haystack.includes('cake') || haystack.includes('cookie') || haystack.includes('sweet')) return 'dessert';
if (haystack.includes('dinner') || haystack.includes('steak') || haystack.includes('pasta') || haystack.includes('roast')) return 'dinner';
return null;
}
function buildDetailImageCandidates(recipe: Recipe | null, tags: Tag[], manifest: PhotoManifest | null): string[] {
const candidates: Array<string | null | undefined> = [];
const recipeImage = extractRecipeImageUrl(recipe);
candidates.push(recipeImage);
const categoryKey = inferCategoryKey(recipe, tags);
if (categoryKey && manifest?.assets?.photos?.category?.[categoryKey]) {
const categoryAsset = manifest.assets.photos.category[categoryKey];
candidates.push(categoryAsset.localPath ?? categoryAsset.remoteCandidate ?? null);
}
candidates.push(manifest?.assets?.photos?.detail?.default16x9);
candidates.push(manifest?.assets?.photos?.list?.default4x3);
candidates.push('/assets/food/placeholder-recipe.svg');
const seen = new Set<string>();
return candidates
.map((item) => normalizeImageUrl(item))
.filter((item): item is string => {
if (!item || seen.has(item)) return false;
seen.add(item);
return true;
});
}
/** /**
* RecipeDetailPage - View, create, and edit recipes (visual refresh - task 3) * RecipeDetailPage - View, create, and edit recipes (T06 detail visual upgrade)
*/ */
export function RecipeDetailPage() { export function RecipeDetailPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const toast = useToastContext(); const toast = useToastContext();
// Parse ID or null for "new" route
const recipeId = id === 'new' ? null : (id ? parseInt(id, 10) : null); const recipeId = id === 'new' ? null : (id ? parseInt(id, 10) : null);
const { recipe, loading, error } = useRecipe(recipeId); const { recipe, loading, error } = useRecipe(recipeId);
const [isEditing, setIsEditing] = useState(recipeId === null); const [isEditing, setIsEditing] = useState(recipeId === null);
const [deleteConfirm, setDeleteConfirm] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(false);
const [recipeTags, setRecipeTags] = useState<Tag[]>([]); const [recipeTags, setRecipeTags] = useState<Tag[]>([]);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [photoManifest, setPhotoManifest] = useState<PhotoManifest | null>(null);
const [detailImageCandidates, setDetailImageCandidates] = useState<string[]>(['/assets/food/placeholder-recipe.svg']);
const [detailImageIndex, setDetailImageIndex] = useState(0);
useEffect(() => {
setDetailImageIndex(0);
}, [recipeId]);
useEffect(() => {
loadPhotoManifest<PhotoManifest>()
.then((manifest) => setPhotoManifest(manifest))
.catch(() => {
setPhotoManifest(null);
});
}, []);
useEffect(() => {
const candidates = buildDetailImageCandidates(recipe, recipeTags, photoManifest);
setDetailImageCandidates(candidates.length > 0 ? candidates : ['/assets/food/placeholder-recipe.svg']);
setDetailImageIndex(0);
}, [recipe, recipeTags, photoManifest]);
useEffect(() => { useEffect(() => {
if (recipeId !== null) { if (recipeId !== null) {
@ -53,7 +229,13 @@ export function RecipeDetailPage() {
ingredients: toApiIngredients(data.ingredients), ingredients: toApiIngredients(data.ingredients),
instructions: data.instructions, instructions: data.instructions,
}); });
for (const tag of tags) { try { await assignTagToRecipe(newRecipe.id, tag.id); } catch {} } for (const tag of tags) {
try {
await assignTagToRecipe(newRecipe.id, tag.id);
} catch {
// no-op
}
}
toast.success('Recipe created successfully!'); toast.success('Recipe created successfully!');
navigate(`/recipe/${newRecipe.id}`); navigate(`/recipe/${newRecipe.id}`);
} else { } else {
@ -62,13 +244,25 @@ export function RecipeDetailPage() {
ingredients: toApiIngredients(data.ingredients), ingredients: toApiIngredients(data.ingredients),
instructions: data.instructions, instructions: data.instructions,
}); });
const currentTagIds = recipeTags.map(t => t.id); const currentTagIds = recipeTags.map((t) => t.id);
const newTagIds = tags.map(t => t.id); const newTagIds = tags.map((t) => t.id);
for (const tagId of currentTagIds) { for (const tagId of currentTagIds) {
if (!newTagIds.includes(tagId)) { try { await removeTagFromRecipe(recipeId, tagId); } catch {} } if (!newTagIds.includes(tagId)) {
try {
await removeTagFromRecipe(recipeId, tagId);
} catch {
// no-op
}
}
} }
for (const tagId of newTagIds) { for (const tagId of newTagIds) {
if (!currentTagIds.includes(tagId)) { try { await assignTagToRecipe(recipeId, tagId); } catch {} } if (!currentTagIds.includes(tagId)) {
try {
await assignTagToRecipe(recipeId, tagId);
} catch {
// no-op
}
}
} }
toast.success('Recipe updated successfully!'); toast.success('Recipe updated successfully!');
setIsEditing(false); setIsEditing(false);
@ -96,215 +290,308 @@ export function RecipeDetailPage() {
} }
}; };
const handlePrint = () => {
window.print();
};
const handleShare = async () => {
const title = recipe?.title ?? 'Recipe';
const shareText = recipe?.description ?? 'Check out this recipe';
const shareUrl = window.location.href;
try {
if (navigator.share) {
await navigator.share({
title,
text: shareText,
url: shareUrl,
});
return;
}
await navigator.clipboard.writeText(shareUrl);
toast.success('Recipe link copied to clipboard');
} catch {
toast.error('Could not share recipe right now');
}
};
if (loading) { if (loading) {
return ( return (
<div className="flex flex-col items-center justify-center py-24"> <div className="flex flex-col items-center justify-center py-24">
<div className="inline-block h-9 w-9 animate-spin rounded-full border-b-2 border-primary"></div> <div className="inline-block h-9 w-9 animate-spin rounded-full border-b-2 border-primary"></div>
<p className="mt-6 text-base font-medium text-gray-500">Loading recipe...</p> <p className="mt-6 text-base font-medium text-[var(--text-dim)]">Loading recipe...</p>
</div> </div>
); );
} }
if (error) { if (error) {
return ( return (
<div className="mx-auto mt-12 flex max-w-xl flex-col items-center rounded-xl border border-red-200 bg-red-50 p-8 shadow-card"> <UiSection className="mx-auto mt-12 flex max-w-xl flex-col items-center border border-[color:var(--color-error-light)] bg-[color:var(--color-error-light)]/45 p-8 text-center" padding="none">
<h3 className="mb-3 text-xl font-bold text-red-800">Error Loading Recipe</h3> <h3 className="mb-3 text-xl font-bold text-[color:var(--color-error)]">Error Loading Recipe</h3>
<p className="mb-2 text-base text-red-600">{error}</p> <p className="mb-2 text-base text-[var(--text-dim)]">{error}</p>
<Link to="/" className="mt-4 rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-blue-700"> Back to recipes</Link> <Link to="/" className="ui-btn ui-btn-primary mt-4"> Back to recipes</Link>
</div> </UiSection>
); );
} }
if (recipeId === null) { if (recipeId === null) {
return ( return (
<div className="mx-auto max-w-3xl pt-8"> <div className="mx-auto max-w-3xl pt-8">
<div <UiSection className="mb-6 overflow-hidden border border-[var(--border)] bg-gradient-to-br from-white via-[var(--surface-muted)]/70 to-[var(--bg-alt)]/70 shadow-card" padding="none">
className="mb-6 overflow-hidden border border-blue-100 bg-gradient-to-br from-white via-blue-50/70 to-indigo-50/80 shadow-card"
style={{ borderRadius: radius.lg }}
>
<div className="flex items-center justify-between px-6 py-5"> <div className="flex items-center justify-between px-6 py-5">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wider text-blue-600">New Recipe</p> <p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-primary)]">New Recipe</p>
<h2 className="text-3xl font-bold text-gray-900">Create New Recipe</h2> <h2 className="text-3xl font-bold text-[var(--text-h)]">Create New Recipe</h2>
<p className="mt-1 text-base text-gray-600">Fill in the details below to add a new recipe.</p> <p className="mt-1 text-base text-[var(--text-dim)]">Fill in the details below to add a new recipe.</p>
</div> </div>
<div className="hidden rounded-xl border border-blue-100 bg-white/80 p-4 text-3xl md:block">🧾</div> <UiChip tone="subtle" className="hidden border-[var(--border)] bg-white/80 px-4 py-2 text-sm font-semibold text-[var(--color-primary)] md:inline-flex">Draft</UiChip>
</div> </div>
</div> </UiSection>
<div className="rounded-xl bg-white p-8 shadow-card"> <UiCard className="p-8">
<RecipeForm initialTags={[]} onSubmit={handleSubmit} onCancel={() => navigate('/')} submitLabel="Create Recipe" /> <RecipeForm initialTags={[]} onSubmit={handleSubmit} onCancel={() => navigate('/')} submitLabel="Create Recipe" />
</div> </UiCard>
</div> </div>
); );
} }
if (!recipe) { if (!recipe) {
return ( return (
<div className="mx-auto mt-12 flex max-w-md flex-col items-center rounded-xl border border-yellow-200 bg-yellow-50 p-8 shadow-card"> <UiSection className="mx-auto mt-12 flex max-w-md flex-col items-center border border-[color:var(--color-warning-light)] bg-[color:var(--color-warning-light)]/45 p-8 text-center" padding="none">
<h3 className="mb-2 text-xl font-bold text-yellow-800">Recipe Not Found</h3> <h3 className="mb-2 text-xl font-bold text-[color:var(--color-warning)]">Recipe Not Found</h3>
<p className="mb-2 text-base text-yellow-600">The recipe you are looking for does not exist.</p> <p className="mb-2 text-base text-[var(--text-dim)]">The recipe you are looking for does not exist.</p>
<Link to="/" className="mt-4 rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-blue-700"> Back to recipes</Link> <Link to="/" className="ui-btn ui-btn-primary mt-4"> Back to recipes</Link>
</div> </UiSection>
); );
} }
if (isEditing) { if (isEditing) {
return ( return (
<div className="mx-auto max-w-3xl pt-8"> <div className="mx-auto max-w-3xl pt-8">
<div <UiSection className="mb-6 overflow-hidden border border-[var(--border)] bg-gradient-to-br from-white via-[var(--surface-muted)]/70 to-[var(--bg-alt)]/70 shadow-card" padding="none">
className="mb-6 overflow-hidden border border-violet-100 bg-gradient-to-br from-white via-violet-50/70 to-indigo-50/70 shadow-card"
style={{ borderRadius: radius.lg }}
>
<div className="flex items-center justify-between px-6 py-5"> <div className="flex items-center justify-between px-6 py-5">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wider text-violet-600">Editing</p> <p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-primary)]">Editing</p>
<h2 className="text-3xl font-bold text-gray-900">Edit Recipe</h2> <h2 className="text-3xl font-bold text-[var(--text-h)]">Edit Recipe</h2>
<p className="mt-1 text-base text-gray-600">Update recipe information below.</p> <p className="mt-1 text-base text-[var(--text-dim)]">Update recipe information below.</p>
</div> </div>
<div className="hidden rounded-xl border border-violet-100 bg-white/80 p-4 text-3xl md:block"></div> <UiChip tone="subtle" className="hidden border-[var(--border)] bg-white/80 px-4 py-2 text-sm font-semibold text-[var(--color-primary)] md:inline-flex">Edit</UiChip>
</div> </div>
</div> </UiSection>
<div className="rounded-xl bg-white p-8 shadow-card"> <UiCard className="p-8">
<RecipeForm recipe={recipe} initialTags={recipeTags} onSubmit={handleSubmit} onCancel={() => setIsEditing(false)} submitLabel="Save Changes" /> <RecipeForm recipe={recipe} initialTags={recipeTags} onSubmit={handleSubmit} onCancel={() => setIsEditing(false)} submitLabel="Save Changes" />
</div> </UiCard>
</div> </div>
); );
} }
const statCards = [ const heroSrc = detailImageCandidates[Math.min(detailImageIndex, detailImageCandidates.length - 1)] ?? '/assets/food/placeholder-recipe.svg';
recipe.servings ? { label: 'Servings', value: String(recipe.servings), icon: '🍽️' } : null,
recipe.prep_time_minutes ? { label: 'Prep Time', value: `${recipe.prep_time_minutes} min`, icon: '⏱️' } : null, const ingredients = Array.isArray(recipe.ingredients)
recipe.cook_time_minutes ? { label: 'Cook Time', value: `${recipe.cook_time_minutes} min`, icon: '🔥' } : null, ? recipe.ingredients
].filter(Boolean) as Array<{ label: string; value: string; icon: string }>; .map((ingredient) => ('item' in ingredient ? ingredient.item : String(ingredient)))
.filter(Boolean)
: [];
const instructions = Array.isArray(recipe.instructions)
? recipe.instructions.filter(Boolean)
: Array.isArray(recipe.steps)
? recipe.steps
.map((step) => step?.instruction)
.filter((instruction): instruction is string => Boolean(instruction))
: [];
const totalTime = (recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0);
const mobileActionButtonClass = 'ui-btn ui-btn-secondary min-h-[2.25rem] w-full min-w-0 justify-start gap-1.5 px-2.5 py-2 text-xs leading-4 sm:text-sm';
const mobilePrimaryActionButtonClass = 'ui-btn ui-btn-primary min-h-[2.25rem] w-full min-w-0 justify-start gap-1.5 px-2.5 py-2 text-xs leading-4 sm:text-sm';
const desktopActionButtonClass = 'ui-btn ui-btn-secondary w-full min-h-[2.5rem] min-w-0 justify-start gap-2 px-3 py-2 text-sm leading-4';
const metadataBadges = [
recipe.servings ? { label: `${recipe.servings} servings`, icon: 'users' as const } : null,
recipe.prep_time_minutes ? { label: `${recipe.prep_time_minutes} min prep`, icon: 'clock' as const } : null,
recipe.cook_time_minutes ? { label: `${recipe.cook_time_minutes} min cook`, icon: 'chef-hat' as const } : null,
totalTime > 0 ? { label: `${totalTime} min total`, icon: 'clock' as const } : null,
ingredients.length > 0 ? { label: `${ingredients.length} ingredients`, icon: 'list' as const } : null,
].filter(Boolean) as Array<{ label: string; icon: IconName }>;
return ( return (
<div className="mx-auto max-w-5xl pt-8"> <UiPage className="max-w-5xl px-4 pt-5 pb-20 sm:px-5 md:px-6 lg:px-8 md:pt-6 md:pb-8">
<section <section className="ui-section relative overflow-hidden">
className="relative mb-6 overflow-hidden border border-slate-200/80 bg-white/95 shadow-card" <div className="absolute inset-0 bg-gradient-to-br from-[var(--color-primary-light)]/70 via-[var(--surface-muted)]/60 to-[var(--bg-alt)]/65" aria-hidden="true" />
style={{ borderRadius: radius.lg }} <div className="relative grid grid-cols-1 gap-6 px-5 py-5 sm:px-6 sm:py-6 md:grid-cols-[1.2fr_1fr] md:items-stretch md:gap-8 md:px-8 md:py-7">
> <div className="min-w-0 space-y-5 px-0.5 sm:px-1 md:space-y-6 md:px-0">
<div className="absolute inset-x-0 top-0 h-28 bg-gradient-to-r from-orange-100 via-amber-50 to-blue-100" /> <p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-primary-dark)]">Recipe Detail</p>
<div className="relative px-6 pb-6 pt-7 md:px-8"> <h1 className="max-w-[24ch] break-words text-xl font-black leading-tight text-[var(--text-h)] sm:text-2xl md:text-3xl">{recipe.title}</h1>
<div className="mb-5 flex items-start justify-between gap-5">
<div className="min-w-0 flex-1">
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-orange-600">Recipe Details</p>
<h2 className="mb-1 break-words text-4xl font-extrabold text-gray-900">{recipe.title}</h2>
{recipe.description && ( {recipe.description && (
<p className="mt-2 max-w-3xl break-words text-base text-gray-600 md:text-lg">{recipe.description}</p> <p className="max-w-prose break-words text-sm leading-relaxed text-[var(--text-dim)] sm:text-base">{recipe.description}</p>
)} )}
{metadataBadges.length > 0 && (
<div className="-mx-0.5 flex flex-wrap gap-2.5 px-0.5 pt-1 sm:mx-0 sm:px-0">
{metadataBadges.map((badge) => (
<UiChip key={badge.label} className="gap-1.5 border-[var(--border)] bg-white/90 px-3.5 py-1.5 text-xs font-semibold text-[var(--text)] shadow-sm">
<Icon name={badge.icon} className="h-3 w-3 text-[var(--text-dim)]" />
{badge.label}
</UiChip>
))}
</div>
)}
{recipeTags.length > 0 && ( {recipeTags.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2"> <div className="-mx-0.5 flex flex-wrap gap-2.5 px-0.5 pt-1 sm:mx-0 sm:px-0">
{recipeTags.map(tag => ( {recipeTags.map((tag) => (
<span <span
key={tag.id} key={tag.id}
className="rounded-full px-3 py-1 text-xs font-semibold text-white shadow" className="inline-flex max-w-full items-center gap-2 rounded-full border border-white/70 px-4 py-2 text-xs font-semibold leading-4 text-[var(--text-h)] shadow-sm sm:px-5"
style={{ backgroundColor: tag.color || '#3B82F6' }} style={{ backgroundColor: `${tag.color || '#E2E8F0'}cc` }}
> >
{tag.name} <Icon name="tag" className="h-3 w-3 shrink-0 text-[var(--text-dim)]" />
<span className="truncate">{tag.name}</span>
</span> </span>
))} ))}
</div> </div>
)} )}
</div> </div>
<div className="hidden rounded-2xl border border-white/80 bg-white/75 p-4 text-4xl shadow-sm md:block">🍲</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"> <div className="relative overflow-hidden rounded-2xl border border-white/70 bg-white/60 shadow-card">
<button onClick={() => setIsEditing(true)} className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 font-semibold text-white shadow transition-all duration-200 hover:-translate-y-0.5 hover:bg-blue-700 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"><span aria-hidden="true"></span> Edit Recipe</button> <img
<Link to={`/recipe/${recipe.id}/cook`} className="inline-flex items-center justify-center gap-2 rounded-lg bg-green-600 px-4 py-2.5 text-center font-semibold text-white shadow transition-all duration-200 hover:-translate-y-0.5 hover:bg-green-700 focus-visible:ring-2 focus-visible:ring-green-500 focus-visible:ring-offset-2"><span aria-hidden="true">🍳</span>Cook Mode</Link> src={heroSrc}
alt={recipe.title}
className="h-full min-h-[240px] w-full object-cover"
onError={() => {
if (detailImageIndex < detailImageCandidates.length - 1) {
setDetailImageIndex((idx) => idx + 1);
}
}}
/>
<div className="absolute bottom-3 left-3 inline-flex max-w-[calc(100%-1.5rem)] items-center gap-1.5 rounded-full border border-white/80 bg-black/55 px-3 py-1 text-xs font-semibold text-white backdrop-blur">
<Icon name="book-open" className="h-3 w-3" />
{instructions.length > 0 ? `${instructions.length} steps` : 'Recipe overview'}
</div>
</div>
</div>
</section>
<section className="mt-5 md:hidden min-w-0">
<div className="sticky bottom-3 z-20 rounded-2xl border border-[var(--border)] bg-white/95 p-2.5 shadow-[0_10px_30px_rgba(15,23,42,0.15)] backdrop-blur">
<div className="grid grid-cols-2 gap-2 min-w-0 text-xs">
<button onClick={() => setIsEditing(true)} className={mobileActionButtonClass}><Icon name="edit" className="h-3.5 w-3.5" size={14} /><span className="truncate">Edit</span></button>
<Link to={`/recipe/${recipe.id}/cook`} className={mobilePrimaryActionButtonClass}><Icon name="cook" className="h-3.5 w-3.5" size={14} /><span className="truncate">Cook</span></Link>
<button onClick={handlePrint} className={mobileActionButtonClass}><Icon name="print" className="h-3.5 w-3.5" size={14} /><span className="truncate">Print</span></button>
<button onClick={handleShare} className={mobileActionButtonClass}><Icon name="share" className="h-3.5 w-3.5" size={14} /><span className="truncate">Share</span></button>
{!deleteConfirm ? ( {!deleteConfirm ? (
<button onClick={() => setDeleteConfirm(true)} className="inline-flex items-center justify-center gap-2 rounded-lg bg-red-600 px-4 py-2.5 font-semibold text-white shadow transition-all duration-200 hover:-translate-y-0.5 hover:bg-red-700 focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2"><span aria-hidden="true">🗑</span>Delete Recipe</button> <UiButton onClick={() => setDeleteConfirm(true)} className="col-span-2 min-h-[2.25rem] w-full min-w-0 justify-center gap-1.5 border border-[color:var(--color-error-light)] bg-[color:var(--color-error-light)] px-2.5 py-2 text-xs leading-4 text-[color:var(--color-error)] sm:text-sm"><Icon name="trash" className="h-3.5 w-3.5" size={14} /><span className="truncate">Delete</span></UiButton>
) : ( ) : (
<div className="grid grid-cols-2 gap-2 sm:col-span-2 lg:col-span-2"> <div className="col-span-2 grid grid-cols-1 gap-2">
<button <UiButton onClick={handleDelete} disabled={isDeleting} className="min-h-[2.25rem] w-full min-w-0 border border-[color:var(--color-error)] bg-[color:var(--color-error)] px-2.5 py-2 text-xs leading-4 text-white disabled:opacity-60 sm:text-sm">{isDeleting ? 'Deleting...' : 'Confirm delete'}</UiButton>
onClick={handleDelete} <UiButton onClick={() => setDeleteConfirm(false)} disabled={isDeleting} className="min-h-[2.25rem] w-full min-w-0 px-2.5 py-2 text-xs leading-4 sm:text-sm">Cancel</UiButton>
disabled={isDeleting}
className="rounded-lg bg-red-700 px-3 py-2.5 text-sm font-semibold text-white shadow transition-all duration-200 hover:-translate-y-0.5 hover:bg-red-800 focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{isDeleting ? 'Deleting...' : 'Confirm Delete'}
</button>
<button
onClick={() => setDeleteConfirm(false)}
disabled={isDeleting}
className="rounded-lg bg-slate-200 px-3 py-2.5 text-sm font-semibold text-slate-700 transition-all duration-200 hover:-translate-y-0.5 hover:bg-slate-300 focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
>
Cancel
</button>
</div> </div>
)} )}
</div> </div>
</div> </div>
</section> </section>
{statCards.length > 0 && ( <div className="mt-7 grid grid-cols-1 gap-7 min-w-0 lg:grid-cols-[minmax(0,1fr)_300px]">
<section className="mb-6 grid grid-cols-1 gap-4 text-center md:grid-cols-3"> <div className="space-y-7 min-w-0">
{statCards.map((stat) => ( <section className="ui-card overflow-hidden">
<div key={stat.label} className="rounded-xl border border-slate-200/80 bg-white/90 p-4 shadow-card"> <div className="border-b border-[var(--border)] bg-gradient-to-r from-[var(--surface-muted)] to-white px-6 py-4 sm:px-7 md:px-8">
<div className="mb-1 text-2xl" aria-hidden="true">{stat.icon}</div> <h2 className="inline-flex items-center gap-2 text-lg sm:text-xl font-bold text-[var(--text-h)]"><Icon name="list" className="h-3.5 w-3.5 text-[var(--color-primary)]" />Ingredients</h2>
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500">{stat.label}</div> <p className="mt-1 text-sm leading-relaxed text-[var(--text-dim)]">Gather these first so cooking goes smoothly.</p>
<div className="mt-1 text-lg font-bold text-gray-900">{stat.value}</div>
</div> </div>
))} <ul className="m-3 space-y-4 rounded-2xl border border-[var(--border)] bg-white/80 px-4 py-5 sm:m-4 sm:px-6 md:px-7 md:py-6">
</section> {ingredients.length > 0 ? (
)} ingredients.map((ingredient, index) => (
<li
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> key={`${ingredient}-${index}`}
<section className="overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-card"> className="grid min-w-0 grid-cols-[1.85rem_minmax(0,1fr)] items-start gap-x-3.5 rounded-xl border border-[var(--border)] bg-[var(--surface-muted)]/60 px-4 py-3.5 sm:px-5"
<div className="border-b border-slate-100 bg-gradient-to-r from-blue-50 to-white px-6 py-4"> >
<h3 className="text-xl font-semibold text-gray-900">Ingredients</h3> <span className="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--color-primary-light)] text-xs font-semibold text-[var(--color-primary-dark)]">
<p className="text-sm text-gray-500">Everything you need before you start cooking.</p> {index + 1}
</div> </span>
<ul className="space-y-3 px-6 py-5"> <span className="min-w-0 break-words pt-0.5 text-sm leading-6 text-[var(--text)]">{ingredient}</span>
{Array.isArray(recipe.ingredients) ? recipe.ingredients.map((ingredient, index) => (
<li key={index} className="flex items-start gap-3 rounded-lg border border-slate-100 bg-slate-50/70 px-3 py-2.5">
<span className="mt-1 inline-block h-2.5 w-2.5 rounded-full bg-blue-500"></span>
<span className="font-mono text-base text-gray-800">{'item' in ingredient ? ingredient.item : ingredient}</span>
</li> </li>
)) : null} ))
) : (
<li className="rounded-xl border border-dashed border-[var(--border)] bg-white px-4 py-6 text-sm text-[var(--text-dim)] sm:px-5">
No ingredients listed yet.
</li>
)}
</ul> </ul>
</section> </section>
<section className="overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-card"> <section className="ui-card overflow-hidden">
<div className="border-b border-slate-100 bg-gradient-to-r from-orange-50 to-white px-6 py-4"> <div className="border-b border-[var(--border)] bg-gradient-to-r from-[var(--surface-muted)] to-white px-6 py-4 sm:px-7 md:px-8">
<h3 className="text-xl font-semibold text-gray-900">Instructions</h3> <h2 className="inline-flex items-center gap-2 text-lg sm:text-xl font-bold text-[var(--text-h)]"><Icon name="book-open" className="h-3.5 w-3.5 text-[var(--color-primary)]" />Instructions</h2>
<p className="text-sm text-gray-500">Follow these steps for best results.</p> <p className="mt-1 text-sm leading-relaxed text-[var(--text-dim)]">Step-by-step flow for better cooking rhythm.</p>
</div> </div>
<ol className="space-y-3 px-6 py-5"> <ol className="m-3 space-y-4 rounded-2xl border border-[var(--border)] bg-white/80 px-4 py-5 sm:m-4 sm:px-6 md:px-7 md:py-6">
{Array.isArray(recipe.instructions) ? recipe.instructions.map((instruction, index) => ( {instructions.length > 0 ? (
<li key={index} className="flex items-start gap-3 rounded-lg border border-slate-100 bg-slate-50/70 px-3 py-2.5"> instructions.map((instruction, index) => (
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-600 text-sm font-bold text-white">{index + 1}</span> <li key={`${instruction}-${index}`} className="grid min-w-0 grid-cols-[2rem_minmax(0,1fr)] items-start gap-x-3.5 rounded-xl border border-[var(--border)] bg-white px-4 py-4 sm:px-5">
<span className="pt-1 text-base leading-6 text-gray-800">{instruction}</span> <span className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--color-primary)] text-sm font-semibold text-white">
{index + 1}
</span>
<span className="min-w-0 break-words pt-1 text-sm leading-6 text-[var(--text)]">{instruction}</span>
</li> </li>
)) : null} ))
) : (
<li className="rounded-xl border border-dashed border-[var(--border)] bg-white px-4 py-6 text-sm text-[var(--text-dim)] sm:px-5">
No instructions listed yet.
</li>
)}
</ol> </ol>
</section> </section>
</div>
{(recipe.source_url || recipe.notes) && ( {(recipe.source_url || recipe.notes) && (
<section className="mt-8 overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-card"> <section className="ui-card overflow-hidden">
<div className="border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-4"> <div className="border-b border-[var(--border)] bg-gradient-to-r from-[var(--surface-muted)] to-white px-6 py-4 sm:px-7 md:px-8">
<h3 className="text-xl font-semibold text-gray-900">Additional Information</h3> <h3 className="text-lg sm:text-xl font-semibold text-[var(--text-h)]">Additional Information</h3>
</div> </div>
<div className="space-y-5 px-6 py-5"> <div className="m-3 space-y-5 rounded-2xl border border-[var(--border)] bg-white/80 px-4 py-5 sm:m-4 sm:px-6 md:px-7 md:py-6">
{recipe.source_url && ( {recipe.source_url && (
<div> <div className="space-y-2 rounded-xl border border-[var(--border)] bg-[var(--surface-muted)]/40 px-4 py-3.5 sm:px-5">
<div className="mb-1 text-sm font-medium text-gray-700">Source</div> <div className="inline-flex items-center gap-1.5 text-sm font-medium text-[var(--text-dim)]"><Icon name="link" className="h-3 w-3" />Source</div>
<a href={recipe.source_url} target="_blank" rel="noopener noreferrer" className="break-all text-primary underline hover:text-blue-700">{recipe.source_url}</a> <a href={recipe.source_url} target="_blank" rel="noopener noreferrer" className="break-words text-[var(--color-primary)] underline decoration-[1.5px] underline-offset-2 hover:text-[var(--color-primary-dark)]">
{recipe.source_url}
</a>
</div> </div>
)} )}
{recipe.notes && ( {recipe.notes && (
<div> <div className="space-y-2 rounded-xl border border-[var(--border)] bg-[var(--surface-muted)]/40 px-4 py-3.5 sm:px-5">
<div className="mb-1 text-sm font-medium text-gray-700">Notes</div> <div className="inline-flex items-center gap-1.5 text-sm font-medium text-[var(--text-dim)]"><Icon name="note" className="h-3 w-3" />Notes</div>
<p className="whitespace-pre-wrap text-gray-800">{recipe.notes}</p> <p className="max-w-prose whitespace-pre-wrap break-words text-sm leading-relaxed text-[var(--text)] sm:text-base">{recipe.notes}</p>
</div> </div>
)} )}
</div> </div>
</section> </section>
)} )}
</div>
<div className="mt-8 text-center"> <aside className="hidden lg:block">
<Link to="/" className="inline-flex items-center gap-1 font-medium text-primary transition-colors hover:text-blue-700 focus-visible:ring-2 focus-visible:ring-blue-500 rounded-sm"> Back to all recipes</Link> <div className="sticky top-6 space-y-3 rounded-2xl border border-[var(--border)] bg-white/95 p-4 shadow-card">
<p className="text-xs font-semibold uppercase tracking-[0.15em] text-[var(--text-dim)]">Quick Actions</p>
<button onClick={() => setIsEditing(true)} className={desktopActionButtonClass}><Icon name="edit" className="h-3.5 w-3.5" size={15} /><span className="truncate">Edit recipe</span></button>
<Link to={`/recipe/${recipe.id}/cook`} className="ui-btn ui-btn-primary w-full min-h-[2.5rem] min-w-0 justify-start gap-2 px-3 py-2 text-sm leading-4"><Icon name="cook" className="h-3.5 w-3.5" size={15} /><span className="truncate">Open cook mode</span></Link>
<button onClick={handlePrint} className={desktopActionButtonClass}><Icon name="print" className="h-3.5 w-3.5" size={15} /><span className="truncate">Print recipe</span></button>
<button onClick={handleShare} className={desktopActionButtonClass}><Icon name="share" className="h-3.5 w-3.5" size={15} /><span className="truncate">Share recipe</span></button>
{!deleteConfirm ? (
<UiButton onClick={() => setDeleteConfirm(true)} className="w-full min-h-[2.5rem] min-w-0 justify-start gap-2 border border-[color:var(--color-error-light)] bg-[color:var(--color-error-light)] px-3 py-2 text-sm leading-4 text-[color:var(--color-error)]"><Icon name="trash" className="h-3.5 w-3.5" size={15} /><span className="truncate">Delete recipe</span></UiButton>
) : (
<div className="grid grid-cols-2 gap-2">
<UiButton onClick={handleDelete} disabled={isDeleting} className="min-h-[2.5rem] min-w-0 border border-[color:var(--color-error)] bg-[color:var(--color-error)] px-3 py-2 text-sm leading-4 text-white disabled:opacity-60">
{isDeleting ? 'Deleting...' : 'Confirm'}
</UiButton>
<UiButton onClick={() => setDeleteConfirm(false)} disabled={isDeleting} className="min-h-[2.5rem] min-w-0 px-3 py-2 text-sm leading-4">
Cancel
</UiButton>
</div> </div>
)}
<Link to="/" className={desktopActionButtonClass}><Icon name="arrow-left" className="h-3.5 w-3.5" size={15} /><span className="truncate">Back to all recipes</span></Link>
</div> </div>
</aside>
</div>
<div className="mt-8 text-center lg:hidden">
<Link to="/" className="inline-flex items-center gap-1.5 text-sm font-medium text-[var(--color-primary)] transition-colors hover:text-[var(--color-primary-dark)]"><Icon name="arrow-left" className="h-3 w-3" />Back to all recipes</Link>
</div>
</UiPage>
); );
} }

View File

@ -1,13 +1,33 @@
import { useState } from 'react'; import { useEffect, useMemo, useState, useRef } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { heroImageChain, visualAssets } from '../assets/visualAssets'; import { heroImageChain, visualAssets } from '../assets/visualAssets';
import { loadPhotoManifest } from '../assets/photoManifest';
import { useRecipes } from '../hooks/useRecipes'; import { useRecipes } from '../hooks/useRecipes';
import { useTags } from '../hooks/useTags'; import { useTags } from '../hooks/useTags';
import { RecipeCard } from '../components/RecipeCard'; import { RecipeCard } from '../components/RecipeCard';
import { MissionControlPanel } from '../components/MissionControlPanel'; import { MissionControlPanel } from '../components/MissionControlPanel';
import { UiCard, UiSection, UiButton } from '../components/ui/primitives';
import type { HarnessStatus } from '../types/recipe'; import type { HarnessStatus } from '../types/recipe';
import { radius } from '../theme'; import { radius } from '../theme';
type ViewMode = 'grid' | 'list';
interface PhotoManifestCategoryAsset {
localPath?: string | null;
remoteCandidate?: string | null;
}
interface PhotoManifest {
assets?: {
photos?: {
list?: {
default4x3?: string;
};
category?: Record<string, PhotoManifestCategoryAsset>;
};
};
}
const emptyStatus: HarnessStatus = { const emptyStatus: HarnessStatus = {
running: false, running: false,
version: '-', version: '-',
@ -18,37 +38,99 @@ const featureHighlights = [
{ {
title: 'Organize with tags', title: 'Organize with tags',
copy: 'Group your meals by cuisine, prep style, or dietary preference so finding dinner is instant.', copy: 'Group your meals by cuisine, prep style, or dietary preference so finding dinner is instant.',
icon: '/assets/category/icon-dinner.svg', image: 'https://images.pexels.com/photos/1279330/pexels-photo-1279330.jpeg?auto=compress&cs=tinysrgb&w=1200',
fallback: '/assets/food/curated/pasta-plate.svg',
eyebrow: 'Smart organization',
}, },
{ {
title: 'Capture from the web', title: 'Capture from the web',
copy: 'Import recipe links and keep your best discoveries in one place instead of scattered bookmarks.', copy: 'Import recipe links and keep your best discoveries in one place instead of scattered bookmarks.',
icon: '/assets/category/icon-lunch.svg', image: 'https://images.pexels.com/photos/1640774/pexels-photo-1640774.jpeg?auto=compress&cs=tinysrgb&w=1200',
fallback: '/assets/food/curated/soup-bowl.svg',
eyebrow: 'Collect recipes fast',
}, },
{ {
title: 'Cook with confidence', title: 'Cook with confidence',
copy: 'Use clear recipe cards and streamlined actions while planning, prepping, and cooking.', copy: 'Use clear recipe cards and streamlined actions while planning, prepping, and cooking.',
icon: '/assets/category/icon-breakfast.svg', image: 'https://images.pexels.com/photos/70497/pexels-photo-70497.jpeg?auto=compress&cs=tinysrgb&w=1200',
fallback: '/assets/food/curated/breakfast-toast.svg',
eyebrow: 'Ready when you are',
}, },
] as const; ] as const;
function CardSkeleton() {
return (
<UiCard className="overflow-hidden">
<div className="h-48 animate-pulse bg-[var(--surface-muted)]" />
<div className="space-y-3 p-5">
<div className="h-5 w-2/3 animate-pulse rounded bg-[var(--surface-muted)]" />
<div className="h-3 w-full animate-pulse rounded bg-[var(--surface-muted)]" />
<div className="h-3 w-5/6 animate-pulse rounded bg-[var(--surface-muted)]" />
<div className="flex gap-2 pt-2">
<div className="h-6 w-20 animate-pulse rounded-full bg-[var(--surface-muted)]" />
<div className="h-6 w-20 animate-pulse rounded-full bg-[var(--surface-muted)]" />
</div>
</div>
</UiCard>
);
}
export function RecipeListPage() { export function RecipeListPage() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedTagId, setSelectedTagId] = useState<number | null>(null); const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
const [heroCandidateIndex, setHeroCandidateIndex] = useState(0); const [heroCandidateIndex, setHeroCandidateIndex] = useState(0);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [photoManifest, setPhotoManifest] = useState<PhotoManifest | null>(null);
// Infinite scroll sentinel ref
const sentinelRef = useRef<HTMLDivElement>(null);
const heroCandidates = heroImageChain(); const heroCandidates = heroImageChain();
const heroSrc = heroCandidates[Math.min(heroCandidateIndex, heroCandidates.length - 1)]; const heroSrc = heroCandidates[Math.min(heroCandidateIndex, heroCandidates.length - 1)];
useEffect(() => {
let active = true;
loadPhotoManifest<PhotoManifest>()
.then((manifest) => {
if (active) setPhotoManifest(manifest);
})
.catch(() => {
if (active) setPhotoManifest(null);
});
return () => {
active = false;
};
}, []);
const { recipes, loading, error, hasMore, loadMore } = useRecipes({ const { recipes, loading, error, hasMore, loadMore } = useRecipes({
search: searchQuery, search: searchQuery,
limit: 20, limit: 20,
tagId: selectedTagId, tagIds: selectedTagIds,
}); });
const { tags, loading: tagsLoading } = useTags(); const { tags, loading: tagsLoading } = useTags();
// Infinite scroll observer
useEffect(() => {
if (!sentinelRef.current || !hasMore || loading) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadMore();
}
},
{ threshold: 0.1, rootMargin: '100px' }
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [hasMore, loading, loadMore]);
const handleSearch = (e: React.FormEvent) => { const handleSearch = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setSearchQuery(searchTerm); setSearchQuery(searchTerm);
@ -62,47 +144,70 @@ export function RecipeListPage() {
const handleClearFilters = () => { const handleClearFilters = () => {
setSearchTerm(''); setSearchTerm('');
setSearchQuery(''); setSearchQuery('');
setSelectedTagId(null); setSelectedTagIds([]);
}; };
const filteredRecipes = recipes; const handleClearSearchFilter = () => {
const hasActiveFilters = Boolean(searchQuery) || selectedTagId !== null; setSearchTerm('');
setSearchQuery('');
};
const handleToggleTagFilter = (tagId: number) => {
setSelectedTagIds((prev) => (prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId]));
};
const handleClearTagFilter = (tagId: number) => {
setSelectedTagIds((prev) => prev.filter((id) => id !== tagId));
};
const selectedTags = useMemo(
() => tags.filter((tag) => selectedTagIds.includes(tag.id)),
[tags, selectedTagIds],
);
const filteredRecipes = useMemo(() => {
if (selectedTagIds.length === 0) return recipes;
return recipes.filter((recipe) => {
const recipeTagIds = recipe.tags.map((tag) => tag.id);
return selectedTagIds.every((tagId) => recipeTagIds.includes(tagId));
});
}, [recipes, selectedTagIds]);
const hasActiveFilters = Boolean(searchQuery) || selectedTagIds.length > 0;
const allRecipesSelected = selectedTagIds.length === 0;
const recipeCountLabel = `${filteredRecipes.length} recipe${filteredRecipes.length !== 1 ? 's' : ''}`;
const activeViewLabel = viewMode === 'grid' ? 'Grid view active' : 'List view active';
return ( return (
<div className="mx-auto max-w-6xl pb-8"> <div className="ui-page">
<MissionControlPanel status={emptyStatus} /> <MissionControlPanel status={emptyStatus} />
<section <UiSection className="relative mt-6 overflow-hidden p-0" padding="none">
className="relative mt-6 overflow-hidden border border-slate-200/80 bg-white/90 p-0 shadow-card"
style={{ borderRadius: radius.xl }}
>
<div className="grid grid-cols-1 lg:grid-cols-2"> <div className="grid grid-cols-1 lg:grid-cols-2">
<div className="flex flex-col justify-center gap-4 px-6 py-8 md:px-10 md:py-12"> <div className="flex flex-col justify-center gap-4 px-6 py-8 md:px-10 md:py-12">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-500 md:text-sm">Your kitchen companion</p> <p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-primary)] md:text-sm">Your kitchen companion</p>
<h1 className="text-3xl font-extrabold leading-tight text-slate-900 md:text-5xl">Plan meals faster and keep every favorite recipe organized</h1> <h1 className="text-3xl font-extrabold leading-tight text-[var(--text-h)] md:text-5xl">Plan meals faster and keep every favorite recipe organized</h1>
<p className="max-w-lg text-sm text-slate-600 md:text-base"> <p className="max-w-lg text-sm text-[var(--text-dim)] md:text-base">
Build your personal cookbook with better structure, quick tag filters, and easy capture from web links. Build your personal cookbook with better structure, quick tag filters, and easy capture from web links.
</p> </p>
<div className="flex flex-wrap gap-3 pt-1"> <div className="flex flex-wrap gap-3 pt-1">
<Link <Link
to="/recipe/new" to="/recipe/new"
className="inline-flex min-h-11 items-center gap-2 rounded-lg bg-blue-600 px-5 py-2.5 font-semibold text-white shadow transition-all duration-200 hover:-translate-y-0.5 hover:bg-blue-700 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2" className="ui-btn ui-btn-primary inline-flex items-center gap-2 px-5 py-2.5"
style={{ borderRadius: radius.md }} style={{ borderRadius: radius.md }}
> >
<span aria-hidden="true"></span>
Start a Recipe Start a Recipe
</Link> </Link>
<a <a
href="#recipes-grid" href="#recipes-grid"
className="inline-flex min-h-11 items-center gap-2 rounded-lg border border-slate-200 bg-white px-5 py-2.5 font-semibold text-slate-700 transition-all duration-200 hover:-translate-y-0.5 hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2" className="ui-btn ui-btn-secondary inline-flex items-center gap-2 px-5 py-2.5"
style={{ borderRadius: radius.md }} style={{ borderRadius: radius.md }}
> >
<span aria-hidden="true">📚</span>
Browse Library Browse Library
</a> </a>
</div> </div>
</div> </div>
<div className="relative min-h-[260px] bg-slate-100 md:min-h-[320px] lg:min-h-full"> <div className="relative min-h-[260px] bg-[var(--surface-muted)] md:min-h-[320px] lg:min-h-full">
<img <img
src={heroSrc} src={heroSrc}
alt={visualAssets.hero.alt} alt={visualAssets.hero.alt}
@ -113,147 +218,259 @@ export function RecipeListPage() {
} }
}} }}
/> />
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-900/45 via-slate-900/15 to-transparent" /> <div
<div className="absolute bottom-4 left-4 rounded-full bg-white/90 px-4 py-1.5 text-sm font-semibold text-slate-800 shadow"> className="pointer-events-none absolute inset-0"
style={{ background: 'linear-gradient(to top, rgba(28, 25, 23, 0.45), rgba(28, 25, 23, 0.15), transparent)' }}
/>
<div className="absolute bottom-4 left-4 rounded-full bg-white/90 px-4 py-1.5 text-sm font-semibold text-[var(--text)] shadow">
{filteredRecipes.length} saved recipes {filteredRecipes.length} saved recipes
</div> </div>
</div> </div>
</div> </div>
</section> </UiSection>
<section className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-3"> <section className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-3">
{featureHighlights.map((feature) => ( {featureHighlights.map((feature) => (
<article <UiCard
key={feature.title} key={feature.title}
className="rounded-xl border border-slate-200/80 bg-white/85 p-4 shadow-card" className="overflow-hidden p-0"
style={{ borderRadius: radius.lg }}
> >
<img src={feature.icon} alt="" aria-hidden="true" className="h-10 w-10" /> <div className="relative h-36 bg-[var(--surface-muted)]">
<h2 className="mt-3 text-lg font-bold text-slate-900">{feature.title}</h2> <img
<p className="mt-1 text-sm text-slate-600">{feature.copy}</p> src={feature.image}
</article> alt=""
aria-hidden="true"
className="h-full w-full object-cover"
loading="lazy"
onError={(e) => {
e.currentTarget.src = feature.fallback;
}}
/>
<div
className="pointer-events-none absolute inset-0"
style={{ background: 'linear-gradient(to top, rgba(28, 25, 23, 0.5), rgba(28, 25, 23, 0.08), transparent)' }}
/>
<p className="absolute bottom-2 left-3 text-xs font-semibold uppercase tracking-[0.08em] text-white/95">{feature.eyebrow}</p>
</div>
<div className="p-4">
<h2 className="text-lg font-bold text-[var(--text-h)]">{feature.title}</h2>
<p className="mt-1 text-sm text-[var(--text-dim)]">{feature.copy}</p>
</div>
</UiCard>
))} ))}
</section> </section>
<section <UiSection
className="mb-10 mt-7 flex flex-col gap-4 rounded-xl border border-slate-200/80 bg-white/90 px-5 py-6 shadow-card md:px-6" className="mb-8 mt-7 flex flex-col gap-4 px-5 py-6 md:px-6"
style={{ borderRadius: radius.lg, boxShadow: '0 2px 8px 0 rgba(28,30,34,0.07)' }} padding="none"
> >
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-end"> <div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div> <div>
<h2 className="mb-0 text-2xl font-extrabold text-gray-900">Recipe Library</h2> <h2 className="mb-0 text-2xl font-extrabold text-[var(--text-h)]">Recipe Library</h2>
<p className="mt-1 text-sm text-gray-500">Search, filter, and jump back into your most-used meals</p> <p className="mt-1 text-sm text-[var(--text-dim)]">Search, filter, and jump back into your most-used meals</p>
</div>
<div className="self-start md:text-right">
<p className="mb-1 text-xs font-semibold uppercase tracking-[0.12em] text-[var(--text-dim)]">Layout</p>
<div className="flex items-center gap-2 rounded-full border border-[var(--border)] bg-[var(--surface)] p-1">
<button
type="button"
onClick={() => setViewMode('grid')}
aria-pressed={viewMode === 'grid'}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
viewMode === 'grid'
? 'bg-[var(--color-primary)] text-white'
: 'text-[var(--text-dim)] hover:bg-[var(--surface-muted)]'
}`}
>
Grid
</button>
<button
type="button"
onClick={() => setViewMode('list')}
aria-pressed={viewMode === 'list'}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
viewMode === 'list'
? 'bg-[var(--color-primary)] text-white'
: 'text-[var(--text-dim)] hover:bg-[var(--surface-muted)]'
}`}
>
List
</button>
</div>
<p className="mt-1 text-xs text-[var(--text-dim)]">Changes the entire recipe library layout</p>
</div> </div>
</div> </div>
<form onSubmit={handleSearch} className="mt-3 flex flex-col items-stretch gap-3 md:mt-0 md:flex-row"> <form onSubmit={handleSearch} className="mt-2 grid grid-cols-1 gap-3 md:grid-cols-[1fr_auto]">
<div className="relative flex-1"> <label className="relative block" aria-label="Search recipes">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-dim)]" aria-hidden="true">
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</span>
<input <input
type="text" type="text"
placeholder="Search recipes by title, ingredients, or tags..." placeholder="Search title, ingredient, or tag"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-base focus:border-transparent focus:ring-2 focus:ring-blue-500" className="ui-input pl-10 pr-10 text-base"
style={{ borderRadius: radius.md, minHeight: 44 }} style={{ borderRadius: radius.md, minHeight: 46 }}
/> />
{!!searchQuery && ( {(searchTerm || searchQuery) && (
<button <button
type="button" type="button"
onClick={handleClearSearch} onClick={handleClearSearch}
className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-gray-400 transition-colors hover:text-gray-600 focus-visible:ring-2 focus-visible:ring-blue-500" className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1 text-[var(--text-dim)]/70 transition-colors hover:text-[var(--text-dim)] focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]"
aria-label="Clear search" aria-label="Clear search"
> >
×
</button> </button>
)} )}
</div> </label>
<button <UiButton
type="submit" type="submit"
className="min-h-11 rounded-lg border border-gray-200 bg-gray-100 px-6 py-2 font-semibold text-gray-700 transition-all duration-200 hover:-translate-y-0.5 hover:bg-gray-200 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2" className="px-6"
style={{ borderRadius: radius.md }}
> >
Search Search
</button> </UiButton>
</form> </form>
{!tagsLoading && tags.length > 0 && ( {!tagsLoading && tags.length > 0 && (
<div className="mt-0 flex flex-wrap items-center gap-2 md:mt-2"> <div className="mt-1 flex flex-wrap items-center gap-2">
<span className="mr-1 text-sm font-medium text-gray-700">Filter by tag:</span> <span className="mr-1 text-sm font-medium text-[var(--text)]">Filter by tag:</span>
<div className="flex flex-1 flex-wrap gap-2">
<button <button
onClick={() => setSelectedTagId(null)} type="button"
onClick={() => setSelectedTagIds([])}
className={ className={
selectedTagId === null allRecipesSelected
? 'rounded-full bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white shadow outline-none transition-all duration-150 hover:-translate-y-0.5 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2' ? 'ui-chip border-transparent bg-[var(--color-primary)] px-3 py-1.5 text-sm font-semibold text-white'
: 'rounded-full bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 outline-none transition-all duration-150 hover:-translate-y-0.5 hover:bg-gray-200 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2' : 'ui-chip px-3 py-1.5 text-sm font-medium text-[var(--text)] hover:bg-[var(--surface-muted)]'
} }
style={{ borderRadius: radius.full }} style={{ borderRadius: radius.full }}
> >
All Recipes All Recipes
</button> </button>
{tags.map((tag) => ( {tags.map((tag) => {
const isSelected = selectedTagIds.includes(tag.id);
return (
<button <button
type="button"
key={tag.id} key={tag.id}
onClick={() => setSelectedTagId(tag.id)} onClick={() => handleToggleTagFilter(tag.id)}
aria-pressed={isSelected}
className={ className={
selectedTagId === tag.id isSelected
? 'rounded-full px-3 py-1.5 text-sm font-semibold text-white shadow outline-none transition-all duration-150 hover:-translate-y-0.5 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2' ? 'ui-chip border-transparent px-3 py-1.5 text-sm font-semibold text-white shadow-sm ring-2 ring-[var(--color-primary)]/35 ring-offset-1 ring-offset-[var(--surface)]'
: 'rounded-full bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 outline-none transition-all duration-150 hover:-translate-y-0.5 hover:bg-gray-200 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2' : 'ui-chip px-3 py-1.5 text-sm font-medium text-[var(--text)] hover:bg-[var(--surface-muted)]'
} }
style={{ backgroundColor: selectedTagId === tag.id ? tag.color : '', borderRadius: radius.full }} style={{
backgroundColor: isSelected ? tag.color || 'var(--color-primary)' : '',
borderRadius: radius.full,
}}
> >
{tag.name} {isSelected ? `${tag.name}` : tag.name}
</button> </button>
))} );
})}
</div>
</div> </div>
)} )}
{hasActiveFilters && ( {hasActiveFilters && (
<div className="mt-2 flex items-center gap-3 text-sm"> <div className="mt-1 flex flex-wrap items-center gap-2 text-sm">
<span className="text-gray-600">Active filters:</span> <span className="text-[var(--text-dim)]">Active filters:</span>
{searchQuery && <span className="rounded bg-blue-50 px-2 py-1 text-blue-700">Search: "{searchQuery}"</span>} {searchQuery && (
{selectedTagId !== null && ( <span className="ui-badge inline-flex items-center gap-1.5 px-2 py-1">
<span className="rounded bg-blue-50 px-2 py-1 text-blue-700">Tag: {tags.find((t) => t.id === selectedTagId)?.name}</span> <span>Search: "{searchQuery}"</span>
)} <button
<button onClick={handleClearFilters} className="rounded-sm font-medium text-blue-600 transition-colors hover:text-blue-700 focus-visible:ring-2 focus-visible:ring-blue-500"> type="button"
Clear all filters onClick={handleClearSearchFilter}
className="rounded-sm px-1 leading-none text-[var(--text-dim)] transition-colors hover:text-[var(--text)] focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]"
aria-label="Remove search filter"
title="Remove search filter"
>
×
</button> </button>
</span>
)}
{selectedTags.map((tag) => (
<span key={tag.id} className="ui-badge inline-flex items-center gap-1.5 px-2 py-1">
<span>Tag: {tag.name}</span>
<button
type="button"
onClick={() => handleClearTagFilter(tag.id)}
className="rounded-sm px-1 leading-none text-[var(--text-dim)] transition-colors hover:text-[var(--text)] focus-visible:ring-2 focus-visible:ring-[var(--color-primary)]"
aria-label={`Remove ${tag.name} tag filter`}
title={`Remove ${tag.name} tag filter`}
>
×
</button>
</span>
))}
<UiButton
type="button"
onClick={handleClearFilters}
className="min-h-0 rounded-sm px-2 py-1 font-medium text-[var(--color-primary-dark)]"
>
Clear all filters
</UiButton>
</div> </div>
)} )}
</section> </UiSection>
<UiSection
className="mt-3 flex flex-col gap-3 border border-[var(--border)]/70 bg-[var(--surface)] px-4 py-3 md:flex-row md:items-center md:justify-between"
padding="none"
>
<div>
<p className="text-lg font-extrabold text-[var(--text-h)]">{recipeCountLabel}</p>
<p className="text-sm text-[var(--text-dim)]">
{hasActiveFilters ? 'Matching your current search and filters' : 'Available in your recipe library'}
</p>
</div>
<div className="inline-flex items-center gap-2 self-start rounded-full border border-[var(--color-primary)]/30 bg-[var(--color-primary-light)] px-3 py-1.5 text-sm font-semibold text-[var(--color-primary-dark)] md:self-auto">
<span aria-hidden="true">{viewMode === 'grid' ? '▦' : '☰'}</span>
<span>{activeViewLabel}</span>
</div>
</UiSection>
{error && ( {error && (
<div className="my-6 rounded-lg border border-red-200 bg-red-50 p-4 text-center"> <div className="my-6 rounded-lg border border-[var(--color-error)]/25 bg-[var(--color-error-light)] p-4 text-center">
<p className="text-red-800"> <p className="text-[var(--color-error)]">
<strong>Error:</strong> {error} <strong>Error:</strong> {error}
</p> </p>
</div> </div>
)} )}
{loading && recipes.length === 0 && ( {loading && recipes.length === 0 && (
<div className="mx-auto my-8 max-w-4xl rounded-xl border border-slate-200/80 bg-white/80 p-8 text-center shadow-card" style={{ borderRadius: radius.lg }}> <div className="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
<div className="mb-4 text-5xl" aria-hidden="true">🍽</div> {Array.from({ length: 6 }).map((_, idx) => (
<p className="text-lg font-semibold text-slate-700">Warming up your recipe shelf...</p> <CardSkeleton key={idx} />
<div className="mx-auto mt-5 h-2 w-56 overflow-hidden rounded-full bg-slate-200"> ))}
<div className="h-full w-1/2 animate-pulse rounded-full bg-blue-500" />
</div>
</div> </div>
)} )}
{!loading && !error && filteredRecipes.length === 0 && ( {!loading && !error && filteredRecipes.length === 0 && (
<div <div
className="mx-auto flex max-w-xl flex-col items-center gap-2 rounded-xl border border-dashed border-orange-200 bg-gradient-to-br from-white to-orange-50 p-14 text-center shadow-card" className="mx-auto flex max-w-xl flex-col items-center gap-2 rounded-xl border border-dashed border-[var(--color-primary)]/25 bg-gradient-to-br from-[var(--surface)] to-[var(--color-primary-light)] p-14 text-center shadow-card"
style={{ borderRadius: radius.lg }} style={{ borderRadius: radius.lg }}
> >
<img src="/assets/empty-state/no-recipes.svg" alt="" aria-hidden="true" className="mb-2 h-28 w-28" /> <img
<h3 className="mb-2 text-xl font-bold text-gray-800">{searchQuery ? 'No recipes found' : 'No recipes yet'}</h3> src={hasActiveFilters ? '/assets/empty-state/no-results-search.svg' : '/assets/empty-state/no-recipes.svg'}
<p className="mb-4 text-gray-600">{searchQuery ? 'Try another keyword or clear filters.' : 'Start your cookbook with a first delicious recipe.'}</p> alt=""
{!searchQuery && ( aria-hidden="true"
className="mb-2 h-28 w-28"
/>
<h3 className="mb-2 text-xl font-bold text-[var(--text-h)]">{hasActiveFilters ? 'No recipes found' : 'No recipes yet'}</h3>
<p className="mb-4 text-[var(--text-dim)]">{hasActiveFilters ? 'Try another keyword or clear filters.' : 'Start your cookbook with a first delicious recipe.'}</p>
{!hasActiveFilters && (
<Link <Link
to="/recipe/new" to="/recipe/new"
className="inline-flex min-h-11 items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 font-semibold text-white shadow transition-all duration-200 hover:-translate-y-0.5 hover:bg-blue-700 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2" className="ui-btn ui-btn-primary px-6 py-3"
style={{ borderRadius: radius.md }} style={{ borderRadius: radius.md }}
> >
<span aria-hidden="true">🍳</span>
Add Your First Recipe Add Your First Recipe
</Link> </Link>
)} )}
@ -262,24 +479,29 @@ export function RecipeListPage() {
{filteredRecipes.length > 0 && ( {filteredRecipes.length > 0 && (
<> <>
<div id="recipes-grid" className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3"> <div
id="recipes-grid"
className={`mt-5 transition-all ${
viewMode === 'grid'
? 'grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3'
: 'flex flex-col gap-3 rounded-xl border-2 border-[var(--color-primary)]/30 bg-[var(--surface-muted)]/45 p-3 shadow-sm'
}`}
>
{filteredRecipes.map((recipe) => ( {filteredRecipes.map((recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} tags={recipe.tags} /> <RecipeCard key={recipe.id} recipe={recipe} tags={recipe.tags} viewMode={viewMode} photoManifest={photoManifest} />
))} ))}
</div> </div>
{/* Infinite scroll sentinel */}
{hasMore && ( {hasMore && (
<div className="mt-8 text-center"> <div ref={sentinelRef} className="mt-8 text-center">
<button {loading && (
onClick={loadMore} <div className="text-sm text-[var(--text-dim)]">Loading more recipes...</div>
disabled={loading} )}
className="rounded-lg border bg-gray-100 px-6 py-3 font-medium text-gray-700 transition-all duration-200 hover:-translate-y-0.5 hover:bg-gray-200 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
style={{ borderRadius: radius.md }}
>
{loading ? 'Loading...' : 'Load More'}
</button>
</div> </div>
)} )}
<div className="mt-7 text-center text-sm text-gray-500">
<div className="mt-7 text-center text-sm text-[var(--text-dim)]">
Showing {filteredRecipes.length} recipe{filteredRecipes.length !== 1 ? 's' : ''} Showing {filteredRecipes.length} recipe{filteredRecipes.length !== 1 ? 's' : ''}
</div> </div>
</> </>

View File

@ -2,13 +2,36 @@ import type { Recipe, RecipeDraft, Tag, ApiResponse, UrlImportResult, HarnessSta
const API_BASE_URL = '/api'; const API_BASE_URL = '/api';
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
...init,
});
const result: ApiResponse<T> = await response.json();
if (!response.ok || !result.success || result.data === null) {
const message =
typeof result?.error === 'string'
? result.error
: `Request failed (${response.status} ${response.statusText})`;
throw new Error(message);
}
return result.data;
}
export async function fetchRecipes(params?: { export async function fetchRecipes(params?: {
search?: string; search?: string;
offset?: number; offset?: number;
limit?: number; limit?: number;
tagId?: number | null; tagId?: number | null;
tagIds?: number[];
}): Promise<Recipe[]> { }): Promise<Recipe[]> {
const url = new URL(`${API_BASE_URL}/recipes`, window.location.origin); const url = new URL(`${API_BASE_URL}/recipes`, window.location.origin);
if (params?.search) { if (params?.search) {
url.searchParams.set('search', params.search); url.searchParams.set('search', params.search);
} }
@ -18,28 +41,112 @@ export async function fetchRecipes(params?: {
if (params?.limit !== undefined) { if (params?.limit !== undefined) {
url.searchParams.set('limit', params.limit.toString()); url.searchParams.set('limit', params.limit.toString());
} }
if (params?.tagId !== undefined && params?.tagId !== null) {
const normalizedTagIds = (params?.tagIds ?? []).filter((id): id is number => Number.isInteger(id) && id > 0);
if (normalizedTagIds.length > 0) {
url.searchParams.set('tagIds', normalizedTagIds.join(','));
} else if (params?.tagId !== undefined && params?.tagId !== null) {
// Backward compatibility for older callers
url.searchParams.set('tagId', params.tagId.toString()); url.searchParams.set('tagId', params.tagId.toString());
} }
const response = await fetch(url.toString()); const response = await fetch(url.toString());
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch recipes: ${response.statusText}`); throw new Error(`Failed to fetch recipes: ${response.statusText}`);
} }
const result: ApiResponse<Recipe[]> = await response.json(); const result: ApiResponse<Recipe[]> = await response.json();
if (!result.success || !result.data) { if (!result.success || !result.data) {
throw new Error(result.error || 'Failed to fetch recipes'); throw new Error(result.error || 'Failed to fetch recipes');
} }
return result.data; return result.data;
} }
export async function fetchRecipe(id: number): Promise<Recipe> { return {} as any; } export async function fetchRecipe(id: number): Promise<Recipe> {
export async function createRecipe(recipe: RecipeDraft): Promise<Recipe> { return {} as any; } return request<Recipe>(`/recipes/${id}`);
export async function updateRecipe(id: number, updates: Partial<Omit<Recipe, 'id' | 'created_at' | 'updated_at'>>): Promise<Recipe> { return {} as any; } }
export async function deleteRecipe(id: number): Promise<void> {}
export async function fetchTags(): Promise<Tag[]> { return []; } export async function createRecipe(recipe: RecipeDraft): Promise<Recipe> {
export async function createTag(tag: Omit<Tag, 'id'>): Promise<Tag> { return { id: 0, name: '', color: tag.color }; } const steps = recipe.instructions.map((instruction) => ({ instruction }));
export async function fetchRecipeTags(recipeId: number): Promise<Tag[]> { return []; } const payload = {
export async function assignTagToRecipe(recipeId: number, tagId: number): Promise<void> {}; ...recipe,
export async function removeTagFromRecipe(recipeId: number, tagId: number): Promise<void> {}; steps,
export async function importRecipeFromUrl(url: string): Promise<UrlImportResult> { return {title:'',ingredients:[],instructions:[]}; } };
export async function fetchHarnessStatus(): Promise<HarnessStatus> { return {running:false,version:'',uptime:0}; } return request<Recipe>('/recipes', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updateRecipe(
id: number,
updates: Partial<Omit<Recipe, 'id' | 'created_at' | 'updated_at'>>
): Promise<Recipe> {
const payload: Record<string, unknown> = { ...updates };
if (Array.isArray(updates.instructions)) {
payload.steps = updates.instructions.map((instruction) => ({ instruction }));
delete payload.instructions;
}
return request<Recipe>(`/recipes/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function deleteRecipe(id: number): Promise<void> {
await request<boolean>(`/recipes/${id}`, { method: 'DELETE' });
}
export async function fetchTags(): Promise<Tag[]> {
return request<Tag[]>('/tags');
}
export async function createTag(tag: Omit<Tag, 'id'>): Promise<Tag> {
return request<Tag>('/tags', {
method: 'POST',
body: JSON.stringify({ name: tag.name }),
});
}
export async function fetchRecipeTags(recipeId: number): Promise<Tag[]> {
const recipe = await fetchRecipe(recipeId);
return recipe.tags ?? [];
}
export async function assignTagToRecipe(recipeId: number, tagId: number): Promise<void> {
await request<boolean>(`/tags/${recipeId}/assign`, {
method: 'POST',
body: JSON.stringify({ tag_id: tagId }),
});
}
export async function removeTagFromRecipe(recipeId: number, tagId: number): Promise<void> {
await request<boolean>(`/tags/${recipeId}/remove`, {
method: 'POST',
body: JSON.stringify({ tag_id: tagId }),
});
}
export async function importRecipeFromUrl(url: string): Promise<UrlImportResult> {
return request<UrlImportResult>('/import/url', {
method: 'POST',
body: JSON.stringify({ url }),
});
}
export async function fetchHarnessStatus(): Promise<HarnessStatus> {
const data = await request<any>('/harness/status');
return {
running: true,
version: data?.commit?.hash ?? 'unknown',
uptime: 0,
keepalive: data?.keepalive,
commit: data?.commit,
todo: data?.todo,
workerHeartbeatHistory: data?.workerHeartbeatHistory,
};
}

View File

@ -0,0 +1,111 @@
:root {
/* Brand + semantic color tokens */
--color-brand-50: #fff7ed;
--color-brand-100: #ffedd5;
--color-brand-200: #fed7aa;
--color-brand-300: #fdba74;
--color-brand-400: #fb923c;
--color-brand-500: #f97316;
--color-brand-600: #ea580c;
--color-brand-700: #c2410c;
--color-brand-800: #9a3412;
--color-primary: var(--color-brand-600);
--color-primary-dark: var(--color-brand-700);
--color-primary-light: var(--color-brand-100);
--color-accent: #d97706;
--color-success: #15803d;
--color-success-light: #dcfce7;
--color-warning: #b45309;
--color-warning-light: #fef3c7;
--color-error: #b91c1c;
--color-error-light: #fee2e2;
--text: #292524;
--text-h: #1c1917;
--text-dim: #57534e;
--bg: #fffaf5;
--bg-alt: #fef3e8;
--surface: #ffffff;
--surface-muted: #fff8f1;
--border: #efdcca;
--code-bg: #f8eadb;
/* Typography tokens */
--sans: "Inter", "Manrope", system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--heading: "Fraunces", "Inter", Georgia, serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
--font-size-4xl: 2.25rem;
--line-height-tight: 1.2;
--line-height-normal: 1.5;
--line-height-relaxed: 1.65;
/* Spacing + radius tokens */
--space-xxs: 0.25rem;
--space-xs: 0.5rem;
--space-sm: 0.75rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 2.5rem;
--space-3xl: 3rem;
--radius-xs: 0.375rem;
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--radius-xl: 1.25rem;
--radius-2xl: 1.5rem;
--radius-full: 9999px;
/* Elevation + focus tokens */
--shadow-subtle: 0 1px 2px rgba(28, 25, 23, 0.08);
--card-shadow: 0 8px 24px rgba(120, 53, 15, 0.12);
--shadow-hover: 0 14px 32px rgba(120, 53, 15, 0.18);
--focus-ring: 0 0 0 3px rgba(234, 88, 12, 0.26);
--surface-gradient:
radial-gradient(1000px 420px at -10% -10%, rgba(249, 115, 22, 0.14), transparent 58%),
radial-gradient(760px 360px at 110% -8%, rgba(217, 119, 6, 0.1), transparent 52%),
linear-gradient(180deg, #fffaf5 0%, #fef3e8 100%);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #f5f5f4;
--text-h: #fafaf9;
--text-dim: #d6d3d1;
--bg: #1c1917;
--bg-alt: #292524;
--surface: #26211e;
--surface-muted: #322b26;
--border: #4a3d31;
--code-bg: #3f3228;
--shadow-subtle: 0 1px 2px rgba(0, 0, 0, 0.32);
--card-shadow: 0 8px 24px rgba(0, 0, 0, 0.42);
--shadow-hover: 0 14px 32px rgba(0, 0, 0, 0.55);
--surface-gradient:
radial-gradient(900px 380px at 0% -5%, rgba(249, 115, 22, 0.22), transparent 60%),
radial-gradient(760px 360px at 110% -8%, rgba(217, 119, 6, 0.2), transparent 56%),
linear-gradient(180deg, #1c1917 0%, #292524 100%);
}
}

View File

@ -1,56 +1,71 @@
/** /**
* Global design tokens for the Recipe Manager frontend. * Token access layer for Recipe Manager.
* *
* Keep these semantic and reusable so components can evolve without large refactors. * Canonical source of truth: src/styles/tokens.css
* This file intentionally exposes CSS variable references only,
* so TypeScript consumers do not duplicate hardcoded token values.
*/ */
export const tokenVar = (name: `--${string}`) => `var(${name})`;
export const colors = { export const colors = {
primary: '#2563eb', brand50: tokenVar('--color-brand-50'),
primaryDark: '#1d4ed8', brand100: tokenVar('--color-brand-100'),
primaryLight: '#dbeafe', brand200: tokenVar('--color-brand-200'),
accent: '#9333ea', brand300: tokenVar('--color-brand-300'),
brand400: tokenVar('--color-brand-400'),
brand500: tokenVar('--color-brand-500'),
brand600: tokenVar('--color-brand-600'),
brand700: tokenVar('--color-brand-700'),
brand800: tokenVar('--color-brand-800'),
success: '#15803d', primary: tokenVar('--color-primary'),
successLight: '#dcfce7', primaryDark: tokenVar('--color-primary-dark'),
warning: '#ca8a04', primaryLight: tokenVar('--color-primary-light'),
warningLight: '#fef3c7', accent: tokenVar('--color-accent'),
error: '#dc2626',
errorLight: '#fee2e2',
bg: '#f4f7fb', success: tokenVar('--color-success'),
bgAlt: '#edf2f7', successLight: tokenVar('--color-success-light'),
surface: '#ffffff', warning: tokenVar('--color-warning'),
surfaceMuted: '#f8fafc', warningLight: tokenVar('--color-warning-light'),
border: '#dbe3ef', error: tokenVar('--color-error'),
errorLight: tokenVar('--color-error-light'),
text: '#1f2937', bg: tokenVar('--bg'),
textDim: '#64748b', bgAlt: tokenVar('--bg-alt'),
textHeading: '#0f172a', surface: tokenVar('--surface'),
surfaceMuted: tokenVar('--surface-muted'),
border: tokenVar('--border'),
focusRing: '#2563eb', text: tokenVar('--text'),
textDim: tokenVar('--text-dim'),
textHeading: tokenVar('--text-h'),
focusRing: tokenVar('--focus-ring'),
} as const; } as const;
export const typography = { export const typography = {
fontFamily: { fontFamily: {
sans: "Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", sans: tokenVar('--sans'),
heading: "Inter, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", heading: tokenVar('--heading'),
mono: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", mono: tokenVar('--mono'),
}, },
fontSize: { fontSize: {
xs: '0.75rem', xs: tokenVar('--font-size-xs'),
sm: '0.875rem', sm: tokenVar('--font-size-sm'),
base: '1rem', base: tokenVar('--font-size-base'),
lg: '1.125rem', lg: tokenVar('--font-size-lg'),
xl: '1.25rem', xl: tokenVar('--font-size-xl'),
'2xl': '1.5rem', '2xl': tokenVar('--font-size-2xl'),
'3xl': '1.875rem', '3xl': tokenVar('--font-size-3xl'),
'4xl': '2.25rem', '4xl': tokenVar('--font-size-4xl'),
}, },
lineHeight: { lineHeight: {
tight: '1.2', tight: tokenVar('--line-height-tight'),
normal: '1.5', normal: tokenVar('--line-height-normal'),
relaxed: '1.65', relaxed: tokenVar('--line-height-relaxed'),
}, },
// retained for semantic utility in TS-only logic
fontWeight: { fontWeight: {
regular: 400, regular: 400,
medium: 500, medium: 500,
@ -61,33 +76,77 @@ export const typography = {
} as const; } as const;
export const spacing = { export const spacing = {
xxs: '0.25rem', xxs: tokenVar('--space-xxs'),
xs: '0.5rem', xs: tokenVar('--space-xs'),
sm: '0.75rem', sm: tokenVar('--space-sm'),
md: '1rem', md: tokenVar('--space-md'),
lg: '1.5rem', lg: tokenVar('--space-lg'),
xl: '2rem', xl: tokenVar('--space-xl'),
'2xl': '2.5rem', '2xl': tokenVar('--space-2xl'),
'3xl': '3rem', '3xl': tokenVar('--space-3xl'),
} as const; } as const;
export const radius = { export const radius = {
xs: '0.375rem', xs: tokenVar('--radius-xs'),
sm: '0.5rem', sm: tokenVar('--radius-sm'),
md: '0.75rem', md: tokenVar('--radius-md'),
lg: '1rem', lg: tokenVar('--radius-lg'),
xl: '1.25rem', xl: tokenVar('--radius-xl'),
full: '9999px', '2xl': tokenVar('--radius-2xl'),
full: tokenVar('--radius-full'),
} as const; } as const;
export const shadows = { export const shadows = {
subtle: '0 1px 2px rgba(15, 23, 42, 0.06)', subtle: tokenVar('--shadow-subtle'),
card: '0 10px 30px rgba(15, 23, 42, 0.08)', card: tokenVar('--card-shadow'),
hover: '0 14px 34px rgba(15, 23, 42, 0.12)', hover: tokenVar('--shadow-hover'),
focus: '0 0 0 3px rgba(37, 99, 235, 0.25)', focus: tokenVar('--focus-ring'),
} as const; } as const;
export const recipeAccentPalette = ['#f97316', '#ef4444', '#22c55e', '#06b6d4', '#3b82f6', '#a855f7'] as const; /**
* Transitional helper map for existing call sites.
* Values must remain references to CSS vars only.
*/
export const componentStyles = {
button: {
height: '2.75rem',
paddingX: spacing.md,
radius: radius.md,
},
card: {
radius: radius.lg,
border: `1px solid ${colors.border}`,
shadow: shadows.card,
},
input: {
radius: radius.md,
border: `1px solid ${colors.border}`,
minHeight: '2.75rem',
},
chip: {
radius: radius.full,
paddingX: '0.625rem',
paddingY: spacing.xxs,
},
section: {
radius: radius.xl,
border: `1px solid ${colors.border}`,
shadow: shadows.card,
},
page: {
maxWidth: '72rem',
gutter: spacing.md,
},
} as const;
export const recipeAccentPalette = [
tokenVar('--color-brand-500'),
tokenVar('--color-error'),
'#84cc16',
'#f59e0b',
tokenVar('--color-primary'),
'#14b8a6',
] as const;
export const designTokens = { export const designTokens = {
colors, colors,
@ -95,5 +154,6 @@ export const designTokens = {
spacing, spacing,
radius, radius,
shadows, shadows,
componentStyles,
recipeAccentPalette, recipeAccentPalette,
} as const; } as const;

View File

@ -47,8 +47,8 @@ export interface CreateRecipeInput {
prep_time_minutes?: number; prep_time_minutes?: number;
cook_time_minutes?: number; cook_time_minutes?: number;
source_url?: string; source_url?: string;
ingredients: Partial<Omit<Ingredient, "id" | "recipe_id"> & { position?: number }>; ingredients: Partial<Omit<Ingredient, 'id' | 'recipe_id'> & { position?: number }>;
steps: Partial<Omit<Step, "id" | "recipe_id"> & { position?: number }>; steps: Partial<Omit<Step, 'id' | 'recipe_id'> & { position?: number }>;
tagIds?: number[]; tagIds?: number[];
} }
@ -59,8 +59,8 @@ export interface UpdateRecipeInput {
prep_time_minutes?: number | null; prep_time_minutes?: number | null;
cook_time_minutes?: number | null; cook_time_minutes?: number | null;
source_url?: string | null; source_url?: string | null;
ingredients?: Partial<Omit<Ingredient, "id" | "recipe_id"> & { position?: number }>[]; ingredients?: Partial<Omit<Ingredient, 'id' | 'recipe_id'> & { position?: number }>[];
steps?: Partial<Omit<Step, "id" | "recipe_id"> & { position?: number }>[]; steps?: Partial<Omit<Step, 'id' | 'recipe_id'> & { position?: number }>[];
tagIds?: number[]; tagIds?: number[];
} }
@ -69,4 +69,5 @@ export interface RecipeFilters {
offset?: number; offset?: number;
limit?: number; limit?: number;
tagId?: number | null; tagId?: number | null;
tagIds?: number[];
} }

View File

@ -47,7 +47,7 @@ export default {
md: 'var(--radius-md)', md: 'var(--radius-md)',
lg: 'var(--radius-lg)', lg: 'var(--radius-lg)',
xl: 'var(--radius-xl)', xl: 'var(--radius-xl)',
full: '9999px', full: 'var(--radius-full)',
}, },
boxShadow: { boxShadow: {
subtle: 'var(--shadow-subtle)', subtle: 'var(--shadow-subtle)',

View File

@ -11,6 +11,11 @@ export default defineConfig({
target: 'http://localhost:3000', target: 'http://localhost:3000',
changeOrigin: true, changeOrigin: true,
}, },
// Proxy image requests to backend
'/images': {
target: 'http://localhost:3000',
changeOrigin: true,
},
}, },
}, },
}) })

11
import_report.md Normal file
View File

@ -0,0 +1,11 @@
# CopyMeThat Import Report
**Directory Scanned:** projects/recipe-manager/data/exports/Copy_Me_That_HTML_20260328_58775_z1p5lpjsgz
**Target Store:** projects/recipe-manager/recipes_store.jsonl
| Recipes Imported | Errors |
|-----------------|--------|
| 0 | 1 |
## Errors
- `recipes.html`: Missing critical fields

View File

@ -0,0 +1,26 @@
# Migration — 2026-03-27 — add `recipes.image_url`
## Purpose
Add first-class recipe image support so API responses can return a stable `image_url` field for UI rendering.
## Schema change
```sql
ALTER TABLE recipes ADD COLUMN image_url TEXT;
```
## Runtime behavior
- New DBs: `schema.sql` now creates `recipes.image_url` directly.
- Existing DBs: runtime migration helper (`applyRuntimeMigrations`) checks `PRAGMA table_info(recipes)` and adds `image_url` only if missing.
## Updated paths
- `src/backend/db/schema.sql`
- `src/backend/db/schemaMigrations.ts`
- `src/backend/db/database.ts`
- `src/backend/db/migrate.ts`
- `src/backend/db/seed.ts`
- recipe types/routes/repository/parser updates
## Operational note
Run one of:
- `npm run migrate` (safe/idempotent for existing DB)
- `npm run seed` (also applies runtime migration before writing seed data)

View File

@ -0,0 +1,43 @@
# Migration — 2026-03-28 — add user metadata fields
## Purpose
Add user-specific metadata fields to support CopyMeThat import:
- `made` — Boolean flag for "I've cooked this"
- `rating` — 1-5 star rating
- `notes` — General recipe notes/comments
## Schema changes
```sql
ALTER TABLE recipes ADD COLUMN made INTEGER DEFAULT 0; -- SQLite boolean as 0/1
ALTER TABLE recipes ADD COLUMN rating INTEGER; -- 1-5 or NULL
ALTER TABLE recipes ADD COLUMN notes TEXT; -- Freeform text
```
## Field specifications
- **made:** INTEGER (0 = false, 1 = true), NOT NULL, default 0
- **rating:** INTEGER (1-5), NULL allowed (no rating yet)
- **notes:** TEXT, NULL allowed (empty = no notes)
## Runtime behavior
- New DBs: `schema.sql` will be updated to include these fields
- Existing DBs: Runtime migration helper will add columns if missing
## Updated paths
- `src/backend/db/schema.sql`
- `src/backend/db/schemaMigrations.ts`
- `src/backend/types/recipe.ts`
- `src/backend/repositories/RecipeRepository.ts`
- `src/frontend/types/recipe.ts`
## Validation rules
- `made`: Must be 0 or 1
- `rating`: If present, must be 1-5
- `notes`: Max length 10,000 characters (reasonable limit)
## Operational note
Run: `npm run migrate` (idempotent, safe for existing DBs)
---
**Migration date:** 2026-03-28
**Reason:** CopyMeThat import feature support

693
package-lock.json generated
View File

@ -8,7 +8,11 @@
"name": "recipe-manager-backend", "name": "recipe-manager-backend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@types/multer": "^2.1.0",
"dotenv": "^17.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^8.3.1",
"multer": "^2.1.1",
"sql.js": "^1.14.1", "sql.js": "^1.14.1",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
@ -19,6 +23,7 @@
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"supertest": "^6.3.4", "supertest": "^6.3.4",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vitest": "^1.2.3" "vitest": "^1.2.3"
} }
@ -325,6 +330,23 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": { "node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
@ -342,6 +364,23 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": { "node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
@ -359,6 +398,23 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": { "node_modules/@esbuild/sunos-x64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
@ -880,7 +936,6 @@
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/connect": "*", "@types/connect": "*",
@ -891,7 +946,6 @@
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
@ -922,7 +976,6 @@
"version": "4.17.25", "version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/body-parser": "*", "@types/body-parser": "*",
@ -935,7 +988,6 @@
"version": "4.19.8", "version": "4.19.8",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
@ -948,7 +1000,6 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/methods": { "node_modules/@types/methods": {
@ -962,14 +1013,21 @@
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/multer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
"license": "MIT",
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.19.37", "version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
@ -979,21 +1037,18 @@
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/range-parser": { "node_modules/@types/range-parser": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/send": { "node_modules/@types/send": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
@ -1003,7 +1058,6 @@
"version": "1.15.10", "version": "1.15.10",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/http-errors": "*", "@types/http-errors": "*",
@ -1015,7 +1069,6 @@
"version": "0.17.6", "version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/mime": "^1", "@types/mime": "^1",
@ -1183,6 +1236,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/arg": { "node_modules/arg": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
@ -1244,6 +1303,23 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -1347,6 +1423,21 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/confbox": { "node_modules/confbox": {
"version": "0.1.8", "version": "0.1.8",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
@ -1501,6 +1592,18 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
} }
}, },
"node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -1710,6 +1813,24 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/fast-safe-stringify": { "node_modules/fast-safe-stringify": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
@ -1870,6 +1991,19 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/get-tsconfig": {
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@ -1970,6 +2104,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -2165,6 +2308,25 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -2459,6 +2621,30 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.60.0", "version": "4.60.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
@ -2748,6 +2934,23 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-final-newline": { "node_modules/strip-final-newline": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
@ -2930,6 +3133,459 @@
} }
} }
}, },
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/android-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/tsx/node_modules/esbuild": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.4",
"@esbuild/android-arm": "0.27.4",
"@esbuild/android-arm64": "0.27.4",
"@esbuild/android-x64": "0.27.4",
"@esbuild/darwin-arm64": "0.27.4",
"@esbuild/darwin-x64": "0.27.4",
"@esbuild/freebsd-arm64": "0.27.4",
"@esbuild/freebsd-x64": "0.27.4",
"@esbuild/linux-arm": "0.27.4",
"@esbuild/linux-arm64": "0.27.4",
"@esbuild/linux-ia32": "0.27.4",
"@esbuild/linux-loong64": "0.27.4",
"@esbuild/linux-mips64el": "0.27.4",
"@esbuild/linux-ppc64": "0.27.4",
"@esbuild/linux-riscv64": "0.27.4",
"@esbuild/linux-s390x": "0.27.4",
"@esbuild/linux-x64": "0.27.4",
"@esbuild/netbsd-arm64": "0.27.4",
"@esbuild/netbsd-x64": "0.27.4",
"@esbuild/openbsd-arm64": "0.27.4",
"@esbuild/openbsd-x64": "0.27.4",
"@esbuild/openharmony-arm64": "0.27.4",
"@esbuild/sunos-x64": "0.27.4",
"@esbuild/win32-arm64": "0.27.4",
"@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4"
}
},
"node_modules/type-detect": { "node_modules/type-detect": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz",
@ -2953,6 +3609,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -2978,7 +3640,6 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unpipe": { "node_modules/unpipe": {
@ -2990,6 +3651,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",

View File

@ -5,17 +5,22 @@
"type": "module", "type": "module",
"main": "src/backend/index.ts", "main": "src/backend/index.ts",
"scripts": { "scripts": {
"dev": "ts-node src/backend/index.ts", "dev": "tsx src/backend/index.ts",
"build": "tsc", "build": "tsc",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"migrate": "ts-node-esm src/backend/db/migrate.ts", "migrate": "ts-node-esm src/backend/db/migrate.ts",
"seed": "ts-node-esm src/backend/db/seed.ts",
"workflow:run": "ts-node scripts/run-workflow.ts", "workflow:run": "ts-node scripts/run-workflow.ts",
"workflow:schedule": "ts-node scripts/schedule-workflow.ts", "workflow:schedule": "ts-node scripts/schedule-workflow.ts",
"workflow:health-check": "ts-node scripts/check-workflow-health.ts" "workflow:health-check": "ts-node scripts/check-workflow-health.ts"
}, },
"dependencies": { "dependencies": {
"@types/multer": "^2.1.0",
"dotenv": "^17.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^8.3.1",
"multer": "^2.1.1",
"sql.js": "^1.14.1", "sql.js": "^1.14.1",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
@ -26,6 +31,7 @@
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"supertest": "^6.3.4", "supertest": "^6.3.4",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vitest": "^1.2.3" "vitest": "^1.2.3"
} }

0
recipes_store.jsonl Normal file
View File

View File

@ -0,0 +1,86 @@
import os
import glob
from typing import List, Dict
# Parsing logic should be implemented or imported here
def parse_copy_me_that_txt(file_content: str) -> Dict:
"""Stub: parsing logic for CopyMeThat .txt files."""
# TODO: Replace with actual parsing logic
return {}
def parse_copy_me_that_html(file_content: str) -> Dict:
"""Stub: parsing logic for CopyMeThat .html files."""
# TODO: Replace with actual parsing logic
return {}
class RecipeManager:
def __init__(self, store_path):
self.store_path = store_path
if not os.path.exists(self.store_path):
open(self.store_path, 'w').close()
def add_recipe(self, recipe: Dict):
with open(self.store_path, 'a', encoding='utf-8') as f:
f.write(f"{recipe}\n")
class CopyMeThatBulkImporter:
def __init__(self, export_dir: str, store_path: str):
self.export_dir = export_dir
self.manager = RecipeManager(store_path)
self.results = {
"imported": 0,
"errors": []
}
def import_all(self):
file_globs = [
os.path.join(self.export_dir, "*.txt"),
os.path.join(self.export_dir, "*.html")
]
files = []
for g in file_globs:
files.extend(glob.glob(g))
for file_path in files:
try:
with open(file_path, encoding='utf-8') as f:
content = f.read()
if file_path.endswith(".txt"):
recipe = parse_copy_me_that_txt(content)
elif file_path.endswith(".html"):
recipe = parse_copy_me_that_html(content)
else:
raise ValueError("Unsupported file type")
if not recipe or "title" not in recipe:
raise ValueError("Missing critical fields")
self.manager.add_recipe(recipe)
self.results["imported"] += 1
except Exception as e:
self.results["errors"].append({
"file": os.path.basename(file_path),
"error": str(e)
})
return self.results
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--export-dir', required=True, help='Directory with CopyMeThat exports')
parser.add_argument('--output', required=True, help='Recipe manager store path (jsonl)')
parser.add_argument('--report', required=False, help='Output report markdown')
args = parser.parse_args()
importer = CopyMeThatBulkImporter(export_dir=args.export_dir, store_path=args.output)
results = importer.import_all()
print('\nImport Summary:')
print(results)
if args.report:
with open(args.report, 'w', encoding='utf-8') as rf:
rf.write(f"# CopyMeThat Import Report\n\n")
rf.write(f"**Directory Scanned:** {args.export_dir}\n")
rf.write(f"**Target Store:** {args.output}\n\n")
rf.write(f"| Recipes Imported | Errors |\n|-----------------|--------|\n")
rf.write(f"| {results['imported']} | {len(results['errors'])} |\n\n")
if results['errors']:
rf.write("## Errors\n")
for err in results['errors']:
rf.write(f"- `{err['file']}`: {err['error']}\n")
if __name__ == "__main__":
main()

118
scripts/test-import.sh Executable file
View File

@ -0,0 +1,118 @@
#!/bin/bash
#
# Test script for CopyMeThat import functionality
# Usage: ./scripts/test-import.sh
#
set -e
BASE_URL="http://localhost:3000"
EXPORT_HTML="data/exports/Copy_Me_That_HTML_20260328_58775_z1p5lpjsgz/recipes.html"
EXPORT_TXT_DIR="data/exports/Copy_Me_That_TXT_20260328_58775_z1p5lpjsgz"
echo "🧪 Testing CopyMeThat Import Functionality"
echo "=========================================="
echo ""
# Check if server is running
if ! curl -s "$BASE_URL" > /dev/null; then
echo "❌ Error: Backend server is not running at $BASE_URL"
echo " Start it with: npm run dev"
exit 1
fi
echo "✅ Server is running"
echo ""
# Test 1: Import from HTML file
echo "📝 Test 1: Import from HTML file"
echo "--------------------------------"
if [ ! -f "$EXPORT_HTML" ]; then
echo "❌ HTML export file not found: $EXPORT_HTML"
exit 1
fi
echo "Uploading: $EXPORT_HTML"
RESPONSE=$(curl -s -X POST "$BASE_URL/api/import/local" \
-F "files=@$EXPORT_HTML" \
-F "skipDuplicates=true")
echo "Response:"
echo "$RESPONSE" | jq '.'
IMPORTED=$(echo "$RESPONSE" | jq '.data.imported')
SKIPPED=$(echo "$RESPONSE" | jq '.data.skipped')
FAILED=$(echo "$RESPONSE" | jq '.data.failed')
echo ""
echo "📊 Results:"
echo " Imported: $IMPORTED"
echo " Skipped: $SKIPPED"
echo " Failed: $FAILED"
echo ""
if [ "$IMPORTED" -gt 0 ]; then
echo "✅ HTML import successful!"
else
echo "⚠️ Warning: No recipes imported from HTML"
fi
echo ""
echo "---"
echo ""
# Test 2: Import from TXT files (sample of 3 files)
echo "📝 Test 2: Import from TXT files (sample)"
echo "----------------------------------------"
if [ ! -d "$EXPORT_TXT_DIR" ]; then
echo "❌ TXT export directory not found: $EXPORT_TXT_DIR"
exit 1
fi
# Get first 3 .txt files
TXT_FILES=$(find "$EXPORT_TXT_DIR" -name "*.txt" | head -3)
if [ -z "$TXT_FILES" ]; then
echo "❌ No .txt files found in: $EXPORT_TXT_DIR"
exit 1
fi
echo "Uploading 3 sample TXT files..."
# Build curl command with multiple -F flags
CURL_CMD="curl -s -X POST \"$BASE_URL/api/import/local\" -F \"skipDuplicates=true\""
for file in $TXT_FILES; do
CURL_CMD="$CURL_CMD -F \"files=@$file\""
done
RESPONSE=$(eval $CURL_CMD)
echo "Response:"
echo "$RESPONSE" | jq '.'
IMPORTED=$(echo "$RESPONSE" | jq '.data.imported')
SKIPPED=$(echo "$RESPONSE" | jq '.data.skipped')
FAILED=$(echo "$RESPONSE" | jq '.data.failed')
echo ""
echo "📊 Results:"
echo " Imported: $IMPORTED"
echo " Skipped: $SKIPPED"
echo " Failed: $FAILED"
echo ""
if [ "$IMPORTED" -gt 0 ] || [ "$SKIPPED" -gt 0 ]; then
echo "✅ TXT import successful!"
else
echo "⚠️ Warning: No recipes imported from TXT files"
fi
echo ""
echo "=========================================="
echo "🎉 Import tests complete!"
echo ""
echo "To view imported recipes:"
echo " curl $BASE_URL/api/recipes | jq '.data.recipes[] | {id, title, made, rating}'"

View File

@ -0,0 +1,47 @@
// Direct parser test without server
const fs = require('fs');
const path = require('path');
const htmlPath = 'data/exports/Copy_Me_That_HTML_20260328_58775_z1p5lpjsgz/recipes.html';
const html = fs.readFileSync(htmlPath, 'utf-8');
console.log('HTML file size:', html.length, 'bytes');
// Test basic extraction
const recipeRegex = /<div\s+class\s*=\s*["']recipe["'][^>]*>([\s\S]*?)(?=<div\s+class\s*=\s*["']recipe["']|<\/body>|$)/gi;
const matches = [];
let match;
while ((match = recipeRegex.exec(html)) !== null) {
matches.push(match[0]);
}
console.log(`Found ${matches.length} recipe blocks\n`);
if (matches.length > 0) {
const firstRecipe = matches[0];
console.log('First recipe block length:', firstRecipe.length);
// Test title extraction
const titleMatch = /<div\s+id\s*=\s*["']name["'][^>]*>([\s\S]*?)<\/div>/i.exec(firstRecipe);
console.log('Title match:', titleMatch ? titleMatch[1].trim() : 'NO MATCH');
// Test ingredients
const ingRegex = /<li\s+class\s*=\s*["']recipeIngredient["'][^>]*>([\s\S]*?)<\/li>/gi;
const ingredients = [];
let ing;
while ((ing = ingRegex.exec(firstRecipe)) !== null) {
ingredients.push(ing[1].trim());
}
console.log('Ingredients found:', ingredients.length);
if (ingredients.length > 0) console.log('First ingredient:', ingredients[0]);
// Test instructions
const instRegex = /<li\s+class\s*=\s*["']instruction["'][^>]*>([\s\S]*?)<\/li>/gi;
const instructions = [];
let inst;
while ((inst = instRegex.exec(firstRecipe)) !== null) {
instructions.push(inst[1].trim());
}
console.log('Instructions found:', instructions.length);
if (instructions.length > 0) console.log('First instruction:', instructions[0].substring(0, 100));
}

View File

@ -1,6 +1,7 @@
import initSqlJs, { Database } from 'sql.js'; import initSqlJs, { Database } from 'sql.js';
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs'; import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
import { dirname } from 'path'; import { dirname } from 'path';
import { applyRuntimeMigrations } from './schemaMigrations.js';
let dbInstance: Database | null = null; let dbInstance: Database | null = null;
@ -18,6 +19,7 @@ export async function getDatabase(dbPath: string = 'data/recipes.db'): Promise<D
if (existsSync(dbPath)) { if (existsSync(dbPath)) {
const buffer = readFileSync(dbPath); const buffer = readFileSync(dbPath);
dbInstance = new SQL.Database(buffer); dbInstance = new SQL.Database(buffer);
applyRuntimeMigrations(dbInstance);
} else { } else {
// Create new empty database // Create new empty database
dbInstance = new SQL.Database(); dbInstance = new SQL.Database();
@ -26,6 +28,7 @@ export async function getDatabase(dbPath: string = 'data/recipes.db'): Promise<D
const schemaPath = new URL('../db/schema.sql', import.meta.url).pathname; const schemaPath = new URL('../db/schema.sql', import.meta.url).pathname;
const schema = readFileSync(schemaPath, 'utf-8'); const schema = readFileSync(schemaPath, 'utf-8');
dbInstance.exec(schema); dbInstance.exec(schema);
applyRuntimeMigrations(dbInstance);
// Ensure data directory exists // Ensure data directory exists
const dir = dirname(dbPath); const dir = dirname(dbPath);
@ -34,6 +37,9 @@ export async function getDatabase(dbPath: string = 'data/recipes.db'): Promise<D
} }
} }
// Enable foreign key constraints
dbInstance.run('PRAGMA foreign_keys = ON');
return dbInstance; return dbInstance;
} }
@ -58,6 +64,16 @@ export function saveDatabase(dbPath: string = 'data/recipes.db'): void {
writeFileSync(dbPath, buffer); writeFileSync(dbPath, buffer);
} }
/**
* Get the current database instance (must be initialized first)
*/
export function getDatabaseSync(): Database {
if (!dbInstance) {
throw new Error('Database not initialized. Call getDatabase() first.');
}
return dbInstance;
}
/** /**
* Close the database connection * Close the database connection
*/ */

View File

@ -1,6 +1,7 @@
import initSqlJs from 'sql.js'; import initSqlJs from 'sql.js';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { applyRuntimeMigrations } from './schemaMigrations.js';
const DATA_DIR = path.resolve(process.cwd(), 'data'); const DATA_DIR = path.resolve(process.cwd(), 'data');
const DB_PATH = path.join(DATA_DIR, 'recipes.db'); const DB_PATH = path.join(DATA_DIR, 'recipes.db');
@ -18,14 +19,20 @@ async function applyMigrations() {
const schema = fs.readFileSync(SCHEMA_PATH, 'utf8'); const schema = fs.readFileSync(SCHEMA_PATH, 'utf8');
const SQL = await initSqlJs(); const SQL = await initSqlJs();
const db = new SQL.Database(); const db = fs.existsSync(DB_PATH)
? new SQL.Database(fs.readFileSync(DB_PATH))
: new SQL.Database();
// Run all SQL statements const hasRecipesTable = db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='recipes'");
if (!hasRecipesTable.length || !hasRecipesTable[0].values.length) {
db.exec(schema); db.exec(schema);
}
applyRuntimeMigrations(db);
// Export and save to file
const data = db.export(); const data = db.export();
fs.writeFileSync(DB_PATH, Buffer.from(data)); fs.writeFileSync(DB_PATH, Buffer.from(data));
db.close();
console.log(`Database migrated: ${DB_PATH}`); console.log(`Database migrated: ${DB_PATH}`);
} }

View File

@ -1,4 +1,4 @@
-- SCHEMA VERSION: 2026-03-25 — MVP-NORMALIZED -- SCHEMA VERSION: 2026-03-28 — MVP-NORMALIZED (+ user metadata)
-- Recipes table (normalized) -- Recipes table (normalized)
CREATE TABLE recipes ( CREATE TABLE recipes (
@ -9,6 +9,10 @@ CREATE TABLE recipes (
prep_time_minutes INTEGER, prep_time_minutes INTEGER,
cook_time_minutes INTEGER, cook_time_minutes INTEGER,
source_url TEXT, source_url TEXT,
image_url TEXT,
made INTEGER DEFAULT 0,
rating INTEGER,
notes TEXT,
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL updated_at DATETIME NOT NULL
); );

View File

@ -18,4 +18,15 @@ export function applyRuntimeMigrations(db: Database): void {
if (!hasColumn(db, 'recipes', 'image_url')) { if (!hasColumn(db, 'recipes', 'image_url')) {
db.exec('ALTER TABLE recipes ADD COLUMN image_url TEXT'); db.exec('ALTER TABLE recipes ADD COLUMN image_url TEXT');
} }
// 2026-03-28: User metadata fields for CopyMeThat import
if (!hasColumn(db, 'recipes', 'made')) {
db.exec('ALTER TABLE recipes ADD COLUMN made INTEGER DEFAULT 0');
}
if (!hasColumn(db, 'recipes', 'rating')) {
db.exec('ALTER TABLE recipes ADD COLUMN rating INTEGER');
}
if (!hasColumn(db, 'recipes', 'notes')) {
db.exec('ALTER TABLE recipes ADD COLUMN notes TEXT');
}
} }

View File

@ -1,24 +1,59 @@
import express from 'express'; import express from 'express';
import path from 'path';
import dotenv from 'dotenv';
import rateLimit from 'express-rate-limit';
import { getDatabase, saveDatabase } from './db/database.js'; import { getDatabase, saveDatabase } from './db/database.js';
import { createRecipeRoutes } from './routes/recipes.js'; import { createRecipeRoutes } from './routes/recipes.js';
import { createTagRoutes } from './routes/tags.js'; import { createTagRoutes } from './routes/tags.js';
import { createImportRoutes } from './routes/import.js'; import { createImportRoutes } from './routes/import.js';
import { createImportLocalRoutes } from './routes/importLocal.js';
import { createHarnessRoutes } from './routes/harness.js'; import { createHarnessRoutes } from './routes/harness.js';
const app = express(); // Load environment variables
const port = 3000; dotenv.config();
const DB_PATH = 'data/recipes.db';
// Middleware const app = express();
const port = parseInt(process.env.PORT || '3000', 10);
const DB_PATH = process.env.DATABASE_PATH || 'data/recipes.db';
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || '*';
const API_KEY = process.env.API_KEY; // if set, will be required for write methods
// Rate limiter for import endpoints
const importRateLimit = rateLimit({
windowMs: parseInt(process.env.IMPORT_RATE_LIMIT_WINDOW_MS || '60000', 10),
max: parseInt(process.env.IMPORT_RATE_LIMIT_MAX_REQUESTS || '10', 10),
message: { success: false, error: 'Too many import requests, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
// Database dirty tracking for smarter saves
let dbDirty = true; // start dirty to ensure initial save
const originalSave = saveDatabase;
const dirtySave = (dbPath: string = 'data/recipes.db') => {
if (dbDirty) {
originalSave(dbPath);
dbDirty = false;
}
};
// Monkey-patch repositories to set dirty flag on mutations
// (Will patch specific db.run calls later)
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
// CORS headers for local development // Serve static images from data/images directory
app.use('/images', express.static(path.join(process.cwd(), 'data', 'images')));
// CORS headers (configurable)
app.use((req, res, next) => { app.use((req, res, next) => {
const origin = req.headers.origin as string | undefined;
if (ALLOWED_ORIGIN === '*') {
res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Origin', '*');
} else if (origin && origin === ALLOWED_ORIGIN) {
res.header('Access-Control-Allow-Origin', origin);
}
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type'); res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
res.sendStatus(200); res.sendStatus(200);
} else { } else {
@ -26,6 +61,27 @@ app.use((req, res, next) => {
} }
}); });
// API key authentication middleware (only enforce if API_KEY is configured)
const requireApiKeyForWrite: express.RequestHandler = (req, res, next) => {
const safeMethods = ['GET', 'HEAD', 'OPTIONS'];
if (safeMethods.includes(req.method)) {
return next();
}
if (!API_KEY) {
return next(); // no auth configured
}
const key = req.headers['x-api-key'] ?? req.query['api_key'] as string | undefined;
if (!key || key !== API_KEY) {
return res.status(401).json({ success: false, error: 'Invalid or missing API key' });
}
next();
};
// Health check endpoint
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Health check endpoint // Health check endpoint
app.get('/', (req, res) => { app.get('/', (req, res) => {
res.json({ res.json({
@ -40,16 +96,25 @@ async function startServer() {
try { try {
const db = await getDatabase(DB_PATH); const db = await getDatabase(DB_PATH);
// Mount API routes // Patch db.run to set dirty flag on any write
app.use('/api/recipes', createRecipeRoutes(db)); const originalRun = db.run.bind(db);
app.use('/api/tags', createTagRoutes(db)); db.run = (...args: any[]) => {
app.use('/api/import', createImportRoutes()); dbDirty = true;
return originalRun(...args);
};
// Mount API routes (write routes protected by API key if configured)
app.use('/api/recipes', requireApiKeyForWrite, createRecipeRoutes(db));
app.use('/api/tags', requireApiKeyForWrite, createTagRoutes(db));
app.use('/api/import', importRateLimit, requireApiKeyForWrite, createImportRoutes());
app.use('/api/import/local', importRateLimit, requireApiKeyForWrite, createImportLocalRoutes());
// Harness routes are internal; keep unprotected but consider restricting in production
app.use('/api/harness', createHarnessRoutes(process.cwd())); app.use('/api/harness', createHarnessRoutes(process.cwd()));
// Save database periodically (every 5 seconds) // Save database periodically (only if dirty)
setInterval(() => { setInterval(() => {
try { try {
saveDatabase(DB_PATH); dirtySave(DB_PATH);
} catch (error) { } catch (error) {
console.error('Error saving database:', error); console.error('Error saving database:', error);
} }
@ -71,6 +136,7 @@ async function startServer() {
app.listen(port, () => { app.listen(port, () => {
console.log(`✓ Recipe Manager API running on http://localhost:${port}`); console.log(`✓ Recipe Manager API running on http://localhost:${port}`);
console.log(`✓ Database: ${DB_PATH}`); console.log(`✓ Database: ${DB_PATH}`);
console.log(`✓ Static images: http://localhost:${port}/images`);
console.log(`✓ Endpoints:`); console.log(`✓ Endpoints:`);
console.log(` Recipes:`); console.log(` Recipes:`);
console.log(` GET /api/recipes - List recipes`); console.log(` GET /api/recipes - List recipes`);
@ -88,6 +154,7 @@ async function startServer() {
console.log(` DELETE /api/tags/recipes/:id/tags/:id - Remove tag`); console.log(` DELETE /api/tags/recipes/:id/tags/:id - Remove tag`);
console.log(` Import:`); console.log(` Import:`);
console.log(` POST /api/import/url - Import recipe foundation data from URL`); console.log(` POST /api/import/url - Import recipe foundation data from URL`);
console.log(` POST /api/import/local - Import recipes from local files (.html/.txt)`);
console.log(` Harness:`); console.log(` Harness:`);
console.log(` GET /api/harness/status - Mission Control progress/status feed`); console.log(` GET /api/harness/status - Mission Control progress/status feed`);
}); });

View File

@ -9,6 +9,12 @@ export class RecipeRepository {
return value ?? null; return value ?? null;
} }
private toNullableUrl(value: string | null | undefined): string | null {
if (value === undefined || value === null) return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
private toRequiredSqlText(value: string | null | undefined, fieldName: string): string { private toRequiredSqlText(value: string | null | undefined, fieldName: string): string {
if (value === undefined || value === null || value.trim() === '') { if (value === undefined || value === null || value.trim() === '') {
throw new Error(`${fieldName} is required`); throw new Error(`${fieldName} is required`);
@ -52,17 +58,40 @@ export class RecipeRepository {
create(input: CreateRecipeInput): Recipe { create(input: CreateRecipeInput): Recipe {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
let recipeId: number;
// Begin transaction
this.db.run('BEGIN TRANSACTION');
try {
this.db.run( this.db.run(
`INSERT INTO recipes (title, description, servings, prep_time_minutes, cook_time_minutes, source_url, created_at, updated_at) `INSERT INTO recipes (title, description, servings, prep_time_minutes, cook_time_minutes, source_url, image_url, made, rating, notes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[input.title, input.description ?? null, input.servings ?? null, input.prep_time_minutes ?? null, input.cook_time_minutes ?? null, input.source_url ?? null, now, now] [
input.title,
input.description ?? null,
input.servings ?? null,
input.prep_time_minutes ?? null,
input.cook_time_minutes ?? null,
this.toNullableUrl(input.source_url),
this.toNullableUrl(input.image_url),
input.made ? 1 : 0,
input.rating ?? null,
input.notes ?? null,
now,
now
]
); );
const id = this.db.exec('SELECT last_insert_rowid() as id')[0].values[0][0] as number; const idResult = this.db.exec('SELECT last_insert_rowid() as id');
if (!idResult.length || !idResult[0].values.length) {
throw new Error('Failed to get recipe ID');
}
recipeId = idResult[0].values[0][0] as number;
if (input.ingredients) { if (input.ingredients) {
input.ingredients.forEach((ing, i) => { input.ingredients.forEach((ing, i) => {
this.db.run('INSERT INTO ingredients (recipe_id, position, quantity, unit, item, notes) VALUES (?, ?, ?, ?, ?, ?)', this.db.run('INSERT INTO ingredients (recipe_id, position, quantity, unit, item, notes) VALUES (?, ?, ?, ?, ?, ?)',
[ [
id, recipeId,
i, i,
this.toNullableSql(ing.quantity), this.toNullableSql(ing.quantity),
this.toNullableSql(ing.unit), this.toNullableSql(ing.unit),
@ -74,21 +103,34 @@ export class RecipeRepository {
if (input.steps) { if (input.steps) {
input.steps.forEach((step, i) => { input.steps.forEach((step, i) => {
this.db.run('INSERT INTO steps (recipe_id, position, instruction) VALUES (?, ?, ?)', this.db.run('INSERT INTO steps (recipe_id, position, instruction) VALUES (?, ?, ?)',
[id, i, this.toRequiredSqlText(step.instruction, 'step.instruction')]); [recipeId, i, this.toRequiredSqlText(step.instruction, 'step.instruction')]);
}); });
} }
if (input.tagIds && input.tagIds.length > 0) { if (input.tagIds && input.tagIds.length > 0) {
input.tagIds.forEach(tagId => { input.tagIds.forEach(tagId => {
this.db.run('INSERT INTO recipe_tags (recipe_id, tag_id) VALUES (?, ?)', [id, tagId]); this.db.run('INSERT INTO recipe_tags (recipe_id, tag_id) VALUES (?, ?)', [recipeId, tagId]);
}); });
} }
return this.findById(id)!;
// Commit transaction
this.db.run('COMMIT');
} catch (error) {
// Rollback on error
this.db.run('ROLLBACK');
throw error;
}
return this.findById(recipeId)!;
} }
update(id: number, input: UpdateRecipeInput): Recipe | null { update(id: number, input: UpdateRecipeInput): Recipe | null {
const existing = this.findById(id); const existing = this.findById(id);
if (!existing) return null; if (!existing) return null;
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
// Begin transaction
this.db.run('BEGIN TRANSACTION');
try {
const fields: string[] = []; const fields: string[] = [];
const params: SqlValue[] = []; const params: SqlValue[] = [];
if (input.title !== undefined) { fields.push('title = ?'); params.push(input.title); } if (input.title !== undefined) { fields.push('title = ?'); params.push(input.title); }
@ -96,10 +138,15 @@ export class RecipeRepository {
if (input.servings !== undefined) { fields.push('servings = ?'); params.push(input.servings); } if (input.servings !== undefined) { fields.push('servings = ?'); params.push(input.servings); }
if (input.prep_time_minutes !== undefined) { fields.push('prep_time_minutes = ?'); params.push(input.prep_time_minutes); } if (input.prep_time_minutes !== undefined) { fields.push('prep_time_minutes = ?'); params.push(input.prep_time_minutes); }
if (input.cook_time_minutes !== undefined) { fields.push('cook_time_minutes = ?'); params.push(input.cook_time_minutes); } if (input.cook_time_minutes !== undefined) { fields.push('cook_time_minutes = ?'); params.push(input.cook_time_minutes); }
if (input.source_url !== undefined) { fields.push('source_url = ?'); params.push(input.source_url); } if (input.source_url !== undefined) { fields.push('source_url = ?'); params.push(this.toNullableUrl(input.source_url)); }
if (input.image_url !== undefined) { fields.push('image_url = ?'); params.push(this.toNullableUrl(input.image_url)); }
if (input.made !== undefined) { fields.push('made = ?'); params.push(input.made ? 1 : 0); }
if (input.rating !== undefined) { fields.push('rating = ?'); params.push(input.rating); }
if (input.notes !== undefined) { fields.push('notes = ?'); params.push(input.notes); }
fields.push('updated_at = ?'); params.push(now); fields.push('updated_at = ?'); params.push(now);
params.push(id); params.push(id);
this.db.run(`UPDATE recipes SET ${fields.join(', ')} WHERE id = ?`, params); this.db.run(`UPDATE recipes SET ${fields.join(', ')} WHERE id = ?`, params);
if (input.ingredients !== undefined) { if (input.ingredients !== undefined) {
this.db.run('DELETE FROM ingredients WHERE recipe_id = ?', [id]); this.db.run('DELETE FROM ingredients WHERE recipe_id = ?', [id]);
input.ingredients.forEach((ing, i) => { input.ingredients.forEach((ing, i) => {
@ -126,6 +173,14 @@ export class RecipeRepository {
this.db.run('INSERT INTO recipe_tags (recipe_id, tag_id) VALUES (?, ?)', [id, tagId]); this.db.run('INSERT INTO recipe_tags (recipe_id, tag_id) VALUES (?, ?)', [id, tagId]);
}); });
} }
// Commit transaction
this.db.run('COMMIT');
} catch (error) {
this.db.run('ROLLBACK');
throw error;
}
return this.findById(id); return this.findById(id);
} }
@ -193,6 +248,10 @@ export class RecipeRepository {
prep_time_minutes: map.prep_time_minutes as number | null, prep_time_minutes: map.prep_time_minutes as number | null,
cook_time_minutes: map.cook_time_minutes as number | null, cook_time_minutes: map.cook_time_minutes as number | null,
source_url: map.source_url as string | null, source_url: map.source_url as string | null,
image_url: (map.image_url as string | null) ?? null,
made: Boolean(map.made),
rating: map.rating as number | null,
notes: map.notes as string | null,
created_at: map.created_at as number, created_at: map.created_at as number,
updated_at: map.updated_at as number, updated_at: map.updated_at as number,
ingredients, ingredients,

View File

@ -2,12 +2,293 @@ import { Router } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import { parseSchemaOrgRecipe } from '../services/SchemaOrgRecipeParserService.js'; import { parseSchemaOrgRecipe } from '../services/SchemaOrgRecipeParserService.js';
import { parseHeuristicRecipe } from '../services/HeuristicRecipeParserService.js'; import { parseHeuristicRecipe } from '../services/HeuristicRecipeParserService.js';
import { UrlImportError, UrlImportService } from '../services/UrlImportService.js';
import type { CreateRecipeInput } from '../types/recipe.js';
export function createImportRoutes() { const importUrlSchema = z.object({
const router = Router(); url: z.string().url('Please provide a valid URL (including https://).'),
// Example: just for build fix; replace with actual logic as needed
router.post('/url', (req, res) => {
res.json({ success: true, data: { draft_recipe: null }});
}); });
interface ImportRouteDraftRecipe {
title: string;
description?: string;
servings?: number;
prep_time_minutes?: number;
cook_time_minutes?: number;
source_url?: string;
image_url?: string;
ingredients: { item: string; quantity?: string | null; unit?: string | null; notes?: string | null }[];
instructions: string[];
tagIds?: number[];
}
interface ImportRouteResult {
title: string;
source_url: string;
json_ld_blocks: unknown[];
draft_recipe: ImportRouteDraftRecipe;
ingredients: string[];
instructions: string[];
parse: {
schema_org_used: boolean;
heuristic_used: boolean;
warnings: string[];
};
}
export function createImportRoutes(urlImportService = new UrlImportService()) {
const router = Router();
router.post('/url', async (req, res) => {
try {
const { url } = importUrlSchema.parse(req.body);
const fetched = await urlImportService.fetchFromUrl(url);
const parseWarnings: string[] = [];
const parsedJsonLdBlocks = parseJsonLdBlocks(fetched.json_ld_blocks, parseWarnings);
const schemaCandidate = findSchemaOrgRecipeCandidate(parsedJsonLdBlocks);
const schemaDraft = schemaCandidate ? toImportDraftSafe(parseSchemaOrgRecipe(schemaCandidate), fetched.source_url) : null;
const heuristicDraft = schemaDraft
? null
: toHeuristicImportDraft(fetched.html, fetched.source_url);
const draft = schemaDraft ?? heuristicDraft;
if (!draft) {
res.status(422).json({
success: false,
data: null,
error: 'Parse failed: Could not extract a usable recipe from this page.',
});
return;
}
const response: ImportRouteResult = {
title: draft.title,
source_url: fetched.source_url,
json_ld_blocks: parsedJsonLdBlocks,
draft_recipe: draft,
ingredients: draft.ingredients.map((item) => item.item),
instructions: draft.instructions,
parse: {
schema_org_used: Boolean(schemaDraft),
heuristic_used: Boolean(!schemaDraft && heuristicDraft),
warnings: parseWarnings,
},
};
res.json({ success: true, data: response, error: null });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({ success: false, data: null, error: error.errors[0]?.message ?? 'Invalid request payload' });
return;
}
if (error instanceof UrlImportError) {
const mapped = mapUrlImportError(error);
res.status(mapped.status).json({ success: false, data: null, error: mapped.message });
return;
}
if (error instanceof Error) {
res.status(500).json({ success: false, data: null, error: error.message });
return;
}
res.status(500).json({ success: false, data: null, error: 'Internal server error' });
}
});
return router; return router;
} }
function mapUrlImportError(error: UrlImportError): { status: number; message: string } {
switch (error.code) {
case 'IMPORT_TIMEOUT':
return { status: 504, message: error.message };
case 'IMPORT_NETWORK':
return { status: 502, message: error.message };
case 'IMPORT_UNSUPPORTED_CONTENT':
return { status: 415, message: error.message };
case 'IMPORT_FETCH_FAILED':
default:
return { status: error.status && error.status >= 400 ? error.status : 502, message: error.message };
}
}
function parseJsonLdBlocks(blocks: string[], warnings: string[]): unknown[] {
const parsed: unknown[] = [];
for (const raw of blocks) {
try {
const value = JSON.parse(raw) as unknown;
parsed.push(value);
} catch {
warnings.push('Skipped malformed JSON-LD block.');
}
}
return parsed;
}
function findSchemaOrgRecipeCandidate(blocks: unknown[]): Record<string, unknown> | null {
const candidates: Record<string, unknown>[] = [];
for (const block of blocks) {
collectRecipeCandidates(block, candidates);
}
if (candidates.length === 0) {
return null;
}
return candidates.find((candidate) => typeof candidate.name === 'string') ?? candidates[0];
}
function collectRecipeCandidates(value: unknown, sink: Record<string, unknown>[]): void {
if (!value) return;
if (Array.isArray(value)) {
for (const item of value) {
collectRecipeCandidates(item, sink);
}
return;
}
if (typeof value !== 'object') {
return;
}
const obj = value as Record<string, unknown>;
if (isRecipeType(obj['@type'])) {
sink.push(obj);
}
if ('@graph' in obj) {
collectRecipeCandidates(obj['@graph'], sink);
}
for (const nested of Object.values(obj)) {
if (nested && typeof nested === 'object') {
collectRecipeCandidates(nested, sink);
}
}
}
function isRecipeType(typeValue: unknown): boolean {
if (typeof typeValue === 'string') {
return typeValue.toLowerCase().includes('recipe');
}
if (Array.isArray(typeValue)) {
return typeValue.some((value) => typeof value === 'string' && value.toLowerCase().includes('recipe'));
}
return false;
}
function toImportDraftSafe(parsed: CreateRecipeInput, sourceUrl: string): ImportRouteDraftRecipe | null {
const title = parsed.title?.trim();
const ingredients = Array.isArray(parsed.ingredients)
? parsed.ingredients
.map((ingredient) => ({
item: typeof ingredient.item === 'string' ? ingredient.item.trim() : '',
quantity: typeof ingredient.quantity === 'string' ? ingredient.quantity : null,
unit: typeof ingredient.unit === 'string' ? ingredient.unit : null,
notes: typeof ingredient.notes === 'string' ? ingredient.notes : null,
}))
.filter((ingredient) => ingredient.item.length > 0)
: [];
const instructions = Array.isArray(parsed.steps)
? parsed.steps
.map((step) => (typeof step.instruction === 'string' ? step.instruction.trim() : ''))
.filter((step) => step.length > 0)
: [];
if (!title || ingredients.length === 0 || instructions.length === 0) {
return null;
}
return {
title,
description: parsed.description,
servings: parsed.servings,
prep_time_minutes: parsed.prep_time_minutes,
cook_time_minutes: parsed.cook_time_minutes,
source_url: parsed.source_url || sourceUrl,
image_url: parsed.image_url,
ingredients,
instructions,
tagIds: parsed.tagIds,
};
}
function toHeuristicImportDraft(html: string, sourceUrl: string): ImportRouteDraftRecipe | null {
const title = extractTitle(html) || 'Imported Recipe';
const ingredients = extractListItems(html, ['ingredient']);
const instructions = extractListItems(html, ['instruction', 'direction', 'method', 'step']);
const createInput = parseHeuristicRecipe({
title,
ingredients,
steps: instructions,
source_url: sourceUrl,
});
return toImportDraftSafe(createInput, sourceUrl);
}
function extractTitle(html: string): string | null {
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (!titleMatch || !titleMatch[1]) {
return null;
}
return normalizeText(titleMatch[1]);
}
function extractListItems(html: string, headingKeywords: string[]): string[] {
const sectionPattern = new RegExp(
`<(?:h2|h3|h4)[^>]*>([\\s\\S]*?)<\\/(?:h2|h3|h4)>[\\s\\S]*?<ul[^>]*>([\\s\\S]*?)<\\/ul>`,
'gi',
);
const items: string[] = [];
let match = sectionPattern.exec(html);
while (match) {
const headingText = normalizeText(match[1]);
if (headingKeywords.some((keyword) => headingText.toLowerCase().includes(keyword))) {
const listHtml = match[2] ?? '';
const liPattern = /<li[^>]*>([\s\S]*?)<\/li>/gi;
let liMatch = liPattern.exec(listHtml);
while (liMatch) {
const text = normalizeText(liMatch[1] ?? '');
if (text) {
items.push(text);
}
liMatch = liPattern.exec(listHtml);
}
}
match = sectionPattern.exec(html);
}
return dedupe(items);
}
function normalizeText(text: string): string {
return text
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/\s+/g, ' ')
.trim();
}
function dedupe(values: string[]): string[] {
return [...new Set(values)];
}

View File

@ -0,0 +1,152 @@
import { Router, type Request, type Response } from 'express';
import multer from 'multer';
import { z } from 'zod';
import { CopyMeThatImportService } from '../services/CopyMeThatImportService.js';
import { getDatabaseSync } from '../db/database.js';
// Configure multer for file uploads (memory storage)
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 50 * 1024 * 1024, // 50MB max per file
files: 200, // Max 200 files at once
},
fileFilter: (req, file, cb) => {
const allowedTypes = ['.html', '.txt'];
const ext = file.originalname.toLowerCase().slice(file.originalname.lastIndexOf('.'));
if (allowedTypes.includes(ext)) {
cb(null, true);
} else {
cb(new Error(`Invalid file type: ${ext}. Only .html and .txt files are allowed.`));
}
},
});
const importOptionsSchema = z.object({
skipDuplicates: z.boolean().optional().default(true),
importImages: z.boolean().optional().default(false),
});
export function createImportLocalRoutes() {
const router = Router();
/**
* POST /api/recipes/import/local
* Import recipes from local CopyMeThat export files (.html or .txt)
*/
router.post('/local', upload.array('files', 200), async (req: Request, res: Response) => {
try {
const files = req.files as Express.Multer.File[] | undefined;
if (!files || files.length === 0) {
res.status(400).json({
success: false,
data: null,
error: 'No files uploaded. Please upload at least one .html or .txt file.',
});
return;
}
// Parse options from request body (sent as form data)
const options = importOptionsSchema.parse({
skipDuplicates: req.body.skipDuplicates === 'true' || req.body.skipDuplicates === true,
importImages: req.body.importImages === 'true' || req.body.importImages === true,
});
const db = getDatabaseSync();
const importService = new CopyMeThatImportService(db);
// Separate HTML and TXT files
const htmlFiles = files.filter(f => f.originalname.toLowerCase().endsWith('.html'));
const txtFiles = files.filter(f => f.originalname.toLowerCase().endsWith('.txt'));
let combinedResult = {
success: true,
imported: 0,
skipped: 0,
failed: 0,
recipes: [] as any[],
errors: [] as string[],
};
// Process HTML files (priority - richer data)
if (htmlFiles.length > 0) {
for (const file of htmlFiles) {
const html = file.buffer.toString('utf-8');
const result = await importService.importFromHtml(html, options);
combinedResult.imported += result.imported;
combinedResult.skipped += result.skipped;
combinedResult.failed += result.failed;
combinedResult.recipes.push(...result.recipes);
combinedResult.errors.push(...result.errors);
if (!result.success) {
combinedResult.success = false;
}
}
}
// Process TXT files
if (txtFiles.length > 0) {
const txtContents = txtFiles.map(f => ({
filename: f.originalname,
content: f.buffer.toString('utf-8'),
}));
const result = await importService.importFromTxtFiles(txtContents, options);
combinedResult.imported += result.imported;
combinedResult.skipped += result.skipped;
combinedResult.failed += result.failed;
combinedResult.recipes.push(...result.recipes);
combinedResult.errors.push(...result.errors);
if (!result.success) {
combinedResult.success = false;
}
}
// Return summary
res.json({
success: combinedResult.success,
data: {
imported: combinedResult.imported,
skipped: combinedResult.skipped,
failed: combinedResult.failed,
total: files.length,
recipes: combinedResult.recipes.slice(0, 10), // Preview first 10 recipes
errors: combinedResult.errors,
},
error: combinedResult.errors.length > 0 ? `${combinedResult.failed} recipes failed to import` : null,
});
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
success: false,
data: null,
error: error.errors[0]?.message ?? 'Invalid options',
});
return;
}
if (error instanceof Error) {
res.status(500).json({
success: false,
data: null,
error: error.message,
});
return;
}
res.status(500).json({
success: false,
data: null,
error: 'Internal server error during import',
});
}
});
return router;
}

View File

@ -10,6 +10,7 @@ const createRecipeSchema = z.object({
prep_time_minutes: z.number().int().positive().optional(), prep_time_minutes: z.number().int().positive().optional(),
cook_time_minutes: z.number().int().positive().optional(), cook_time_minutes: z.number().int().positive().optional(),
source_url: z.string().url().optional().or(z.literal('')), source_url: z.string().url().optional().or(z.literal('')),
image_url: z.string().url().optional().or(z.literal('')),
ingredients: z.array(z.object({ ingredients: z.array(z.object({
quantity: z.string().optional(), quantity: z.string().optional(),
unit: z.string().optional(), unit: z.string().optional(),
@ -29,6 +30,7 @@ const updateRecipeSchema = z.object({
prep_time_minutes: z.number().int().positive().optional().nullable(), prep_time_minutes: z.number().int().positive().optional().nullable(),
cook_time_minutes: z.number().int().positive().optional().nullable(), cook_time_minutes: z.number().int().positive().optional().nullable(),
source_url: z.string().url().optional().nullable().or(z.literal('')), source_url: z.string().url().optional().nullable().or(z.literal('')),
image_url: z.string().url().optional().nullable().or(z.literal('')),
ingredients: z.array(z.object({ ingredients: z.array(z.object({
quantity: z.string().optional(), quantity: z.string().optional(),
unit: z.string().optional(), unit: z.string().optional(),
@ -44,6 +46,24 @@ const recipeFiltersSchema = z.object({
offset: z.coerce.number().int().nonnegative().optional(), offset: z.coerce.number().int().nonnegative().optional(),
limit: z.coerce.number().int().positive().max(100).optional(), limit: z.coerce.number().int().positive().max(100).optional(),
tagId: z.coerce.number().int().positive().optional(), tagId: z.coerce.number().int().positive().optional(),
tagIds: z.preprocess(
(value) => {
if (typeof value === 'string') {
if (!value.trim()) return [];
return value
.split(',')
.map((part) => Number(part.trim()))
.filter((num) => Number.isInteger(num) && num > 0);
}
if (Array.isArray(value)) {
return value
.map((part) => Number(part))
.filter((num) => Number.isInteger(num) && num > 0);
}
return undefined;
},
z.array(z.number().int().positive()).optional(),
),
}); });
export function createRecipeRoutes(db: Database): Router { export function createRecipeRoutes(db: Database): Router {
@ -52,7 +72,18 @@ export function createRecipeRoutes(db: Database): Router {
router.get('/', (req, res) => { router.get('/', (req, res) => {
try { try {
const filters = recipeFiltersSchema.parse(req.query); const parsedFilters = recipeFiltersSchema.parse(req.query);
const normalizedTagIds = parsedFilters.tagIds && parsedFilters.tagIds.length > 0
? parsedFilters.tagIds
: parsedFilters.tagId
? [parsedFilters.tagId]
: undefined;
const filters = {
...parsedFilters,
tagIds: normalizedTagIds,
};
const result = recipeService.list(filters); const result = recipeService.list(filters);
res.json({ res.json({
success: true, success: true,

View File

@ -108,13 +108,13 @@ export function createTagRoutes(db: Database): Router {
// Tag <-> Recipe assignment/removal // Tag <-> Recipe assignment/removal
router.post('/:id/assign', (req, res) => { router.post('/:id/assign', (req, res) => {
try { try {
const id = parseInt(req.params.id, 10); const recipeId = parseInt(req.params.id, 10);
if (isNaN(id)) { if (isNaN(recipeId)) {
res.status(400).json({ success: false, data: null, error: 'Invalid tag ID' }); res.status(400).json({ success: false, data: null, error: 'Invalid recipe ID' });
return; return;
} }
const data = assignTagSchema.parse(req.body); const data = assignTagSchema.parse(req.body);
const ok = tagService.assignToRecipe(data.tag_id, id); const ok = tagService.assignToRecipe(recipeId, data.tag_id);
res.json({ success: ok, data: ok, error: ok ? null : 'Assignment failed' }); res.json({ success: ok, data: ok, error: ok ? null : 'Assignment failed' });
} catch (error) { } catch (error) {
res.status(400).json({ success: false, data: null, error: 'Invalid request' }); res.status(400).json({ success: false, data: null, error: 'Invalid request' });
@ -123,13 +123,13 @@ export function createTagRoutes(db: Database): Router {
router.post('/:id/remove', (req, res) => { router.post('/:id/remove', (req, res) => {
try { try {
const id = parseInt(req.params.id, 10); const recipeId = parseInt(req.params.id, 10);
if (isNaN(id)) { if (isNaN(recipeId)) {
res.status(400).json({ success: false, data: null, error: 'Invalid tag ID' }); res.status(400).json({ success: false, data: null, error: 'Invalid recipe ID' });
return; return;
} }
const data = assignTagSchema.parse(req.body); const data = assignTagSchema.parse(req.body);
const ok = tagService.removeFromRecipe(data.tag_id, id); const ok = tagService.removeFromRecipe(recipeId, data.tag_id);
res.json({ success: ok, data: ok, error: ok ? null : 'Remove failed' }); res.json({ success: ok, data: ok, error: ok ? null : 'Remove failed' });
} catch (error) { } catch (error) {
res.status(400).json({ success: false, data: null, error: 'Invalid request' }); res.status(400).json({ success: false, data: null, error: 'Invalid request' });

View File

@ -0,0 +1,248 @@
import type { CreateRecipeInput } from '../types/recipe.js';
export interface ParsedCopyMeThatRecipe {
title: string;
sourceUrl?: string;
description?: string;
imageUrl?: string;
tags: string[];
made: boolean;
rating?: number;
servings?: string;
ingredients: string[];
instructions: string[];
notes?: string;
}
/**
* Parses CopyMeThat HTML export format.
* Supports both single-recipe HTML and multi-recipe exports.
*/
export class CopyMeThatHtmlParser {
/**
* Parse all recipes from a CopyMeThat HTML export file.
*/
parseRecipes(html: string): ParsedCopyMeThatRecipe[] {
const recipeBlocks = this.extractRecipeBlocks(html);
console.log(`[CopyMeThatHtmlParser] Found ${recipeBlocks.length} recipe blocks`);
const parsed = recipeBlocks.map(block => this.parseRecipeBlock(block)).filter(r => r !== null) as ParsedCopyMeThatRecipe[];
console.log(`[CopyMeThatHtmlParser] Successfully parsed ${parsed.length} recipes`);
return parsed;
}
/**
* Extract individual recipe HTML blocks from the document.
*/
private extractRecipeBlocks(html: string): string[] {
const blocks: string[] = [];
// Match with flexible whitespace around = and quotes
const recipeRegex = /<div\s+class\s*=\s*["']recipe["'][^>]*>([\s\S]*?)(?=<div\s+class\s*=\s*["']recipe["']|<\/body>|$)/gi;
let match;
while ((match = recipeRegex.exec(html)) !== null) {
blocks.push(match[0]);
}
return blocks;
}
/**
* Parse a single recipe HTML block.
*/
private parseRecipeBlock(html: string): ParsedCopyMeThatRecipe | null {
try {
const title = this.extractById(html, 'name');
const sourceUrl = this.extractLinkById(html, 'original_link');
const description = this.extractById(html, 'description');
const imageUrl = this.extractAttr(html, /<img\s+class\s*=\s*["']recipeImage["'][^>]*src\s*=\s*["']([^"']+)["']/i);
const tags = this.extractTags(html);
const made = html.includes('id="made_this"') || html.includes("id = \"made_this\"") || html.includes('I made this');
const rating = this.extractRating(html);
const servings = this.extractText(html, /<a\s+id\s*=\s*["']recipeYield["'][^>]*>([^<]+)<\/a>/i);
const ingredients = this.extractListItems(html, 'recipeIngredient');
const instructions = this.extractListItems(html, 'instruction');
const notes = this.extractNotes(html);
if (!title || ingredients.length === 0 || instructions.length === 0) {
console.log(`[Parser] Rejected recipe - title: ${!!title}, ingredients: ${ingredients.length}, instructions: ${instructions.length}`);
return null; // Invalid recipe
}
return {
title: this.cleanText(title),
sourceUrl: sourceUrl || undefined,
description: description ? this.cleanText(description) : undefined,
imageUrl: imageUrl || undefined,
tags,
made,
rating,
servings: servings ? this.cleanText(servings) : undefined,
ingredients: ingredients.map(i => this.cleanText(i)),
instructions: instructions.map(i => this.cleanText(i)),
notes: notes ? this.cleanText(notes) : undefined,
};
} catch (error) {
console.error('Error parsing recipe block:', error);
return null;
}
}
/**
* Extract text content from a regex match.
*/
private extractText(html: string, regex: RegExp): string | null {
const match = regex.exec(html);
return match ? match[1] : null;
}
/**
* Extract attribute value from a regex match.
*/
private extractAttr(html: string, regex: RegExp): string | null {
const match = regex.exec(html);
return match ? match[1] : null;
}
/**
* Extract text from an element by id (handles spaces around =).
* Only captures immediate text content, not nested elements.
*/
private extractById(html: string, id: string): string | null {
const regex = new RegExp(`<div\\s+id\\s*=\\s*["']${id}["'][^>]*>\\s*([^<]+)`, 'i');
const match = regex.exec(html);
return match ? match[1].trim() : null;
}
/**
* Extract href from a link by id (handles spaces around =).
*/
private extractLinkById(html: string, id: string): string | null {
const regex = new RegExp(`<a\\s+id\\s*=\\s*["']${id}["'][^>]*href\\s*=\\s*["']([^"']+)["']`, 'i');
return this.extractAttr(html, regex);
}
/**
* Extract tags from categories section.
*/
private extractTags(html: string): string[] {
const tags: string[] = [];
const tagRegex = /<span\s+class\s*=\s*["']recipeCategory["'][^>]*>([^<]+)<\/span>/gi;
let match;
while ((match = tagRegex.exec(html)) !== null) {
const tag = this.cleanText(match[1]);
if (tag) tags.push(tag);
}
return tags;
}
/**
* Extract rating value (1-5).
*/
private extractRating(html: string): number | undefined {
const ratingMatch = /<span\s+id\s*=\s*["']ratingValue["'][^>]*>(\d+)<\/span>/i.exec(html);
if (ratingMatch) {
const rating = parseInt(ratingMatch[1], 10);
return (rating >= 1 && rating <= 5) ? rating : undefined;
}
return undefined;
}
/**
* Extract list items (ingredients or instructions).
*/
private extractListItems(html: string, className: string): string[] {
const items: string[] = [];
// More flexible regex to handle spaces around =
const itemRegex = new RegExp(`<li\\s+class\\s*=\\s*["']${className}["'][^>]*>([\\s\\S]*?)<\\/li>`, 'gi');
let match;
while ((match = itemRegex.exec(html)) !== null) {
const text = this.cleanText(match[1]);
if (text) items.push(text);
}
return items;
}
/**
* Extract recipe notes.
*/
private extractNotes(html: string): string | null {
const notesMatch = /<div\s+id\s*=\s*["']recipeNotes["'][^>]*>([\s\S]*?)<\/div>/i.exec(html);
if (!notesMatch) return null;
const notesHtml = notesMatch[1];
const noteTexts: string[] = [];
const noteRegex = /<div\s+class\s*=\s*["']recipeNote["'][^>]*>([\\s\\S]*?)<\/div>/gi;
let match;
while ((match = noteRegex.exec(notesHtml)) !== null) {
const note = this.cleanText(match[1]);
if (note) noteTexts.push(note);
}
return noteTexts.length > 0 ? noteTexts.join('\n\n') : null;
}
/**
* Clean HTML entities and extra whitespace from text.
*/
private cleanText(text: string): string {
return text
.replace(/<[^>]+>/g, '') // Remove HTML tags
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
}
/**
* Convert parsed recipe to CreateRecipeInput format.
*/
toCreateRecipeInput(parsed: ParsedCopyMeThatRecipe): CreateRecipeInput {
// Normalize image URL: if relative path (images/...), convert to absolute /images/...
let imageUrl = parsed.imageUrl;
if (imageUrl) {
if (imageUrl.startsWith('images/')) {
imageUrl = '/' + imageUrl;
} else if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) {
// Other relative paths or invalid URLs — discard
imageUrl = undefined;
}
}
return {
title: parsed.title,
description: parsed.description,
source_url: parsed.sourceUrl,
image_url: imageUrl || undefined,
made: parsed.made,
rating: parsed.rating,
notes: parsed.notes,
servings: parsed.servings ? this.extractServingCount(parsed.servings) : undefined,
ingredients: parsed.ingredients.map((item, index) => ({
item,
position: index,
})),
steps: parsed.instructions.map((instruction, index) => ({
instruction,
position: index,
})),
};
}
/**
* Try to extract numeric serving count from serving string.
*/
private extractServingCount(servingStr: string): number | undefined {
const match = /(\d+)\s*servings?/i.exec(servingStr);
return match ? parseInt(match[1], 10) : undefined;
}
}

View File

@ -0,0 +1,154 @@
import type { Database } from 'sql.js';
import { RecipeRepository } from '../repositories/RecipeRepository.js';
import { TagRepository } from '../repositories/TagRepository.js';
import { CopyMeThatHtmlParser, type ParsedCopyMeThatRecipe } from './CopyMeThatHtmlParser.js';
import { CopyMeThatTxtParser, type ParsedCopyMeThatTxtRecipe } from './CopyMeThatTxtParser.js';
import type { Recipe, CreateRecipeInput } from '../types/recipe.js';
export interface ImportOptions {
skipDuplicates?: boolean;
importImages?: boolean;
}
export interface ImportResult {
success: boolean;
imported: number;
skipped: number;
failed: number;
recipes: Recipe[];
errors: string[];
}
export class CopyMeThatImportService {
private recipeRepo: RecipeRepository;
private tagRepo: TagRepository;
private htmlParser: CopyMeThatHtmlParser;
private txtParser: CopyMeThatTxtParser;
constructor(private db: Database) {
this.recipeRepo = new RecipeRepository(db);
this.tagRepo = new TagRepository(db);
this.htmlParser = new CopyMeThatHtmlParser();
this.txtParser = new CopyMeThatTxtParser();
}
/**
* Import recipes from CopyMeThat HTML export file.
*/
async importFromHtml(html: string, options: ImportOptions = {}): Promise<ImportResult> {
const parsed = this.htmlParser.parseRecipes(html);
return this.processRecipes(parsed, options);
}
/**
* Import recipes from multiple CopyMeThat TXT export files.
*/
async importFromTxtFiles(txtContents: { filename: string; content: string }[], options: ImportOptions = {}): Promise<ImportResult> {
const parsed: ParsedCopyMeThatTxtRecipe[] = [];
for (const { content } of txtContents) {
const recipe = this.txtParser.parseRecipe(content);
if (recipe) parsed.push(recipe);
}
return this.processRecipes(parsed, options);
}
/**
* Core import logic: process parsed recipes and insert into database.
*/
private async processRecipes(
recipes: (ParsedCopyMeThatRecipe | ParsedCopyMeThatTxtRecipe)[],
options: ImportOptions
): Promise<ImportResult> {
const result: ImportResult = {
success: true,
imported: 0,
skipped: 0,
failed: 0,
recipes: [],
errors: [],
};
// Precompute a Set of normalized keys for O(1) duplicate detection
const existingRecipes = this.recipeRepo.findAll({ limit: 10000 });
const existingKeys = new Set<string>();
for (const r of existingRecipes) {
const key = `${r.title.toLowerCase()}|${r.source_url ?? ''}`;
existingKeys.add(key);
}
for (const parsedRecipe of recipes) {
try {
// Check for duplicates
if (options.skipDuplicates) {
const key = `${parsedRecipe.title.toLowerCase()}|${parsedRecipe.sourceUrl ?? ''}`;
if (existingKeys.has(key)) {
result.skipped++;
continue;
}
}
// Get or create tags
const tagIds: number[] = [];
if ('tags' in parsedRecipe && parsedRecipe.tags.length > 0) {
for (const tagName of parsedRecipe.tags) {
let tag = this.tagRepo.findByName(tagName);
if (!tag) {
tag = this.tagRepo.create({ name: tagName });
}
tagIds.push(tag.id);
}
}
// Convert to CreateRecipeInput
let input: CreateRecipeInput;
if ('description' in parsedRecipe) {
// HTML format has more fields
input = this.htmlParser.toCreateRecipeInput(parsedRecipe as ParsedCopyMeThatRecipe);
} else {
// TXT format
input = this.txtParser.toCreateRecipeInput(parsedRecipe as ParsedCopyMeThatTxtRecipe);
}
input.tagIds = tagIds;
// Create recipe
const created = this.recipeRepo.create(input);
result.recipes.push(created);
result.imported++;
} catch (error) {
result.failed++;
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
result.errors.push(`Failed to import "${parsedRecipe.title}": ${errorMsg}`);
console.error(`Import error for "${parsedRecipe.title}":`, error);
}
}
if (result.failed > 0) {
result.success = false;
}
return result;
}
/**
* Validate and clean an image URL path from CopyMeThat export.
*/
private cleanImageUrl(imageUrl: string | undefined): string | undefined {
if (!imageUrl) return undefined;
// If it's a relative path (images/...), we'll need to handle image uploads separately
// For now, return undefined for relative paths
if (imageUrl.startsWith('images/')) {
return undefined;
}
// If it's an absolute URL, return as-is
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
return imageUrl;
}
return undefined;
}
}

View File

@ -0,0 +1,140 @@
import type { CreateRecipeInput } from '../types/recipe.js';
export interface ParsedCopyMeThatTxtRecipe {
title: string;
sourceUrl?: string;
tags: string[];
made: boolean;
servings?: string;
ingredients: string[];
instructions: string[];
notes?: string;
}
/**
* Parses CopyMeThat TXT export format.
* Each .txt file contains one recipe.
*/
export class CopyMeThatTxtParser {
/**
* Parse a single .txt file content.
*/
parseRecipe(content: string): ParsedCopyMeThatTxtRecipe | null {
try {
const lines = content.split('\n').map(l => l.trim());
if (lines.length < 5) return null; // Too short to be valid
const title = lines[0];
if (!title) return null;
let sourceUrl: string | undefined;
let tags: string[] = [];
let made = false;
let servings: string | undefined;
// Parse header section
let currentLine = 1;
for (let i = 1; i < lines.length && i < 20; i++) {
const line = lines[i];
if (line.startsWith('Adapted from ')) {
sourceUrl = line.replace('Adapted from ', '').trim();
} else if (line.startsWith('tags:')) {
tags = line.replace('tags:', '').split(',').map(t => t.trim()).filter(t => t);
} else if (line.includes('I made this')) {
made = true;
} else if (line.startsWith('Servings:')) {
servings = line.replace('Servings:', '').trim();
} else if (line === 'INGREDIENTS') {
currentLine = i + 1;
break;
}
}
// Extract ingredients
const ingredients: string[] = [];
for (let i = currentLine; i < lines.length; i++) {
const line = lines[i];
if (line === 'STEPS') {
currentLine = i + 1;
break;
}
if (line && line !== '') {
ingredients.push(line);
}
}
// Extract instructions
const instructions: string[] = [];
for (let i = currentLine; i < lines.length; i++) {
const line = lines[i];
if (line === 'NOTES' || line === 'NOTE') {
currentLine = i + 1;
break;
}
if (line && line !== '') {
// Remove leading numbers like "1)", "2)", etc.
const cleaned = line.replace(/^\d+\)\s*/, '').trim();
if (cleaned) instructions.push(cleaned);
}
}
// Extract notes (everything after NOTES section)
let notes: string | undefined;
if (currentLine < lines.length) {
const notesLines = lines.slice(currentLine).filter(l => l !== '');
if (notesLines.length > 0) {
notes = notesLines.join('\n\n');
}
}
if (!title || ingredients.length === 0 || instructions.length === 0) {
return null; // Invalid recipe
}
return {
title,
sourceUrl,
tags,
made,
servings,
ingredients,
instructions,
notes,
};
} catch (error) {
console.error('Error parsing TXT recipe:', error);
return null;
}
}
/**
* Convert parsed recipe to CreateRecipeInput format.
*/
toCreateRecipeInput(parsed: ParsedCopyMeThatTxtRecipe): CreateRecipeInput {
return {
title: parsed.title,
source_url: parsed.sourceUrl,
made: parsed.made,
notes: parsed.notes,
servings: parsed.servings ? this.extractServingCount(parsed.servings) : undefined,
ingredients: parsed.ingredients.map((item, index) => ({
item,
position: index,
})),
steps: parsed.instructions.map((instruction, index) => ({
instruction,
position: index,
})),
};
}
/**
* Try to extract numeric serving count from serving string.
*/
private extractServingCount(servingStr: string): number | undefined {
const match = /(\d+)\s*servings?/i.exec(servingStr);
return match ? parseInt(match[1], 10) : undefined;
}
}

View File

@ -1,12 +1,13 @@
import type { CreateRecipeInput } from '../types/recipe.js'; import type { CreateRecipeInput } from '../types/recipe.js';
// ...other necessary imports... // ...other necessary imports...
// Dummy extract/logic: Map string[] to normalized CreateRecipeInput.ingredients // Dummy extract/logic: Map string[] to normalized CreateRecipeInput.ingredients
export function parseHeuristicRecipe(plainRecipe: { title: string; description?: string; ingredients: string[]; steps: string[]; source_url?: string }): CreateRecipeInput { export function parseHeuristicRecipe(plainRecipe: { title: string; description?: string; ingredients: string[]; steps: string[]; source_url?: string; image_url?: string }): CreateRecipeInput {
return { return {
title: plainRecipe.title, title: plainRecipe.title,
description: plainRecipe.description, description: plainRecipe.description,
ingredients: plainRecipe.ingredients.map(item => ({ item })), ingredients: plainRecipe.ingredients.map(item => ({ item })),
steps: plainRecipe.steps.map(instruction => ({ instruction })), steps: plainRecipe.steps.map(instruction => ({ instruction })),
source_url: plainRecipe.source_url, source_url: plainRecipe.source_url,
image_url: plainRecipe.image_url,
}; };
} }

View File

@ -8,5 +8,6 @@ export function parseSchemaOrgRecipe(jsonLd: any): CreateRecipeInput {
ingredients: (jsonLd.recipeIngredient??[]).map((item: string) => ({ item })), ingredients: (jsonLd.recipeIngredient??[]).map((item: string) => ({ item })),
steps: (jsonLd.recipeInstructions??[]).map((txt: any) => ({ instruction: typeof txt === 'string' ? txt : txt.text })), steps: (jsonLd.recipeInstructions??[]).map((txt: any) => ({ instruction: typeof txt === 'string' ? txt : txt.text })),
source_url: jsonLd.url, source_url: jsonLd.url,
image_url: Array.isArray(jsonLd.image) ? jsonLd.image[0] : (typeof jsonLd.image === 'string' ? jsonLd.image : (jsonLd.image?.url ?? undefined)),
}; };
} }

Some files were not shown because too many files have changed in this diff Show More