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>
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
# T07 — Styling Stabilization QA Pass
|
||||||
|
|
||||||
|
Date: 2026-03-28
|
||||||
|
Task: **T07 — Stabilization QA Pass**
|
||||||
|
Scope: QA review of T02–T06 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).**
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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**
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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_
|
||||||
|
|
@ -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** — `&`, `"`, 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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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 (T04–T06)
|
|
||||||
- Add visual regression screenshots once integrated
|
|
||||||
- If future photography is required, only use assets with explicit commercial licenses and record attribution here
|
|
||||||
|
|
|
||||||
|
|
@ -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_
|
||||||
|
|
@ -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_
|
||||||
|
|
@ -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_
|
||||||
|
|
@ -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 2–3 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: 18–20px for controls, 24px section accents, stroke 1.75–2.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**
|
||||||
|
|
@ -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`.
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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.
|
||||||
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</UiCard>
|
||||||
</div>
|
</UiPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 className="flex gap-4">
|
|
||||||
<span className="text-xs text-gray-700">Git: {getRecentCommit(status)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<UiChip className="bg-[var(--surface)] text-xs text-[var(--text)]">Git: {getRecentCommit(status) || '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="flex flex-wrap gap-2">
|
||||||
<div className="text-xs">Heartbeat: {keepalive.heartbeatAgeSeconds != null ? `${keepalive.heartbeatAgeSeconds}s ago` : 'n/a'}</div>
|
<UiChip className="text-xs">Keepalive: {keepalive.status || 'n/a'} ({keepalive.activeSessionLabel || 'none'})</UiChip>
|
||||||
<div className="text-xs">Todo: checked {todo.checked ?? 0}/unchecked {todo.unchecked ?? 0}</div>
|
<UiChip className="text-xs">Heartbeat: {keepalive.heartbeatAgeSeconds != null ? `${keepalive.heartbeatAgeSeconds}s ago` : 'n/a'}</UiChip>
|
||||||
<div className="text-xs">Next: {todo.nextTask || 'n/a'}</div>
|
<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>
|
||||||
|
|
||||||
{!!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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
<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]">
|
onError={(e) => {
|
||||||
{tags[0]?.name ?? 'Homemade'}
|
e.currentTarget.src = listFallback;
|
||||||
</div>
|
}}
|
||||||
<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="pointer-events-none absolute inset-0"
|
||||||
|
style={{ background: 'linear-gradient(to top, rgba(28, 25, 23, 0.45), rgba(28, 25, 23, 0.1), transparent)' }}
|
||||||
|
/>
|
||||||
|
<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">
|
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||||
{recipe.servings ? <MetaChip emoji="🍽️" value={`${recipe.servings}`} label="servings" /> : null}
|
{tags.slice(0, 4).map((tag) => (
|
||||||
{totalTime > 0 ? <MetaChip emoji="⏱️" value={formatTime(totalTime)} label="total" /> : null}
|
<span
|
||||||
<MetaChip emoji="🥄" value={`${recipe.ingredients.length}`} label="ingredients" />
|
key={tag.id}
|
||||||
|
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 }}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{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>
|
||||||
|
|
||||||
{tags.length > 0 && (
|
<div className="mt-auto flex items-center justify-between border-t border-[var(--border)]/70 pt-3 text-xs text-[var(--text-dim)]">
|
||||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
|
||||||
{tags.slice(0, 4).map((tag) => (
|
|
||||||
<span
|
|
||||||
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]"
|
|
||||||
style={{ backgroundColor: tag.color || colors.primary, borderRadius: radius.full }}
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</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}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between border-t border-slate-100 pt-3 text-xs text-gray-500">
|
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export type ClassValue = string | false | null | undefined;
|
||||||
|
|
||||||
|
export function cn(...values: ClassValue[]): string {
|
||||||
|
return values.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
border-radius: var(--radius-md);
|
margin-right: auto;
|
||||||
border: 1px solid transparent;
|
max-width: 72rem;
|
||||||
transition: background-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease,
|
padding-left: 1rem;
|
||||||
transform 0.15s ease;
|
padding-right: 1rem;
|
||||||
}
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
button:hover,
|
@media (min-width: 768px) {
|
||||||
.button:hover,
|
.ui-page {
|
||||||
.btn:hover {
|
padding-left: 1.5rem;
|
||||||
box-shadow: var(--shadow-subtle);
|
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: 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,
|
||||||
|
transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</div>
|
</UiPage>
|
||||||
);
|
|
||||||
}
|
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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'}
|
||||||
</div>
|
</p>
|
||||||
<div className="bg-white rounded-2xl shadow-card p-7 mb-8 border border-gray-100">
|
</div>
|
||||||
<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>
|
)}
|
||||||
<div className="mb-6 bg-gray-200 rounded-full h-2 overflow-hidden">
|
</UiSection>
|
||||||
<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>
|
||||||
</div>
|
</UiSection>
|
||||||
<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>
|
<UiSection className="mb-8" padding="lg">
|
||||||
<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="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 className="mb-6 h-2 overflow-hidden rounded-full bg-[var(--border)]">
|
||||||
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
<span className={`text-lg flex-1 ${checkedSteps.has(index) ? 'line-through text-gray-500' : 'text-gray-900'}`}>{instruction}</span>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
>
|
</Link>
|
||||||
Back to Recipes
|
</UiSection>
|
||||||
</Link>
|
</UiPage>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
{recipe.description && (
|
||||||
<div className="min-w-0 flex-1">
|
<p className="max-w-prose break-words text-sm leading-relaxed text-[var(--text-dim)] sm:text-base">{recipe.description}</p>
|
||||||
<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 && (
|
{metadataBadges.length > 0 && (
|
||||||
<p className="mt-2 max-w-3xl break-words text-base text-gray-600 md:text-lg">{recipe.description}</p>
|
<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) => (
|
||||||
{recipeTags.length > 0 && (
|
<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">
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<Icon name={badge.icon} className="h-3 w-3 text-[var(--text-dim)]" />
|
||||||
{recipeTags.map(tag => (
|
{badge.label}
|
||||||
<span
|
</UiChip>
|
||||||
key={tag.id}
|
))}
|
||||||
className="rounded-full px-3 py-1 text-xs font-semibold text-white shadow"
|
</div>
|
||||||
style={{ backgroundColor: tag.color || '#3B82F6' }}
|
)}
|
||||||
>
|
|
||||||
{tag.name}
|
{recipeTags.length > 0 && (
|
||||||
</span>
|
<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) => (
|
||||||
</div>
|
<span
|
||||||
)}
|
key={tag.id}
|
||||||
</div>
|
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"
|
||||||
<div className="hidden rounded-2xl border border-white/80 bg-white/75 p-4 text-4xl shadow-sm md:block">🍲</div>
|
style={{ backgroundColor: `${tag.color || '#E2E8F0'}cc` }}
|
||||||
|
>
|
||||||
|
<Icon name="tag" className="h-3 w-3 shrink-0 text-[var(--text-dim)]" />
|
||||||
|
<span className="truncate">{tag.name}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</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
|
||||||
|
key={`${ingredient}-${index}`}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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)]">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 break-words pt-0.5 text-sm leading-6 text-[var(--text)]">{ingredient}</span>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<section className="ui-card overflow-hidden">
|
||||||
<section className="overflow-hidden rounded-xl border border-slate-200/80 bg-white 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="border-b border-slate-100 bg-gradient-to-r from-blue-50 to-white px-6 py-4">
|
<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>
|
||||||
<h3 className="text-xl font-semibold text-gray-900">Ingredients</h3>
|
<p className="mt-1 text-sm leading-relaxed text-[var(--text-dim)]">Step-by-step flow for better cooking rhythm.</p>
|
||||||
<p className="text-sm text-gray-500">Everything you need before you start cooking.</p>
|
</div>
|
||||||
</div>
|
<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">
|
||||||
<ul className="space-y-3 px-6 py-5">
|
{instructions.length > 0 ? (
|
||||||
{Array.isArray(recipe.ingredients) ? recipe.ingredients.map((ingredient, index) => (
|
instructions.map((instruction, 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">
|
<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="mt-1 inline-block h-2.5 w-2.5 rounded-full bg-blue-500"></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">
|
||||||
<span className="font-mono text-base text-gray-800">{'item' in ingredient ? ingredient.item : ingredient}</span>
|
{index + 1}
|
||||||
</li>
|
</span>
|
||||||
)) : null}
|
<span className="min-w-0 break-words pt-1 text-sm leading-6 text-[var(--text)]">{instruction}</span>
|
||||||
</ul>
|
</li>
|
||||||
</section>
|
))
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-card">
|
{(recipe.source_url || recipe.notes) && (
|
||||||
<div className="border-b border-slate-100 bg-gradient-to-r from-orange-50 to-white px-6 py-4">
|
<section className="ui-card overflow-hidden">
|
||||||
<h3 className="text-xl font-semibold text-gray-900">Instructions</h3>
|
<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">
|
||||||
<p className="text-sm text-gray-500">Follow these steps for best results.</p>
|
<h3 className="text-lg sm:text-xl font-semibold text-[var(--text-h)]">Additional Information</h3>
|
||||||
</div>
|
</div>
|
||||||
<ol className="space-y-3 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">
|
||||||
{Array.isArray(recipe.instructions) ? recipe.instructions.map((instruction, index) => (
|
{recipe.source_url && (
|
||||||
<li key={index} className="flex items-start gap-3 rounded-lg border border-slate-100 bg-slate-50/70 px-3 py-2.5">
|
<div className="space-y-2 rounded-xl border border-[var(--border)] bg-[var(--surface-muted)]/40 px-4 py-3.5 sm:px-5">
|
||||||
<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>
|
<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>
|
||||||
<span className="pt-1 text-base leading-6 text-gray-800">{instruction}</span>
|
<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)]">
|
||||||
</li>
|
{recipe.source_url}
|
||||||
)) : null}
|
</a>
|
||||||
</ol>
|
</div>
|
||||||
</section>
|
)}
|
||||||
</div>
|
{recipe.notes && (
|
||||||
|
<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="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="max-w-prose whitespace-pre-wrap break-words text-sm leading-relaxed text-[var(--text)] sm:text-base">{recipe.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{(recipe.source_url || recipe.notes) && (
|
<aside className="hidden lg:block">
|
||||||
<section className="mt-8 overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-card">
|
<div className="sticky top-6 space-y-3 rounded-2xl border border-[var(--border)] bg-white/95 p-4 shadow-card">
|
||||||
<div className="border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-4">
|
<p className="text-xs font-semibold uppercase tracking-[0.15em] text-[var(--text-dim)]">Quick Actions</p>
|
||||||
<h3 className="text-xl font-semibold text-gray-900">Additional Information</h3>
|
<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>
|
||||||
</div>
|
<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>
|
||||||
<div className="space-y-5 px-6 py-5">
|
<button onClick={handlePrint} className={desktopActionButtonClass}><Icon name="print" className="h-3.5 w-3.5" size={15} /><span className="truncate">Print recipe</span></button>
|
||||||
{recipe.source_url && (
|
<button onClick={handleShare} className={desktopActionButtonClass}><Icon name="share" className="h-3.5 w-3.5" size={15} /><span className="truncate">Share recipe</span></button>
|
||||||
<div>
|
{!deleteConfirm ? (
|
||||||
<div className="mb-1 text-sm font-medium text-gray-700">Source</div>
|
<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>
|
||||||
<a href={recipe.source_url} target="_blank" rel="noopener noreferrer" className="break-all text-primary underline hover:text-blue-700">{recipe.source_url}</a>
|
) : (
|
||||||
</div>
|
<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">
|
||||||
{recipe.notes && (
|
{isDeleting ? 'Deleting...' : 'Confirm'}
|
||||||
<div>
|
</UiButton>
|
||||||
<div className="mb-1 text-sm font-medium text-gray-700">Notes</div>
|
<UiButton onClick={() => setDeleteConfirm(false)} disabled={isDeleting} className="min-h-[2.5rem] min-w-0 px-3 py-2 text-sm leading-4">
|
||||||
<p className="whitespace-pre-wrap text-gray-800">{recipe.notes}</p>
|
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>
|
||||||
</section>
|
</aside>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-8 text-center">
|
|
||||||
<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>
|
</div>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
<button
|
<div className="flex flex-1 flex-wrap gap-2">
|
||||||
onClick={() => setSelectedTagId(null)}
|
|
||||||
className={
|
|
||||||
selectedTagId === null
|
|
||||||
? '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'
|
|
||||||
: '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'
|
|
||||||
}
|
|
||||||
style={{ borderRadius: radius.full }}
|
|
||||||
>
|
|
||||||
All Recipes
|
|
||||||
</button>
|
|
||||||
{tags.map((tag) => (
|
|
||||||
<button
|
<button
|
||||||
key={tag.id}
|
type="button"
|
||||||
onClick={() => setSelectedTagId(tag.id)}
|
onClick={() => setSelectedTagIds([])}
|
||||||
className={
|
className={
|
||||||
selectedTagId === tag.id
|
allRecipesSelected
|
||||||
? '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 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={{ backgroundColor: selectedTagId === tag.id ? tag.color : '', borderRadius: radius.full }}
|
style={{ borderRadius: radius.full }}
|
||||||
>
|
>
|
||||||
{tag.name}
|
All Recipes
|
||||||
</button>
|
</button>
|
||||||
))}
|
{tags.map((tag) => {
|
||||||
|
const isSelected = selectedTagIds.includes(tag.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => handleToggleTagFilter(tag.id)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
className={
|
||||||
|
isSelected
|
||||||
|
? '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)]'
|
||||||
|
: 'ui-chip px-3 py-1.5 text-sm font-medium text-[var(--text)] hover:bg-[var(--surface-muted)]'
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSelected ? tag.color || 'var(--color-primary)' : '',
|
||||||
|
borderRadius: radius.full,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSelected ? `✓ ${tag.name}` : tag.name}
|
||||||
|
</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
|
||||||
|
type="button"
|
||||||
|
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>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<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">
|
{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
|
Clear all filters
|
||||||
</button>
|
</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>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,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()
|
||||||
|
|
@ -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}'"
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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'");
|
||||||
db.exec(schema);
|
if (!hasRecipesTable.length || !hasRecipesTable[0].values.length) {
|
||||||
|
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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
res.header('Access-Control-Allow-Origin', '*');
|
const origin = req.headers.origin as string | undefined;
|
||||||
|
if (ALLOWED_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`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,80 +58,129 @@ 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);
|
||||||
this.db.run(
|
let recipeId: number;
|
||||||
`INSERT INTO recipes (title, description, servings, prep_time_minutes, cook_time_minutes, source_url, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
// Begin transaction
|
||||||
[input.title, input.description ?? null, input.servings ?? null, input.prep_time_minutes ?? null, input.cook_time_minutes ?? null, input.source_url ?? null, now, now]
|
this.db.run('BEGIN TRANSACTION');
|
||||||
);
|
try {
|
||||||
const id = this.db.exec('SELECT last_insert_rowid() as id')[0].values[0][0] as number;
|
this.db.run(
|
||||||
if (input.ingredients) {
|
`INSERT INTO recipes (title, description, servings, prep_time_minutes, cook_time_minutes, source_url, image_url, made, rating, notes, created_at, updated_at)
|
||||||
input.ingredients.forEach((ing, i) => {
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
this.db.run('INSERT INTO ingredients (recipe_id, position, quantity, unit, item, notes) VALUES (?, ?, ?, ?, ?, ?)',
|
[
|
||||||
[
|
input.title,
|
||||||
id,
|
input.description ?? null,
|
||||||
i,
|
input.servings ?? null,
|
||||||
this.toNullableSql(ing.quantity),
|
input.prep_time_minutes ?? null,
|
||||||
this.toNullableSql(ing.unit),
|
input.cook_time_minutes ?? null,
|
||||||
this.toRequiredSqlText(ing.item, 'ingredient.item'),
|
this.toNullableUrl(input.source_url),
|
||||||
this.toNullableSql(ing.notes)
|
this.toNullableUrl(input.image_url),
|
||||||
]);
|
input.made ? 1 : 0,
|
||||||
});
|
input.rating ?? null,
|
||||||
|
input.notes ?? null,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
]
|
||||||
|
);
|
||||||
|
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) {
|
||||||
|
input.ingredients.forEach((ing, i) => {
|
||||||
|
this.db.run('INSERT INTO ingredients (recipe_id, position, quantity, unit, item, notes) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
[
|
||||||
|
recipeId,
|
||||||
|
i,
|
||||||
|
this.toNullableSql(ing.quantity),
|
||||||
|
this.toNullableSql(ing.unit),
|
||||||
|
this.toRequiredSqlText(ing.item, 'ingredient.item'),
|
||||||
|
this.toNullableSql(ing.notes)
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.steps) {
|
||||||
|
input.steps.forEach((step, i) => {
|
||||||
|
this.db.run('INSERT INTO steps (recipe_id, position, instruction) VALUES (?, ?, ?)',
|
||||||
|
[recipeId, i, this.toRequiredSqlText(step.instruction, 'step.instruction')]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.tagIds && input.tagIds.length > 0) {
|
||||||
|
input.tagIds.forEach(tagId => {
|
||||||
|
this.db.run('INSERT INTO recipe_tags (recipe_id, tag_id) VALUES (?, ?)', [recipeId, tagId]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit transaction
|
||||||
|
this.db.run('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
// Rollback on error
|
||||||
|
this.db.run('ROLLBACK');
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
if (input.steps) {
|
|
||||||
input.steps.forEach((step, i) => {
|
return this.findById(recipeId)!;
|
||||||
this.db.run('INSERT INTO steps (recipe_id, position, instruction) VALUES (?, ?, ?)',
|
|
||||||
[id, i, this.toRequiredSqlText(step.instruction, 'step.instruction')]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (input.tagIds && input.tagIds.length > 0) {
|
|
||||||
input.tagIds.forEach(tagId => {
|
|
||||||
this.db.run('INSERT INTO recipe_tags (recipe_id, tag_id) VALUES (?, ?)', [id, tagId]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return this.findById(id)!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
const fields: string[] = [];
|
|
||||||
const params: SqlValue[] = [];
|
// Begin transaction
|
||||||
if (input.title !== undefined) { fields.push('title = ?'); params.push(input.title); }
|
this.db.run('BEGIN TRANSACTION');
|
||||||
if (input.description !== undefined) { fields.push('description = ?'); params.push(input.description); }
|
try {
|
||||||
if (input.servings !== undefined) { fields.push('servings = ?'); params.push(input.servings); }
|
const fields: string[] = [];
|
||||||
if (input.prep_time_minutes !== undefined) { fields.push('prep_time_minutes = ?'); params.push(input.prep_time_minutes); }
|
const params: SqlValue[] = [];
|
||||||
if (input.cook_time_minutes !== undefined) { fields.push('cook_time_minutes = ?'); params.push(input.cook_time_minutes); }
|
if (input.title !== undefined) { fields.push('title = ?'); params.push(input.title); }
|
||||||
if (input.source_url !== undefined) { fields.push('source_url = ?'); params.push(input.source_url); }
|
if (input.description !== undefined) { fields.push('description = ?'); params.push(input.description); }
|
||||||
fields.push('updated_at = ?'); params.push(now);
|
if (input.servings !== undefined) { fields.push('servings = ?'); params.push(input.servings); }
|
||||||
params.push(id);
|
if (input.prep_time_minutes !== undefined) { fields.push('prep_time_minutes = ?'); params.push(input.prep_time_minutes); }
|
||||||
this.db.run(`UPDATE recipes SET ${fields.join(', ')} WHERE id = ?`, params);
|
if (input.cook_time_minutes !== undefined) { fields.push('cook_time_minutes = ?'); params.push(input.cook_time_minutes); }
|
||||||
if (input.ingredients !== undefined) {
|
if (input.source_url !== undefined) { fields.push('source_url = ?'); params.push(this.toNullableUrl(input.source_url)); }
|
||||||
this.db.run('DELETE FROM ingredients WHERE recipe_id = ?', [id]);
|
if (input.image_url !== undefined) { fields.push('image_url = ?'); params.push(this.toNullableUrl(input.image_url)); }
|
||||||
input.ingredients.forEach((ing, i) => {
|
if (input.made !== undefined) { fields.push('made = ?'); params.push(input.made ? 1 : 0); }
|
||||||
this.db.run('INSERT INTO ingredients (recipe_id, position, quantity, unit, item, notes) VALUES (?, ?, ?, ?, ?, ?)',
|
if (input.rating !== undefined) { fields.push('rating = ?'); params.push(input.rating); }
|
||||||
[
|
if (input.notes !== undefined) { fields.push('notes = ?'); params.push(input.notes); }
|
||||||
id,
|
fields.push('updated_at = ?'); params.push(now);
|
||||||
i,
|
params.push(id);
|
||||||
this.toNullableSql(ing.quantity),
|
this.db.run(`UPDATE recipes SET ${fields.join(', ')} WHERE id = ?`, params);
|
||||||
this.toNullableSql(ing.unit),
|
|
||||||
this.toRequiredSqlText(ing.item, 'ingredient.item'),
|
if (input.ingredients !== undefined) {
|
||||||
this.toNullableSql(ing.notes)
|
this.db.run('DELETE FROM ingredients WHERE recipe_id = ?', [id]);
|
||||||
]);
|
input.ingredients.forEach((ing, i) => {
|
||||||
});
|
this.db.run('INSERT INTO ingredients (recipe_id, position, quantity, unit, item, notes) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
}
|
[
|
||||||
if (input.steps !== undefined) {
|
id,
|
||||||
this.db.run('DELETE FROM steps WHERE recipe_id = ?', [id]);
|
i,
|
||||||
input.steps.forEach((step, i) => {
|
this.toNullableSql(ing.quantity),
|
||||||
this.db.run('INSERT INTO steps (recipe_id, position, instruction) VALUES (?, ?, ?)', [id, i, this.toRequiredSqlText(step.instruction, 'step.instruction')]);
|
this.toNullableSql(ing.unit),
|
||||||
});
|
this.toRequiredSqlText(ing.item, 'ingredient.item'),
|
||||||
}
|
this.toNullableSql(ing.notes)
|
||||||
if (input.tagIds !== undefined) {
|
]);
|
||||||
this.db.run('DELETE FROM recipe_tags WHERE recipe_id = ?', [id]);
|
});
|
||||||
input.tagIds.forEach(tagId => {
|
}
|
||||||
this.db.run('INSERT INTO recipe_tags (recipe_id, tag_id) VALUES (?, ?)', [id, tagId]);
|
if (input.steps !== undefined) {
|
||||||
});
|
this.db.run('DELETE FROM steps WHERE recipe_id = ?', [id]);
|
||||||
|
input.steps.forEach((step, i) => {
|
||||||
|
this.db.run('INSERT INTO steps (recipe_id, position, instruction) VALUES (?, ?, ?)', [id, i, this.toRequiredSqlText(step.instruction, 'step.instruction')]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.tagIds !== undefined) {
|
||||||
|
this.db.run('DELETE FROM recipe_tags WHERE recipe_id = ?', [id]);
|
||||||
|
input.tagIds.forEach(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,
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
url: z.string().url('Please provide a valid URL (including https://).'),
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
const router = Router();
|
||||||
// Example: just for build fix; replace with actual logic as needed
|
|
||||||
router.post('/url', (req, res) => {
|
router.post('/url', async (req, res) => {
|
||||||
res.json({ success: true, data: { draft_recipe: null }});
|
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(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupe(values: string[]): string[] {
|
||||||
|
return [...new Set(values)];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
|
|
|
||||||
|
|
@ -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(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/ /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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||