23 Commits

Author SHA1 Message Date
Giancarmine Salucci
ecd2aef971 docs: add session findings — Instagram extraction, LLM, SSE, CI lessons
Some checks failed
Build & Push Docker Image / test-and-build (push) Failing after 33s
Documents hard-won discoveries from active debugging sessions:
- Instagram GraphQL/mobile API silent caption truncation (no marker)
- DOM extraction (html-section strategy) as the only reliable approach
- creator-written '….' vs API truncation — cannot use as signal
- cookies.txt vs auth.json session management and sessionid loss
- Playwright browser session expiry independent of API cookies
- phi4-mini too strict for Italian recipe posts → gemma4 switch
- gemma4 thinking model behavior with max_tokens: 1024
- Tandoor requires Step for ingredients to be saved
- SvelteKit SSE: 3 bugs that caused phase updates to never reach UI
- Gitea CI gotchas: Alpine Chromium, $env/dynamic/private, secrets
- yt-dlp + Playwright split architecture rationale
- Infrastructure reference table

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 03:13:17 +02:00
Giancarmine Salucci
61876f18e5 fix(progress): currentPhase and live messages now update in real-time
Some checks failed
Build & Push Docker Image / test-and-build (push) Failing after 35s
Three issues causing the 'prepping → done' jump:

1. +page.svelte updateQueueItem: never applied update.phase to
   currentPhase, so CookingHero always showed 'Prepping' regardless
   of actual backend state. Fixed: currentPhase: update.phase ?? prev.

2. +page.svelte updateQueueItem: progress events (type:'progress')
   were discarded. Fixed: append data.event to progressEvents array
   so live messages are available to components.

3. stream/+server.ts: initial SSE snapshot omitted phase field, so
   items already in-progress on page load showed wrong phase. Fixed.

Bonus: CookingHero now shows the latest user-friendly progress
message (status/complete/model_loading types) as a live scrolling
sub-line under the phase hint.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 03:00:58 +02:00
Giancarmine Salucci
a389b0db15 fix(detection+tandoor): handle stepless Instagram recipes
Some checks failed
Build & Push Docker Image / test-and-build (push) Failing after 33s
Many Instagram recipe posts list ingredients without preparation steps,
directing users to the 'link in bio' for the full recipe.

- Detection prompt: removed step requirement entirely — title + 2
  ingredients is sufficient to detect a recipe
- tandoor.ts: when steps array is null/empty, create a single
  placeholder step so all ingredients are preserved in Tandoor

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 02:45:21 +02:00
Giancarmine Salucci
d09bf80088 fix(parser): relax detection prompt — quantities not required for social media recipes
Some checks failed
Build & Push Docker Image / test-and-build (push) Failing after 34s
Instagram recipes frequently list ingredients without quantities.
The old prompt required 'at least 3 ingredients WITH quantities' which
caused valid Italian social-media recipe posts to be rejected.

New criteria: dish name + 3 ingredients (any form) + 1 preparation step.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 02:37:59 +02:00
Giancarmine Salucci
226b2e7f15 fix(extraction): always use DOM extraction, never trust GraphQL caption
Some checks failed
Build & Push Docker Image / test-and-build (push) Failing after 33s
Instagram's GraphQL API silently truncates captions WITHOUT '….' markers.
Both DWWxiymssxE (393 chars full, 327 from API) and DXT73izCBoH
(744+ chars full, cut mid-sentence) were affected.

Remove the GraphQL-interception shortcut entirely. Always use DOM
extraction (HTML Section) which clicks '… more' to get the complete text.

The intercepted GraphQL caption is kept only as emergency fallback if
all DOM strategies fail.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 02:24:40 +02:00
Giancarmine Salucci
73e10730dc fix(extraction): don't use truncated GraphQL caption — fall through to DOM
Some checks failed
Build & Push Docker Image / test-and-build (push) Failing after 35s
If the GraphQL-intercepted caption ends with '….' (Instagram's truncation
marker), skip it and fall through to HTML Section extraction which clicks
the '… more' button in the DOM to get the complete, untruncated caption.

Previously the 327-char truncated caption for DWWxiymssxE was returned
immediately, causing the LLM to say 'no recipe' even though the full
description had all ingredients and steps.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 01:52:02 +02:00
Giancarmine Salucci
c9f5300272 feat: use Playwright for caption, yt-dlp for thumbnail only
Some checks failed
Build & Push Docker Image / test-and-build (push) Failing after 33s
Always extract the full caption via Playwright (browser sees the
untruncated text). yt-dlp runs in parallel only to get the thumbnail
CDN URL quickly; its result for the description is discarded.

This eliminates the truncation problem at the source without needing
a fallback heuristic.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 01:31:33 +02:00
Giancarmine Salucci
958353d15a feat: Playwright fallback for truncated Instagram captions
All checks were successful
Build & Push Docker Image / test-and-build (push) Successful in 1m1s
When yt-dlp returns a caption ending with the truncation marker '….'
(GraphQL API caps the text), automatically retry with the Playwright
extractor, which intercepts the full caption from live GraphQL network
traffic.

Falls back gracefully to the partial yt-dlp caption if Playwright fails.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-13 00:17:36 +02:00
Giancarmine Salucci
10c4f78ace Revert "feat: auto Playwright fallback when yt-dlp caption is truncated"
All checks were successful
Build & Push Docker Image / test-and-build (push) Successful in 1m3s
This reverts commit 8c25bce400.
2026-05-12 23:49:34 +02:00
Giancarmine Salucci
8c25bce400 feat: auto Playwright fallback when yt-dlp caption is truncated
All checks were successful
Build & Push Docker Image / test-and-build (push) Successful in 1m2s
Instagram truncates long captions server-side (ends with '…').
When yt-dlp returns a truncated caption, automatically fall back to
the Playwright extractor which runs JS in a real browser and can
click the 'more' button to expand the full caption.

Falls back gracefully: if Playwright fails, the truncated text is
still used rather than failing the whole extraction.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 23:46:24 +02:00
Giancarmine Salucci
22280d5536 feat(pwa): dynamic theme-color meta tags + transparent/dark mode icons
All checks were successful
Build & Push Docker Image / test-and-build (push) Successful in 1m3s
- +layout.svelte: replace Svelte logo favicon with actual InstaChef icons;
  add two <meta name="theme-color"> tags with media queries so the browser
  chrome (mobile top bar) matches --bg for light (#FFF8F5) and dark (#110510);
  add <meta name="color-scheme" content="dark light">
- manifest.json: split 'any maskable' into separate 'any' and 'maskable' entries;
  maskable uses icon-512-maskable.png (icon with 10% safe-zone padding on gradient bg)
- New icons:
  - icon-256/512.png → replaced with transparent-background versions
  - icon-256/512-transparent.png → white bg removed via flood-fill BFS
  - icon-256/512-dark.png → transparent icon on brand gradient (#833AB4→#E1306C)
  - icon-512-maskable.png → 80% icon centered on gradient (PWA maskable safe zone)
  - favicon-32.png → 32x32 transparent icon for browser tab
  - favicon.png (192×192) → updated to transparent InstaChef icon

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 23:33:57 +02:00
Giancarmine Salucci
9e14613746 fix(auth): always regenerate cookies.txt from auth.json, don't skip if yt-dlp overwrote it
All checks were successful
Build & Push Docker Image / test-and-build (push) Successful in 1m2s
Previously cookies.txt was only regenerated when auth.json was newer. But yt-dlp
overwrites cookies.txt during extraction with its own header ('generated by yt-dlp')
and potentially fewer/different cookies, losing the sessionid from auth.json.

Fix: remove mtime comparison — always regenerate cookies.txt from auth.json on each
extraction call. This ensures the full session cookie set is always present.
Also remove the now-unused statSync import.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 23:19:55 +02:00
Giancarmine Salucci
561c2843b1 feat(ui): add delete button to RecipeSheet + fix NaNd ago + full QueueItem in POST response
All checks were successful
Build & Push Docker Image / test-and-build (push) Successful in 1m1s
- RecipeSheet: add onDelete prop and 'Remove from queue' button at bottom of sheet
- +page.svelte: wire onDelete -> removeItem in RecipeSheet
- POST /api/queue: return full QueueItem (with createdAt, phases) instead of stripped subset
- TimelineRow: defensive relTime() handles undefined/NaN, uses createdAt ?? enqueuedAt

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 23:05:44 +02:00
Giancarmine Salucci
1f3bfe2119 fix(ui): fix NaNd ago - return full QueueItem from POST /api/queue + defensive relTime
All checks were successful
Build & Push Docker Image / test-and-build (push) Successful in 1m3s
- POST /api/queue now returns the full QueueItem (with createdAt, phases, etc.)
  instead of a stripped {id,url,status,enqueuedAt} subset
- TimelineRow.relTime() now handles undefined/NaN gracefully, falls back to 'just now'
- TimelineRow timestamp uses item.createdAt ?? item.enqueuedAt as fallback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 23:00:52 +02:00
Giancarmine Salucci
8d979a9305 fix(ui): destructure {item} from POST /api/queue response
All checks were successful
Build & Push Docker Image / test-and-build (push) Successful in 1m1s
submitUrl() was using the full {duplicate, item} response object
as the queue item, causing 'Cannot read properties of undefined
(reading length)' crash when rendering phases in RecipeSheet/
TimelineRow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 22:50:53 +02:00
Giancarmine Salucci
040ae17c12 fix(ui): add ic-btn-reset CSS + auto-convert auth.json to cookies.txt
All checks were successful
Build & Push Docker Image / test-and-build (push) Successful in 1m3s
- layout.css: add button.ic-btn-reset rule so all icon buttons
  (bell, back, close, retry, etc.) get proper background:none reset
  instead of browser-default white/grey appearance in dark mode
- instagram-extractor.ts: auto-convert secrets/auth.json
  (Playwright storage format) to Netscape cookies.txt at runtime
  whenever auth.json is newer; ensures sessionid and all Instagram
  session cookies are passed to yt-dlp, fixing empty media response

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 22:29:12 +02:00
Giancarmine Salucci
91aca8d35a ci: trigger rebuild with registry secrets configured
All checks were successful
Build & Push Docker Image / test-and-build (push) Successful in 1m42s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 22:14:56 +02:00
Giancarmine Salucci
bd00595ded fix(test): mock $env/dynamic/private in llm-logging spec
Some checks failed
Build & Push Docker Image / test-and-build (push) Failing after 37s
Tests passed locally because .env provided OPENAI_BASE_URL and
OPENAI_API_KEY. In the Docker build stage there is no .env, so
createLLM() threw 'OPENAI_BASE_URL environment variable is not set'
before the mocked OpenAI client ever ran, causing 3 test failures.

Add vi.mock('$env/dynamic/private', ...) with stub values so the
tests are self-contained and environment-independent.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 22:13:20 +02:00
Giancarmine Salucci
d36629d5f0 fix(ci): run only server tests in Docker tester stage
Some checks failed
Build & Push Docker Image / test-and-build (push) Failing after 38s
Playwright Chromium is not available in node:24-alpine, causing the
vitest 'client' project (browser tests) to fail with an unhandled
browserType.launch error and exit code 1.

- Dockerfile: switch tester stage command to
  'npm run test:unit -- --run --project=server'
  so only Node.js unit tests run during Docker builds
- page.svelte.spec.ts: update stale 'renders h1' assertion to match
  the new InstaChef design (no h1; check for 'InstaChef' logo text)

Browser component tests still run locally when Playwright/Chromium
is available.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 22:09:57 +02:00
Giancarmine Salucci
573cf49ac5 feat(ui): implement InstaChef design system
Some checks failed
Build & Push Docker Image / test-and-build (push) Failing after 38s
- Replace Tailwind with IC CSS design tokens (purple/pink/orange brand gradient,
  Lilita One / DM Sans / JetBrains Mono fonts, light+dark theme via data-theme)
- Add all SVG icon components (ic/Bell, BellOff, Check, Chevron, Clipboard,
  Close, Download, External, Filter, Link, Plus, Retry, Search, Settings,
  Share, Spark, Trash, PhasePrepping, PhaseSimmering, PhasePlating)
- Add shared primitives: Chip, RecipeThumb (deterministic gradient swatch),
  CookingPot (animated SVG), PhaseTrack, SectionHead
- Add TopBar with LIVE indicator and notification bell
- Add CookingHero: animated hero card for in-progress items
- Add TimelineRow: queue list row with status badges
- Add EmptyState: gradient hero + dismissible How it works card
- Add RecipeSheet: bottom-sheet detail overlay with phase progress
- Add AddUrlScreen: full-page URL input with clipboard paste
- Add NotificationsScreen: push toggle + SSE status
- Rewrite +page.svelte: screen router (home/addurl/notifs) + RecipeSheet;
  preserves all SSE, retry, remove, filter, auto-subscribe logic
- Rewrite share/+page.svelte: uses AddUrlScreen shell, preserves Share Target
  logic and auto-process on URL param
- Rewrite InstallPrompt.svelte: InstallSheet bottom-sheet design, all PWA logic intact
- Update manifest.json theme_color to #FFF8F5
- 282 unit tests passing (unchanged)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 22:02:47 +02:00
Giancarmine Salucci
0b9f598c7d fix(parser): handle thinking models in recipe detection
Some checks failed
Build & Push Docker Image / test-and-build (push) Failing after 38s
Increase max_tokens from 10 to 1024 for detection so thinking
models have room to reason. Also fall back to reasoning_content
if content is empty, since some local models (e.g. Gemma 4
thinking variants) put their answer there.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 21:11:50 +02:00
Giancarmine Salucci
97355d859f ci: multi-stage Dockerfile + fix workflow to match runner pattern
Some checks failed
Build & Push Docker Image / test-and-build (push) Failing after 38s
- Add tester/builder/runner stages to Dockerfile
- Use docker/build-push-action@v6 (matches other repos)
- Run tests via Docker tester stage (no setup-node needed)
- Fix secret names: REGISTRY_USERNAME / REGISTRY_TOKEN
- Runner target: keep yt-dlp + Chromium, copy only build artifacts

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 20:53:35 +02:00
Giancarmine Salucci
b4edfe2ac1 Merge feature/RECIPE-0009_deduplication_notifications_ui
Some checks failed
Build & Push Docker Image / test-and-build (push) Failing after 31s
RECIPE-0009: deduplication, notifications, UI improvements
- Iteration 0: deduplication, push notification subscribe, UI
- Iteration 1: footer status bar, icon-only buttons
- Iteration 2: ARIA-compliant footer icon contrast

yt-dlp extractor:
- Replace Playwright scraper with yt-dlp subprocess
- Feature flag EXTRACTOR_BACKEND (ytdlp|playwright)
- Dockerfile: add yt-dlp via pip3

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 20:50:52 +02:00
63 changed files with 3543 additions and 761 deletions

View File

@@ -0,0 +1,46 @@
name: Build & Push Docker Image
on:
push:
branches:
- master
env:
REGISTRY: git.sal.giize.com
IMAGE_NAME: mozempk/insta-recipe
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Run unit tests
uses: docker/build-push-action@v6
with:
context: .
target: tester
push: false
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
platforms: linux/amd64

3
.gitignore vendored
View File

@@ -18,6 +18,9 @@ Thumbs.db
!.env.example
!.env.test
# Secrets (never commit cookies, tokens or credentials)
secrets/
# Local certificates
.ssl/

View File

@@ -1,4 +1,23 @@
FROM node:24-alpine
# ── stage: tester ────────────────────────────────────────────────────────────
FROM node:24-alpine AS tester
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Run only server-side unit tests; browser (Playwright) tests require a full
# Chromium environment that is not available in the Alpine build stage.
RUN npm run test:unit -- --run --project=server
# ── stage: builder ───────────────────────────────────────────────────────────
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ── stage: runner ────────────────────────────────────────────────────────────
FROM node:24-alpine AS runner
WORKDIR /app
# Install yt-dlp (primary Instagram extractor) and Playwright system dependencies (fallback)
@@ -12,10 +31,8 @@ RUN apk add --no-cache \
pip3 install --break-system-packages yt-dlp
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm ci --omit=dev
COPY --from=builder /app/build ./build
EXPOSE 3000
ENV NODE_ENV=production

View File

@@ -3145,3 +3145,243 @@ Footer component needs null-safe access since initial state is `null`:
**Document Version:** 3.0
**Last Updated by:** Planner Agent (RECIPE-0009 Iteration 1)
**Next Update:** Developer Agent
---
---
# Session Findings: Instagram Extraction & Production Lessons
*Recorded during active development sessions (20252026). These are hard-won discoveries from real debugging — not theoretical analysis.*
---
## Instagram: Caption Truncation in Web GraphQL API
**Symptom:** LLM says "no recipe found" even though the full recipe IS in the Instagram caption.
**Root cause:** Instagram's web GraphQL API (`doc_id=8845758582119845`) silently truncates captions in `edge_media_to_caption.edges[0].node.text`. Truncation is **inconsistent**:
- Sometimes ends with `….` (Unicode U+2026 + period)
- Sometimes cuts off mid-sentence with no marker at all
Known examples:
- `DWWxiymssxE`: GraphQL returns 327 chars, full caption is 393 chars (no truncation marker)
- `DXT73izCBoH`: GraphQL returns 744 chars, cuts off mid-sentence `"Versa nella tortiera co'"`
**Fix:** Never trust the GraphQL-intercepted caption. Always use DOM extraction (`extractWithStrategies` → `extractFromHTMLSection` → `tryExpandCaptionInHTMLSection` clicks "… more" button). Keep the intercepted GraphQL caption only as an emergency fallback when DOM extraction fails entirely.
**Key lesson:** The `….` suffix check is **not sufficient** to detect truncation. The only reliable approach is to always go through the DOM.
---
## Instagram: Mobile API vs GraphQL API (yt-dlp behavior)
**How yt-dlp selects which API to call:**
1. If `sessionid` cookie present → calls `https://i.instagram.com/api/v1/media/{PK}/info/` (mobile API)
2. If mobile API fails (or no sessionid) → falls back to GraphQL `doc_id=8845758582119845`
**Mobile API User-Agent:**
- Desktop UA → HTTP 404
- Instagram Android UA → HTTP 200 with full response
- The `--user-agent` CLI flag only affects video download requests, **not** API calls — yt-dlp uses its own hardcoded headers for API calls
**Mobile API also truncates:** Even with a valid sessionid and HTTP 200, `caption.text` in the mobile API response can still be truncated. DOM extraction is the only fully reliable source.
**Shortcode → PK conversion:**
```python
def shortcode_to_pk(sc):
alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
n = 0
for c in sc: n = n * 64 + alphabet.index(c)
return n
```
---
## Instagram: Creator-Written `….` vs API Truncation
**Gotcha:** Some creators intentionally end their captions with `….` or `#seriesname….` as a signature or series marker. This is NOT API truncation.
**Example:** Reel `DW5zH3xjY-_` ("5030 LOW CAL 💪") — the `….` is written by the creator as a series signature. The reel has only 213 chars of real content and no recipe.
**Implication:** Never use `….` suffix as the primary signal to fetch more content — always use DOM extraction regardless.
---
## Instagram: cookies.txt vs auth.json — Session Management
**Two auth formats coexist:**
- `secrets/auth.json` — Playwright `storageState` format (JSON, cookies + origins)
- `secrets/cookies.txt` — Netscape format for yt-dlp
**yt-dlp overwrites cookies.txt** after each extraction, removing `sessionid`. The next run regenerates it from `auth.json` via `maybeConvertAuthJson()` before each call. This is safe in normal operation — but inspecting cookies.txt directly between runs will show a reduced file.
**`sessionid` is critical.** Without it:
- yt-dlp mobile API returns HTTP 404 (empty response)
- Falls back to GraphQL → truncated caption
**Auth scheduler:** `scheduler.ts` runs every 15 minutes to renew the session by navigating to Instagram. Verify with logs: `[Scheduler] Instagram authentication renewed successfully`.
---
## Instagram: Playwright Browser Session Expiry (independent of cookies)
**Symptom:** Playwright navigates to Instagram, sees a profile selector ("Continue as …"), clicks Continue, gets redirected to `/accounts/login/`.
**Root cause:** The `sessionid` cookie is valid for API calls but the browser-level session can expire independently. Instagram shows the profile selector as a soft prompt which, when clicked, triggers a re-auth that fails with a stale session.
**Diagnosis:**
- `svg[aria-label="Home"]` found → session valid ✅
- `(N) Instagram` in title with notifications count → logged in ✅
- Profile selector visible → session expired, need to re-authenticate
**Fix:** Re-authenticate by updating `auth.json` with a fresh login from a real browser session and copying to the volume at `/home/moze/Server/stacks/insta-recipe/data/secrets/auth.json`.
---
## Instagram: DOM Extraction Strategy Order (2025/2026)
`extractWithStrategies` tries 6 approaches in order. Only one reliably works now:
| Strategy | Status | Reason |
|---|---|---|
| `embedded-json` | ❌ Fails | Instagram removed `window.__additionalDataLoaded` |
| `internal-state` | ❌ Fails | Instagram removed `window._sharedData` |
| `html-section` | ✅ Works | DOM extraction + "… more" button click |
| `dom-selector` | ⚠️ Partial | Simpler DOM query, may miss truncated captions |
| `graphql-api` | ⚠️ Truncated | Live interception but caption is still truncated |
| `legacy` | ❌ Fails | Old format gone |
**Note:** Clicking "… more" triggers feed-loading GraphQL calls (`xdt_api__v1__clips__home__connection_v2`) as a side effect. The full text comes purely from the expanded DOM, not a network response.
---
## LLM: phi4-mini Recipe Detection Too Strict
**Problem:** phi4-mini rejected valid Italian Instagram recipe posts as "no recipe found" during detection.
**Root cause:** Detection prompt required quantities + at least 2 steps. Italian Instagram posts often:
- Omit explicit quantities (just list ingredients by name)
- Say "full recipe at link in bio" with no steps at all
**Detection prompt evolution:**
- v1: title + 3 ingredients with quantities + 2 steps
- v2: title + 3 ingredients (no quantities) + 1 step
- v3 (current): title + 2 ingredients, NO step requirement
**Lesson:** If it reads like food content with at least 2 named ingredients, say yes.
---
## LLM: gemma4 Thinking Models Behavior
**gemma4 models on llama-swap (`http://192.168.1.50:8080`):**
- `gemma4-e2b-q8_0` — smaller/faster
- `gemma4-e4b-q6k` — better quality (production model)
- `gemma4-26b-moe-iq4xs`, `granite-3.3-8b-q6k`, `deepseek-r1-8b-q6k` also available
**gemma4 is a "thinking" model:** Outputs internal reasoning before the actual answer.
With `max_tokens: 1024`: Model skips most reasoning and puts the answer directly in `content`. The `reasoning_content` fallback in `parser.ts` covers edge cases where content is empty.
**vs phi4-mini:** phi4-mini is more literal and strict. For permissive recipe detection of Italian informal posts, gemma4 is significantly better.
---
## Tandoor: Steps Required to Save Ingredients
**Symptom:** Recipe saved to Tandoor has no ingredients even though parsing succeeded.
**Root cause:** Tandoor requires at least one Step for ingredients to be associated. When `recipe.steps` is null/empty:
```typescript
// Old code — creates stepCount=1 but no actual step:
const stepCount = recipe.steps?.length || 1;
(recipe.steps || []).map(...) // returns [] → all ingredients lost
```
**Fix in `tandoor.ts` `buildTandoorRecipeDTO()`:** When `recipe.steps` is null or empty, create a placeholder:
```typescript
const steps = (recipe.steps?.length ? recipe.steps : ['Vedi la ricetta completa al link in bio.']);
```
---
## SvelteKit SSE: Phase Updates Never Reaching UI
**Symptom:** Processing animation showed "Prepping" throughout, then jumped straight to done.
**Three root causes found:**
1. **`updateQueueItem` never set `currentPhase`:** Spreading `...items[idx]` but never applying `update.phase`. Fix:
```typescript
currentPhase: update.phase ?? prev.currentPhase
```
2. **Progress events silently discarded:** SSE `type: 'progress'` messages received but `progressEvents` array never updated. Live messages (e.g. "Parsing with LLM…") were dropped. Fix: append `data.event` to `progressEvents`.
3. **Initial SSE snapshot missing `phase`:** The initial broadcast of queued items omitted `phase: item.currentPhase`. Items already in-progress on page load showed the wrong phase. Fix: include `phase` in the initial snapshot.
---
## Gitea CI: Common Failure Modes
**Chromium not available in Alpine Docker:**
`vite.config.ts` defines two vitest projects: `client` (browser, needs Chromium) and `server` (Node.js). Alpine CI has no Chromium. Always specify:
```bash
npm run test:unit -- --run --project=server
```
**`$env/dynamic/private` throws in Docker build (no `.env`):**
Any code reading SvelteKit env vars at module import time will throw during Docker `RUN npm test` because there's no `.env` file in the build. Fix: mock the module in affected tests:
```typescript
vi.mock('$env/dynamic/private', () => ({
env: { OPENAI_BASE_URL: 'http://localhost:11434', OPENAI_MODEL: 'test-model' }
}));
```
**Registry secrets must be set manually in Gitea:**
`REGISTRY_USERNAME` and `REGISTRY_TOKEN` must be created in repo Settings → Actions → Secrets. They are not automatically available.
---
## TypeScript Quirk: Async Callback Closure Narrowing
```typescript
let interceptedCaption: string | null = null;
page.on('response', async () => { interceptedCaption = 'value'; }); // assigned in async callback
// TypeScript may narrow `interceptedCaption` to `never` outside the callback
// if no other assignment exists in the outer scope.
const capturedCaption = interceptedCaption as string | null; // explicit cast required
```
---
## Production Architecture: yt-dlp + Playwright Split
**Current split (as of commit `c9f5300`+):**
- **Playwright** → caption extraction (DOM, always full text)
- **yt-dlp** → thumbnail URL only (fast, no browser overhead)
- Both run **in parallel** in `QueueProcessor.ts`
**Why not yt-dlp for caption?** Both mobile API and GraphQL responses can be truncated even with a valid session. DOM is the only reliable source.
**Why not Playwright for thumbnail?** yt-dlp extracts thumbnail cleanly and quickly. Playwright-based thumbnail extraction was fragile.
---
## Infrastructure Reference
| Resource | Value |
|---|---|
| App URL | `https://insta-recipe.sal.giize.com` |
| SSH | `ssh -o IdentitiesOnly=yes -i ~/.ssh/id_rsa_ideapad moze@192.168.1.50` |
| Compose file | `/home/moze/Server/stacks/insta-recipe/compose.yaml` |
| Env file | `/home/moze/Server/stacks/insta-recipe/.env` |
| Docker registry | `git.sal.giize.com/mozempk/insta-recipe:latest` |
| Build | `docker buildx build --platform linux/amd64 -t git.sal.giize.com/mozempk/insta-recipe:latest --push .` |
| Deploy | `docker compose pull && docker compose up -d` |
| LLM (internal) | `http://chat_llama-cpp:8080/v1` |
| LLM (external) | `http://192.168.1.50:8080` |
| Current LLM model | `gemma4-e4b-q6k` (via `LLM_MODEL` in `.env`) |
| Auth file (host) | `/home/moze/Server/stacks/insta-recipe/data/secrets/auth.json` |
| Auth file (container) | `/app/secrets/auth.json` |

34
playwright.config.ts.bak Normal file
View File

@@ -0,0 +1,34 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright configuration for E2E tests
*
* See https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './src/tests',
testMatch: '**/*.e2e.spec.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'list',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120000
}
});

View File

@@ -1,127 +0,0 @@
{
"cookies": [
{
"name": "csrftoken",
"value": "SDRORLyWEsWWty2ZoVGdER",
"domain": ".instagram.com",
"path": "/",
"expires": 1805950837.432368,
"httpOnly": false,
"secure": true,
"sameSite": "Lax"
},
{
"name": "datr",
"value": "isQuaeXe5-2mFvFSOdcgVq0u",
"domain": ".instagram.com",
"path": "/",
"expires": 1799232653.525143,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "ig_did",
"value": "5650C8B9-B8D8-4102-9B49-F0668CE34202",
"domain": ".instagram.com",
"path": "/",
"expires": 1796208680.653147,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "mid",
"value": "aS7EigALAAHxXAxrkYg18Fzi-SR7",
"domain": ".instagram.com",
"path": "/",
"expires": 1799232653.525191,
"httpOnly": true,
"secure": true,
"sameSite": "None"
},
{
"name": "ds_user_id",
"value": "59661903731",
"domain": ".instagram.com",
"path": "/",
"expires": 1779166837.432468,
"httpOnly": false,
"secure": true,
"sameSite": "None"
},
{
"name": "sessionid",
"value": "59661903731%3AbekaIlo4nn7x2n%3A29%3AAYhv1LJUsfRtBSH-WmDLVrxiM7T9UotIOM3XY3iHKQ",
"domain": ".instagram.com",
"path": "/",
"expires": 1797910987.674116,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
},
{
"name": "wd",
"value": "1280x720",
"domain": ".instagram.com",
"path": "/",
"expires": 1771995638,
"httpOnly": false,
"secure": true,
"sameSite": "Lax"
},
{
"name": "rur",
"value": "\"CLN\\05459661903731\\0541802926837:01fecdef958a382ffda59c31905f1176573c8f80e9cf231a912f3a861e2b46301946954f\"",
"domain": ".instagram.com",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://www.instagram.com",
"localStorage": [
{
"name": "chatd-deviceid",
"value": "2190f1d6-0ca8-465c-aa86-533cb7538906"
},
{
"name": "hb_timestamp",
"value": "1771389939252"
},
{
"name": "IGSession",
"value": "d498hi:1771392639144"
},
{
"name": "pixel_fire_ts",
"value": "1771121302843"
},
{
"name": "signal_flush_timestamp",
"value": "1771389939261"
},
{
"name": "Session",
"value": "czylty:1771390874144"
},
{
"name": "has_interop_upgraded",
"value": "{\"lastCheckedAt\":1766366944051,\"status\":false}"
},
{
"name": "ig_boost_on_web_campaign_upsell_shown",
"value": "false"
},
{
"name": "banzai:last_storage_flush",
"value": "1771366998859.2"
}
]
}
]
}

View File

@@ -1386,22 +1386,29 @@ export async function extractTextAndThumbnail(
});
await page.waitForTimeout(1000);
// If we intercepted a full caption, use it immediately
if (interceptedCaption) {
console.log('[Extractor] Using intercepted caption from network traffic');
const thumbnail = await extractThumbnailStealth(page, onProgress);
onProgress?.({
type: 'complete',
message: 'Extraction completed via GraphQL interception',
method: 'graphql-intercept',
timestamp: new Date().toISOString()
});
return { bodyText: cleanText(interceptedCaption), thumbnail };
// Always use DOM extraction (HTML Section) — it clicks "… more" in
// the browser and gets the fully expanded caption. The GraphQL
// interception is unreliable: Instagram often truncates captions
// in API responses without any "…." marker, so we cannot trust
// the intercepted text to be complete.
const capturedCaption = interceptedCaption as string | null;
if (capturedCaption) {
console.log(
`[Extractor] Intercepted GraphQL caption (${capturedCaption.length} chars) — always using DOM extraction for full text`
);
}
const result = await extractWithStrategies(url, page, context, onProgress);
if (!result.success || !result.data) {
// DOM extraction failed — fall back to intercepted caption if available
if (capturedCaption) {
console.log(
'[Extractor] DOM extraction failed — using intercepted GraphQL caption as fallback'
);
const thumbnail = await extractThumbnailStealth(page, onProgress);
return { bodyText: cleanText(capturedCaption), thumbnail };
}
throw new Error(result.error || 'Extraction failed');
}

View File

@@ -9,7 +9,7 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { existsSync } from 'node:fs';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { logError } from './utils/logger';
import type { ExtractedContent, ProgressCallback } from './extraction';
@@ -20,9 +20,56 @@ const IMAGE_FETCH_TIMEOUT_MS = 10_000;
const USER_AGENT =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
const AUTH_PATHS = ['/app/secrets/auth.json', './secrets/auth.json'];
const COOKIE_PATHS = ['/app/secrets/cookies.txt', './secrets/cookies.txt'];
interface PlaywrightCookie {
name: string;
value: string;
domain: string;
path: string;
expires: number;
httpOnly?: boolean;
secure?: boolean;
}
/** Convert Playwright auth.json → Netscape cookies.txt next to it. */
function maybeConvertAuthJson(): void {
for (let i = 0; i < AUTH_PATHS.length; i++) {
const authPath = AUTH_PATHS[i];
const cookiePath = COOKIE_PATHS[i];
if (!existsSync(authPath)) continue;
// Always regenerate from auth.json so yt-dlp cannot overwrite our session cookies
try {
const auth = JSON.parse(readFileSync(authPath, 'utf8')) as {
cookies?: PlaywrightCookie[];
};
const cookies: PlaywrightCookie[] = auth.cookies ?? [];
const lines = [
'# Netscape HTTP Cookie File',
'# Auto-generated from auth.json by InstaChef.',
''
];
for (const c of cookies) {
const domain = c.domain.startsWith('.') ? c.domain : `.${c.domain}`;
const includeSubdomains = 'TRUE';
const secure = c.secure ? 'TRUE' : 'FALSE';
const expiry = Math.floor(c.expires > 0 ? c.expires : 0);
lines.push(
[domain, includeSubdomains, c.path ?? '/', secure, expiry, c.name, c.value].join('\t')
);
}
writeFileSync(cookiePath, lines.join('\n') + '\n', 'utf8');
} catch {
// Non-fatal: yt-dlp will just run without cookies
}
break;
}
}
function resolveCookiePath(): string | null {
maybeConvertAuthJson();
for (const p of COOKIE_PATHS) {
if (existsSync(p)) return p;
}

View File

@@ -49,11 +49,17 @@ export async function detectRecipe(text: string): Promise<boolean> {
content: `Does this text contain a recipe?\n\n${text}`
}
],
max_tokens: 10,
// 1024 gives thinking models room to reason before answering
max_tokens: 1024,
temperature: 0
});
const detectionResult = detectionResponse.choices[0].message.content?.toLowerCase() ?? '';
const msg = detectionResponse.choices[0].message;
// Some local models (e.g. Gemma thinking variants) return the answer in
// reasoning_content instead of content when max_tokens is tight.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const reasoning: string = (msg as any).reasoning_content ?? '';
const detectionResult = (msg.content ?? reasoning).toLowerCase();
console.log('[LLM] Detection response:', detectionResult);
return detectionResult.includes('yes');

View File

@@ -9,32 +9,33 @@
export const RECIPE_DETECTION_PROMPT = `You are a recipe detector for social media posts.
Your task: Determine if the text contains a complete or partial recipe.
Your task: Determine if the text contains recipe content (ingredients list, cooking info, or dish preparation).
REQUIREMENTS FOR "YES":
1. Recipe name/title is present
2. At least 3 ingredients with quantities (even if approximate)
3. At least 2 cooking steps
SAY "yes" if the text has:
1. A dish name or title
2. At least 2 ingredients (quantities optional — many posts omit them)
IGNORE:
- Hashtags (#recipe, #food, etc.)
- Mentions (@username)
- Emojis
- Like counts, comments, social metadata
- Promotional text
Steps are NOT required — many Instagram posts list only ingredients and link to the full recipe elsewhere.
SAY "no" only if the text has NO ingredients at all (e.g. pure food appreciation posts, restaurant reviews, memes).
IGNORE: hashtags, @mentions, emojis, "save this", "follow me", "link in bio" phrases.
OUTPUT: Answer with ONLY 'yes' or 'no' - nothing else.
EXAMPLES:
Text: "🍝 Pasta al Pomodoro 🍅 Ingredients: 320g pasta, 400g tomatoes, 2 garlic cloves. Boil pasta. Sauté garlic. Add tomatoes. Mix! #italianfood @chef"
Text: "🍝 Pasta al Pomodoro Ingredients: pasta, tomatoes, garlic. Boil pasta. Sauté garlic. Add tomatoes. Mix! #italianfood @chef"
Answer: yes
Text: "Panini al Latte Ingredienti: 250g farina, 35g zucchero, 8g lievito, 130g latte, 1 uovo, 40g burro, gocce di cioccolato. Trovi la ricetta completa al link in bio."
Answer: yes
Text: "Amazing dinner tonight! 😍 So delicious! 🔥 #foodporn"
Answer: no
Text: "You need pasta, tomatoes, and garlic for this recipe"
Answer: no (missing steps)
Text: "Best restaurant in Milano! You have to try it 🙌"
Answer: no
`;
export const RECIPE_EXTRACTION_PROMPT = `You are an EXPERT RECIPE EXTRACTOR specialized in parsing recipes from social media posts.

View File

@@ -19,22 +19,35 @@ import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/t
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService';
import { queueConfig } from './config';
import { logError } from '../utils/logger';
import { env } from '$env/dynamic/private';
import type { ProgressEvent, ExtractedContent, ProgressCallback } from '$lib/server/extraction';
import type { QueueItem } from './types';
// Feature flag: pick which Instagram extractor backend to invoke.
// Default to yt-dlp; set EXTRACTOR_BACKEND=playwright to fall back to the
// legacy stealth scraper while we verify the new path.
const extractTextAndThumbnail = (
/**
* Extract caption via Playwright (full, untruncated) and thumbnail via yt-dlp
* (fast, reliable CDN URL). Both run in parallel; yt-dlp failure is non-fatal.
*/
async function extractTextAndThumbnail(
url: string,
cb?: ProgressCallback
): Promise<ExtractedContent> => {
const backend = (env.EXTRACTOR_BACKEND ?? 'ytdlp').toLowerCase();
return backend === 'playwright'
? extractWithPlaywright(url, cb)
: extractWithYtDlp(url, cb);
};
): Promise<ExtractedContent> {
// Run Playwright (caption) and yt-dlp (thumbnail) concurrently
const [ytdlpResult, playwrightResult] = await Promise.allSettled([
extractWithYtDlp(url),
extractWithPlaywright(url, cb)
]);
if (playwrightResult.status === 'rejected') {
throw playwrightResult.reason;
}
// Prefer yt-dlp thumbnail; fall back to whatever Playwright captured
const thumbnail =
ytdlpResult.status === 'fulfilled' && ytdlpResult.value.thumbnail
? ytdlpResult.value.thumbnail
: playwrightResult.value.thumbnail;
return { bodyText: playwrightResult.value.bodyText, thumbnail };
}
/**
* Queue processor with configurable concurrency

View File

@@ -234,10 +234,13 @@ function parseAmount(amountStr: string): number | null {
* Includes ingredients partitioned across steps
*/
function buildTandoorRecipeDTO(recipe: ExtractedRecipe): TandoorRecipeDTO {
const stepCount = recipe.steps?.length || 1;
// When the caption has no steps (e.g. "full recipe at link in bio"), create
// a single placeholder step so ingredients are preserved in Tandoor.
const recipeSteps = recipe.steps?.length ? recipe.steps : ['Vedi la ricetta completa al link in bio.'];
const stepCount = recipeSteps.length;
const ingredientPartitions = partitionIngredientsAcrossSteps(recipe.ingredients || [], stepCount);
const steps: TandoorRecipeDTO['steps'] = (recipe.steps || []).map((instruction, index) => {
const steps: TandoorRecipeDTO['steps'] = recipeSteps.map((instruction, index) => {
// Map ingredients, converting unparseable amounts to 1 q.b.
const mappedIngredients = (ingredientPartitions[index] || []).map((ing) => {
const amount = parseAmount(ing.amount);

View File

@@ -1,16 +1,41 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import InstallPrompt from './components/InstallPrompt.svelte';
import { onMount } from 'svelte';
import './layout.css';
let { children } = $props();
onMount(() => {
const root = document.getElementById('ic-root');
if (!root) return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
root.setAttribute('data-theme', mq.matches ? 'dark' : 'light');
const handler = (e: MediaQueryListEvent) => root!.setAttribute('data-theme', e.matches ? 'dark' : 'light');
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
});
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<!-- Favicon -->
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/favicon.png" />
<!-- Theme color: matches --bg in light/dark theme -->
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#FFF8F5" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#110510" />
<meta name="color-scheme" content="dark light" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Lilita+One&family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=JetBrains+Mono:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</svelte:head>
<div class="ic-root" data-theme="light" id="ic-root">
{@render children()}
</div>
<!-- PWA Install Prompt -->
<InstallPrompt />

View File

@@ -3,42 +3,82 @@
import { browser } from '$app/environment';
import { onMount, onDestroy } from 'svelte';
import type { QueueItem, QueueStatusUpdate } from '$lib/server/queue/types';
import QueueItemCard from './components/QueueItemCard.svelte';
import NotificationSettings from './components/NotificationSettings.svelte';
import { replaceState } from '$app/navigation';
import { pushNotificationManager } from '$lib/client/PushNotificationManager';
import type { NotificationState } from '$lib/client/PushNotificationManager';
import TopBar from './components/TopBar.svelte';
import CookingHero from './components/CookingHero.svelte';
import TimelineRow from './components/TimelineRow.svelte';
import EmptyState from './components/EmptyState.svelte';
import RecipeSheet from './components/RecipeSheet.svelte';
import SectionHead from './components/SectionHead.svelte';
import AddUrlScreen from './components/AddUrlScreen.svelte';
import NotificationsScreen from './components/NotificationsScreen.svelte';
// ── State ──────────────────────────────────────────────────
let items = $state<QueueItem[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let filter = $state<string>('all');
let loadError = $state<string | null>(null);
let filter = $state<'all' | 'in_progress' | 'success' | 'error'>('all');
let eventSource = $state<EventSource | null>(null);
let connectionStatus = $state<'connecting' | 'connected' | 'disconnected'>('disconnected');
let lastPing = $state<string | null>(null);
let hasAttemptedAutoSubscribe = $state(false);
let notificationViewModel = $state<NotificationState | null>(null);
// Screen router
let screen = $state<'home' | 'addurl' | 'notifications'>('home');
// Recipe detail sheet
let selectedItem = $state<QueueItem | null>(null);
// "How it works" dismissible card
let showHowTo = $state(true);
// Get highlighted item ID from URL params (when redirected from Share page)
let highlightId = $derived($page.url.searchParams.get('highlight'));
const highlightId = $derived($page.url.searchParams.get('highlight'));
// Available filters - derived to be reactive
let filters = $derived([
{ id: 'all', name: 'All Items', count: items.length },
{ id: 'pending', name: 'Pending', count: items.filter(item => item.status === 'pending').length },
{ id: 'in_progress', name: 'Processing', count: items.filter(item => item.status === 'in_progress').length },
{ id: 'success', name: 'Complete', count: items.filter(item => item.status === 'success').length },
{ id: 'error', name: 'Failed', count: items.filter(item => item.status === 'error' || item.status === 'unhealthy').length }
]);
// Filter items based on selected filter
// Using $derived.by to execute the function and derive the result array
let filteredItems = $derived.by(() => {
// ── Derived ────────────────────────────────────────────────
const filteredItems = $derived.by(() => {
if (filter === 'all') return items;
if (filter === 'error') return items.filter(item => item.status === 'error' || item.status === 'unhealthy');
return items.filter(item => item.status === filter);
if (filter === 'error') return items.filter((i) => i.status === 'error' || i.status === 'unhealthy');
return items.filter((i) => i.status === filter);
});
const sseLastPing = $derived(
lastPing ? relTime(lastPing) + ' ago' : ''
);
function relTime(iso: string): string {
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
if (diff < 60) return Math.round(diff) + 's';
if (diff < 3600) return Math.floor(diff / 60) + 'm';
return Math.floor(diff / 3600) + 'h';
}
// ── groupByDate ────────────────────────────────────────────
type Group = 'Cooking now' | 'In line' | 'Today' | 'Yesterday' | 'Earlier';
function groupByDate(list: QueueItem[]): Record<Group, QueueItem[]> {
const g: Record<Group, QueueItem[]> = {
'Cooking now': [],
'In line': [],
Today: [],
Yesterday: [],
Earlier: []
};
const now = Date.now();
for (const it of list) {
if (it.status === 'in_progress') { g['Cooking now'].push(it); continue; }
if (it.status === 'pending') { g['In line'].push(it); continue; }
const age = (now - new Date(it.createdAt).getTime()) / 1000;
if (age < 86400) g['Today'].push(it);
else if (age < 172800) g['Yesterday'].push(it);
else g['Earlier'].push(it);
}
return g;
}
const groups = $derived(groupByDate(filteredItems));
const cooking = $derived(groups['Cooking now'][0] ?? null);
// ── Lifecycle ──────────────────────────────────────────────
let unsubscribeNotifications: (() => void) | undefined;
onMount(async () => {
@@ -46,384 +86,384 @@
if (browser) {
startSSEConnection();
setupAutoSubscribe();
unsubscribeNotifications = pushNotificationManager.onStateChange((newState) => {
notificationViewModel = newState;
});
unsubscribeNotifications = pushNotificationManager.onStateChange(() => {});
// Open RecipeSheet for highlighted item
if (highlightId) {
const found = items.find((i) => i.id === highlightId);
if (found) { selectedItem = found; clearHighlight(); }
}
}
});
onDestroy(() => {
if (eventSource) {
console.log('[SSE] Closing connection on component destroy');
eventSource.close();
eventSource?.close();
connectionStatus = 'disconnected';
}
// Add notification state cleanup
unsubscribeNotifications?.();
});
// ── Data fetching ──────────────────────────────────────────
async function loadQueueItems() {
try {
loading = true;
error = null;
loadError = null;
const response = await fetch('/api/queue');
if (!response.ok) {
throw new Error('Failed to load queue items');
}
if (!response.ok) throw new Error('Failed to load queue items');
const data = await response.json();
items = data.items || [];
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
loadError = e instanceof Error ? e.message : 'Unknown error';
console.error('Failed to load queue items:', e);
} finally {
loading = false;
}
}
function startSSEConnection() {
if (!browser) {
console.error('Cannot start SSE connection on server side');
return; // Guard: EventSource is browser-only API
async function submitUrl(url: string) {
try {
const response = await fetch('/api/queue', {
method: 'POST',
body: JSON.stringify({ url }),
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.message || 'Failed to enqueue URL');
}
const { item: queueItem } = await response.json();
// Item will arrive via SSE, but add immediately for UX
items = [queueItem, ...items];
screen = 'home';
// Show the new item in RecipeSheet
selectedItem = queueItem;
} catch (e) {
console.error('Failed to submit URL:', e);
}
}
// ── SSE ────────────────────────────────────────────────────
function startSSEConnection() {
if (!browser) return;
connectionStatus = 'connecting';
console.log('[SSE] Connecting to queue stream...');
try {
eventSource = new EventSource('/api/queue/stream');
eventSource.addEventListener('open', () => {
console.log('[SSE] Connection opened');
connectionStatus = 'connected';
});
eventSource.addEventListener('connection', (event) => {
const data = JSON.parse(event.data);
console.log('[SSE] Connection confirmed:', data.message);
connectionStatus = 'connected';
});
eventSource.addEventListener('open', () => { connectionStatus = 'connected'; });
eventSource.addEventListener('connection', () => { connectionStatus = 'connected'; });
eventSource.addEventListener('queue-update', (event) => {
const update: QueueStatusUpdate = JSON.parse(event.data);
updateQueueItem(update);
updateQueueItem(JSON.parse(event.data) as QueueStatusUpdate);
});
eventSource.addEventListener('error', (event) => {
console.error('[SSE] Connection error:', event);
eventSource.addEventListener('error', () => {
connectionStatus = 'disconnected';
// Attempt to reconnect after 5 seconds
setTimeout(() => {
// EventSource.CLOSED = 2 (use numeric constant for SSR safety)
if (eventSource?.readyState === 2) {
console.log('[SSE] Attempting reconnection...');
startSSEConnection();
}
if (eventSource?.readyState === 2) startSSEConnection();
}, 5000);
});
eventSource.addEventListener('ping', (event) => {
// Keep-alive ping, update last ping timestamp
const data = JSON.parse(event.data);
lastPing = data.timestamp;
console.log('[SSE] Keep-alive ping received at:', data.timestamp);
lastPing = JSON.parse(event.data).timestamp;
});
} catch (e) {
console.error('[SSE] Failed to start SSE connection:', e);
console.error('[SSE] Failed to start:', e);
connectionStatus = 'disconnected';
}
}
/**
* Setup automatic notification subscription on first user interaction
*
* Follows Web Push API best practices: subscription requires user gesture.
* Listens for first click/touch anywhere on page, checks if notifications
* are supported but not subscribed, then auto-subscribes.
*/
function setupAutoSubscribe() {
if (hasAttemptedAutoSubscribe) return;
const attemptSubscribe = async () => {
const attempt = async () => {
if (hasAttemptedAutoSubscribe) return;
hasAttemptedAutoSubscribe = true;
const state = pushNotificationManager.getState();
// Only auto-subscribe if:
// - Browser supports notifications
// - Permission is not denied
// - Not already subscribed
if (state.supported && state.permission !== 'denied' && !state.subscribed) {
console.log('[HomePage] Auto-subscribing to notifications on first interaction');
await pushNotificationManager.subscribe();
}
// Remove listener after first attempt
document.removeEventListener('click', attemptSubscribe);
document.removeEventListener('touchstart', attemptSubscribe);
};
// Listen for first user interaction
document.addEventListener('click', attemptSubscribe, { once: true });
document.addEventListener('touchstart', attemptSubscribe, { once: true });
document.addEventListener('click', attempt, { once: true });
document.addEventListener('touchstart', attempt, { once: true });
}
function updateQueueItem(update: QueueStatusUpdate) {
// Find and update the item in the list
const itemIndex = items.findIndex(item => item.id === update.itemId);
const idx = items.findIndex((i) => i.id === update.itemId);
if (idx >= 0) {
const prev = items[idx];
// Append progress events (type: 'progress' carries data.event)
const newEvents = update.data?.event
? [...(prev.progressEvents ?? []), update.data.event]
: (prev.progressEvents ?? []);
if (itemIndex >= 0) {
// Update existing item
items[itemIndex] = {
...items[itemIndex],
items[idx] = {
...prev,
status: update.status,
phases: update.progress || items[itemIndex].phases,
results: update.results || items[itemIndex].results,
error: update.error || items[itemIndex].error,
// currentPhase is sent as update.phase on status_change events
currentPhase: update.phase ?? prev.currentPhase,
phases: update.progress || prev.phases,
progressEvents: newEvents,
results: update.results || prev.results,
error: update.error || prev.error,
updatedAt: update.timestamp
};
// Keep selectedItem in sync
if (selectedItem?.id === update.itemId) selectedItem = items[idx];
} else {
// New item - fetch full details from API
fetchQueueItem(update.itemId);
}
// Trigger reactivity
items = [...items];
}
async function fetchQueueItem(id: string) {
try {
const response = await fetch(`/api/queue/${id}`);
if (response.ok) {
const item = await response.json();
items = [item, ...items]; // Add to top of list
}
if (response.ok) items = [await response.json(), ...items];
} catch (e) {
console.error('Failed to fetch queue item:', e);
}
}
// ── Actions ────────────────────────────────────────────────
async function retryItem(id: string) {
try {
const response = await fetch(`/api/queue/${id}/retry`, {
method: 'POST'
});
const response = await fetch(`/api/queue/${id}/retry`, { method: 'POST' });
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to retry item');
const err = await response.json();
throw new Error(err.message || 'Failed to retry');
}
// Item will be updated via SSE
console.log('Retry initiated for item:', id);
} catch (e) {
console.error('Failed to retry item:', e);
// Could show a toast notification here
}
}
async function removeItem(id: string) {
try {
const response = await fetch(`/api/queue/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to remove item');
}
// Item will be removed from local state via SSE update
// but remove immediately for better UX
items = items.filter(item => item.id !== id);
console.log('Item removed successfully:', id);
await fetch(`/api/queue/${id}`, { method: 'DELETE' });
} catch (e) {
console.error('Failed to remove item:', e);
// Fallback: remove from local state anyway
items = items.filter(item => item.id !== id);
} finally {
items = items.filter((i) => i.id !== id);
if (selectedItem?.id === id) selectedItem = null;
}
}
function clearHighlight() {
// Remove highlight parameter from URL without navigation
const url = new URL(window.location.href);
url.searchParams.delete('highlight');
replaceState(url, {});
}
// Queue positions for pending items
function queuePos(item: QueueItem): number {
return items.filter((i) => i.status === 'pending').indexOf(item) + 1;
}
</script>
<svelte:head>
<title>InstaRecipe Queue Dashboard</title>
<meta name="description" content="Monitor your recipe extraction queue in real-time" />
<title>InstaChef</title>
<meta name="description" content="Cook anything from an Instagram link." />
</svelte:head>
<div class="mx-auto p-6 max-w-6xl">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2">Recipe Queue Dashboard</h1>
<p class="text-gray-600">Monitor your Instagram recipe extractions in real-time</p>
</div>
<div class="app-root">
<!-- ── Home screen ────────────────────────────────────────── -->
{#if screen === 'home'}
<div class="ic-scroll home-scroll">
<TopBar
count={items.length}
notifCount={0}
onNotifications={() => (screen = 'notifications')}
/>
<!-- Action Bar -->
<div class="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
<div class="flex items-center gap-4 w-full sm:w-auto">
<!-- Filter Dropdown -->
<div class="flex items-center gap-2">
<label for="filter-select" class="text-sm font-medium text-gray-700">Filter:</label>
<select
id="filter-select"
bind:value={filter}
class="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
>
{#each filters as filterOption}
<option value={filterOption.id}>
{filterOption.name} ({filterOption.count})
</option>
{/each}
</select>
</div>
<!-- Refresh Button (moved to same row) -->
<button
onclick={loadQueueItems}
disabled={loading}
title="Refresh queue"
aria-label="Refresh queue"
class="flex items-center p-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 disabled:opacity-50 transition-colors"
>
<svg class="w-5 h-5 {loading ? 'animate-spin' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</button>
</div>
<!-- Add Recipe Button (icon-only, visible when items exist) -->
{#if items.length > 0}
<a
href="/share"
title="Add recipe URL"
aria-label="Add recipe URL"
class="inline-flex items-center p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
</a>
{/if}
</div>
<!-- Loading State -->
{#if loading}
<div class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span class="ml-3 text-gray-600">Loading queue items...</span>
<div class="loading-wrap">
<div class="spinner"></div>
</div>
{/if}
<!-- Error State -->
{#if error}
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div class="flex items-center">
<svg class="w-5 h-5 text-red-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 15.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
<span class="text-red-800">Error loading queue: {error}</span>
</div>
</div>
{/if}
<!-- Queue Items -->
{#if !loading && filteredItems.length === 0}
<div class="text-center py-12">
<div class="text-gray-400 mb-4">
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2 2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">No queue items</h3>
<p class="text-gray-600 mb-6">
{#if filter === 'all'}
Start by sharing an Instagram recipe or adding a URL manually
{:else if loadError}
<div class="err-banner">Failed to load queue: {loadError}</div>
{:else if items.length === 0}
<EmptyState
onAdd={() => (screen = 'addurl')}
{showHowTo}
onDismissHowTo={() => (showHowTo = false)}
/>
{:else}
No items match the selected filter
<!-- Cooking hero -->
{#if cooking && filter !== 'success' && filter !== 'error'}
<CookingHero item={cooking} onTap={() => (selectedItem = cooking)} />
{/if}
</p>
<a
href="/share"
class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
<!-- Filter chips -->
<div class="ic-scroll filter-row">
{#each [
{ id: 'all', label: 'All', count: items.length },
{ id: 'in_progress', label: 'Cooking', count: items.filter((i) => i.status === 'in_progress').length },
{ id: 'success', label: 'Saved', count: items.filter((i) => i.status === 'success').length },
{ id: 'error', label: 'Failed', count: items.filter((i) => i.status === 'error' || i.status === 'unhealthy').length }
] as f}
<button
class="ic-btn-reset filter-chip"
class:active={filter === f.id}
onclick={() => (filter = f.id as typeof filter)}
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Add Recipe URL
</a>
{f.label}
<span class="chip-count">{f.count}</span>
</button>
{/each}
</div>
{:else}
<div class="space-y-4">
{#each filteredItems as item (item.id)}
<QueueItemCard
{item}
highlighted={item.id === highlightId}
onRetry={() => retryItem(item.id)}
onRemove={() => removeItem(item.id)}
onClearHighlight={clearHighlight}
<!-- Timeline groups -->
<div class="timeline">
{#each (['In line', 'Today', 'Yesterday', 'Earlier'] as const) as g}
{#if groups[g]?.length}
<SectionHead>{g}</SectionHead>
{#each groups[g] as it (it.id)}
<TimelineRow
item={it}
queuePosition={queuePos(it)}
onTap={() => (selectedItem = it)}
onRetry={retryItem}
/>
{/each}
{/if}
{/each}
</div>
{/if}
<!-- Notification Settings - Always visible -->
<div class="mt-8" data-notification-settings>
<NotificationSettings />
</div>
<!-- Footer Status Bar (icons only) -->
<div class="fixed bottom-0 left-0 right-0 bg-white border-t shadow-lg z-50">
<div class="mx-auto max-w-6xl px-6 py-3 flex items-center justify-between">
<!-- Notification Status Icon (left) -->
<button
onclick={() => {
// Scroll to NotificationSettings component
document.querySelector('[data-notification-settings]')?.scrollIntoView({ behavior: 'smooth' });
}}
title={notificationViewModel?.subscribed ? 'Notifications enabled' : notificationViewModel?.supported ? 'Notifications disabled' : 'Notifications not supported'}
aria-label="Notification status"
class="p-2 rounded-lg hover:bg-gray-100 transition-colors"
>
{#if !notificationViewModel?.supported || notificationViewModel?.permission === 'denied'}
<!-- Not supported / denied - bell with slash -->
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636m12.728 12.728L18.364 5.636M5.636 18.364l12.728-12.728"></path>
<!-- Sticky add button -->
<div class="add-fab-wrap">
<button class="ic-btn-reset add-fab" onclick={() => (screen = 'addurl')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round">
<path d="M9 4v9a4 4 0 004 4h8M9 8h4M5 20h8" />
</svg>
{:else if notificationViewModel?.subscribed}
<!-- Enabled - bell icon (green) -->
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-5-5-5 5h5v-8a1 1 0 011-1h8a1 1 0 011 1v1a1 1 0 01-1 1h-7v7z"></path>
</svg>
{:else}
<!-- Disabled - bell icon (gray) -->
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-5-5-5 5h5v-8a1 1 0 011-1h8a1 1 0 011 1v1a1 1 0 01-1 1h-7v7z"></path>
</svg>
{/if}
Paste Instagram link
</button>
</div>
</div>
<!-- Live Update Indicator (right) -->
<div
title={connectionStatus === 'connected' ? 'Live updates active' : connectionStatus === 'connecting' ? 'Connecting to live updates...' : 'Live updates disconnected'}
aria-label="Live update status"
class="flex items-center space-x-2"
>
<div class="w-2 h-2 rounded-full {
connectionStatus === 'connected' ? 'bg-green-600' :
connectionStatus === 'connecting' ? 'bg-yellow-600' :
'bg-red-600'
}"></div>
</div>
<!-- ── Add URL screen ────────────────────────────────────── -->
{:else if screen === 'addurl'}
<div class="ic-scroll screen-scroll">
<AddUrlScreen
onBack={() => (screen = 'home')}
onSubmit={submitUrl}
/>
</div>
<!-- ── Notifications screen ──────────────────────────────── -->
{:else if screen === 'notifications'}
<div class="ic-scroll screen-scroll">
<NotificationsScreen
onBack={() => (screen = 'home')}
sseConnected={connectionStatus === 'connected'}
{sseLastPing}
/>
</div>
{/if}
<!-- ── Recipe sheet overlay ─────────────────────────────── -->
{#if selectedItem}
<RecipeSheet
item={selectedItem}
onClose={() => (selectedItem = null)}
onRetry={(id) => { retryItem(id); selectedItem = null; }}
onDelete={(id) => { removeItem(id); selectedItem = null; }}
/>
{/if}
</div>
<style>
.app-root {
position: relative;
width: 100%;
min-height: 100vh;
min-height: 100dvh;
}
.home-scroll,
.screen-scroll {
height: 100vh;
height: 100dvh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.loading-wrap {
display: flex;
justify-content: center;
padding: 60px 0;
}
.spinner {
width: 32px;
height: 32px;
border-radius: 50%;
border: 3px solid var(--border);
border-top-color: var(--pink);
animation: spin 0.75s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.err-banner {
margin: 20px 16px;
padding: 14px 16px;
border-radius: 16px;
background: #ffe9e9;
border: 1px solid #f8c2c2;
color: #7e1717;
font-size: 14px;
}
.filter-row {
display: flex;
gap: 8px;
padding: 20px 20px 6px;
overflow-x: auto;
}
.filter-chip {
padding: 8px 14px;
border-radius: 99px;
background: var(--surface-2);
color: var(--ink-2);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
transition: background 0.15s, color 0.15s;
}
.filter-chip.active {
background: var(--ink);
color: var(--bg);
border-color: var(--ink);
}
.chip-count {
font-size: 11px;
font-family: var(--font-mono);
opacity: 0.6;
}
.filter-chip.active .chip-count {
opacity: 0.7;
}
.timeline {
padding-bottom: 100px;
}
.add-fab-wrap {
position: sticky;
bottom: 0;
left: 0;
right: 0;
padding: 12px 16px 34px;
background: linear-gradient(to top, var(--bg) 55%, color-mix(in srgb, var(--bg) 80%, transparent));
pointer-events: none;
z-index: 40;
}
.add-fab {
width: 100%;
pointer-events: auto;
background: var(--brand-gradient);
color: #fff;
padding: 16px 20px;
border-radius: 99px;
font-size: 15px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
box-shadow: var(--shadow-lg);
}
</style>

View File

@@ -58,12 +58,7 @@ export const POST: RequestHandler = async ({ request }) => {
return json({
duplicate: true,
message: 'This recipe is already in the queue',
item: {
id: existingItem.id,
url: existingItem.url,
status: existingItem.status,
enqueuedAt: existingItem.enqueuedAt
}
item: existingItem
}, { status: 200 }); // 200 OK, not an error
}
@@ -73,12 +68,7 @@ export const POST: RequestHandler = async ({ request }) => {
// Return success response
return json({
duplicate: false,
item: {
id: queueItem.id,
url: queueItem.url,
status: queueItem.status,
enqueuedAt: queueItem.enqueuedAt
}
item: queueItem
});
} catch (error) {
return handleApiError(error);

View File

@@ -124,6 +124,7 @@ export const GET: RequestHandler = async ({ url, request }) => {
status: item.status,
timestamp: new Date().toISOString(),
url: item.url,
phase: item.currentPhase,
progress: item.phases,
results: item.results,
error: item.error

View File

@@ -0,0 +1,276 @@
<script lang="ts">
import Chevron from './ic/Chevron.svelte';
import Link from './ic/Link.svelte';
import Close from './ic/Close.svelte';
import Clipboard from './ic/Clipboard.svelte';
import Spark from './ic/Spark.svelte';
interface Props {
onBack?: () => void;
onSubmit?: (url: string) => void;
initialUrl?: string;
}
let { onBack, onSubmit, initialUrl = '' }: Props = $props();
let url = $state(initialUrl);
let focused = $state(false);
let pasting = $state(false);
const valid = $derived(/https?:\/\/(www\.)?instagram\.com\//.test(url));
async function handlePaste() {
pasting = true;
try {
const text = await navigator.clipboard?.readText();
if (text) url = text;
} catch {
// clipboard access denied — do nothing
} finally {
setTimeout(() => (pasting = false), 200);
}
}
</script>
<div class="screen">
<!-- Back bar -->
<div class="back-bar">
<button class="ic-btn-reset back-btn" onclick={onBack} aria-label="Back">
<Chevron size={18} dir="left" color="var(--ink)" />
</button>
<div class="screen-title">Add a recipe</div>
<div style="width: 40px;"></div>
</div>
<div class="content">
<div class="step-label">STEP 01 · PASTE</div>
<h1 class="ic-display headline">
Drop the<br />
<span class="ic-grad-text">Instagram</span><br />
link here.
</h1>
<p class="sub-text">
Reels, posts, carousels — anything with a recipe in the caption. We'll cook it down into
something searchable.
</p>
<!-- URL input -->
<div class="input-ring" class:focused>
<div class="input-inner">
<Link size={18} color="var(--muted)" />
<input
type="url"
placeholder="instagram.com/reel/..."
bind:value={url}
onfocus={() => (focused = true)}
onblur={() => (focused = false)}
class="url-input"
/>
{#if url}
<button class="ic-btn-reset clear-btn" onclick={() => (url = '')} aria-label="Clear">
<Close size={12} color="var(--muted)" />
</button>
{/if}
</div>
</div>
<!-- Paste button -->
<button class="ic-btn-reset paste-btn" onclick={handlePaste} style="opacity: {pasting ? 0.6 : 1}">
<Clipboard size={16} color="var(--ink-2)" /> Paste from clipboard
</button>
<!-- Pro tip -->
<div class="hint-card">
<div class="hint-title">
<Spark size={12} color="var(--purple)" /> Pro move
</div>
<div class="hint-body">
Add InstaChef to your share sheet and you can send recipes here straight from the Instagram
app — no copy-paste required.
</div>
</div>
</div>
<!-- Submit button -->
<div class="submit-wrap">
<button
class="ic-btn-reset submit-btn"
onclick={() => valid && onSubmit?.(url)}
disabled={!valid}
style="
background: {valid ? 'var(--brand-gradient)' : 'var(--surface-3)'};
color: {valid ? '#fff' : 'var(--muted-2)'};
box-shadow: {valid ? 'var(--shadow-lg)' : 'none'};
cursor: {valid ? 'pointer' : 'not-allowed'};
opacity: {valid ? 1 : 0.85};
"
>
Start cooking <Chevron size={16} color={valid ? '#fff' : 'var(--muted-2)'} dir="right" />
</button>
</div>
</div>
<style>
.screen {
min-height: 100%;
background: var(--bg);
display: flex;
flex-direction: column;
}
.back-bar {
position: sticky;
top: 0;
z-index: 5;
background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
padding: 60px 16px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--border);
}
.back-btn {
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--surface-2);
display: flex;
align-items: center;
justify-content: center;
}
.screen-title {
flex: 1;
font-size: 16px;
font-weight: 700;
text-align: center;
font-family: 'Lilita One', system-ui, sans-serif;
letter-spacing: -0.005em;
color: var(--ink);
}
.content {
padding: 24px 22px;
flex: 1;
}
.step-label {
font-size: 11px;
font-family: var(--font-mono);
font-weight: 700;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--pink);
margin-bottom: 10px;
}
.headline {
font-size: 44px;
line-height: 0.95;
margin: 0 0 14px;
letter-spacing: -0.01em;
color: var(--ink);
}
.sub-text {
font-size: 14px;
color: var(--muted);
line-height: 1.45;
margin: 0 0 26px;
}
.input-ring {
border-radius: 20px;
padding: 2px;
background: var(--border-strong);
transition: background 0.2s;
}
.input-ring.focused {
background: var(--brand-gradient);
}
.input-inner {
background: var(--surface);
border-radius: 18px;
padding: 14px 16px;
display: flex;
align-items: center;
gap: 10px;
}
.url-input {
flex: 1;
border: 0;
outline: 0;
background: transparent;
font-family: var(--font-mono);
font-size: 14px;
color: var(--ink);
min-width: 0;
}
.url-input::placeholder {
color: var(--muted-2);
}
.clear-btn {
width: 22px;
height: 22px;
border-radius: 99px;
background: var(--surface-3);
display: flex;
align-items: center;
justify-content: center;
}
.paste-btn {
width: 100%;
margin-top: 10px;
padding: 12px 16px;
border-radius: 14px;
background: var(--surface-2);
color: var(--ink-2);
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: opacity 0.15s;
}
.hint-card {
margin-top: 26px;
padding: 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 18px;
}
.hint-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
font-family: var(--font-mono);
color: var(--purple);
letter-spacing: 1.2px;
text-transform: uppercase;
margin-bottom: 8px;
}
.hint-body {
font-size: 13px;
color: var(--ink-2);
line-height: 1.4;
}
.submit-wrap {
position: sticky;
bottom: 0;
left: 0;
right: 0;
padding: 12px 16px 34px;
background: linear-gradient(to top, var(--bg) 60%, transparent);
z-index: 40;
}
.submit-btn {
width: 100%;
padding: 16px 20px;
border-radius: 99px;
font-size: 15px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: opacity 0.15s;
}
</style>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
interface Props {
children: import('svelte').Snippet;
color?: string;
bg?: string;
mono?: boolean;
style?: string;
}
let { children, color, bg, mono = false, style = '' }: Props = $props();
</script>
<span
class="ic-chip"
style="
{bg ? `background: ${bg};` : ''}
{color ? `color: ${color};` : ''}
{mono ? `font-family: var(--font-mono);` : ''}
{style}
"
>
{@render children()}
</span>
<style>
.ic-chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.2px;
padding: 4px 8px;
border-radius: 999px;
background: var(--surface-2);
color: var(--ink-2);
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,188 @@
<script lang="ts">
import type { QueueItem } from '$lib/server/queue/types';
import CookingPot from './CookingPot.svelte';
import PhaseTrack from './PhaseTrack.svelte';
interface Props {
item: QueueItem;
onTap?: () => void;
}
let { item, onTap }: Props = $props();
const phaseMap: Record<string, string> = {
extraction: 'Prepping',
parsing: 'Simmering',
uploading: 'Plating'
};
const phaseHints: Record<string, string> = {
extraction: 'Pulling the post + ingredients off Instagram',
parsing: 'Reading the caption with an LLM',
uploading: 'Sending it to your Tandoor library'
};
function username(url: string) {
const m = url.match(/instagram\.com\/([^/?#]+)/);
return m ? '@' + m[1] : '@instagram';
}
function phase(item: QueueItem): 'prepping' | 'simmering' | 'plating' {
const map: Record<string, 'prepping' | 'simmering' | 'plating'> = {
extraction: 'prepping',
parsing: 'simmering',
uploading: 'plating'
};
return map[item.currentPhase ?? 'extraction'] ?? 'prepping';
}
function relTime(iso: string): string {
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
return Math.floor(diff / 3600) + 'h ago';
}
// Latest progress message — shown as a live sub-status inside the hero.
// Only show user-friendly event types (skip low-level extraction method attempts).
const SHOW_TYPES = new Set(['status', 'complete', 'model_loading', 'error', 'retry']);
const latestMsg = $derived.by(() => {
const events = item.progressEvents ?? [];
for (let i = events.length - 1; i >= 0; i--) {
const ev = events[i];
if (!ev?.message || ev.message.length <= 3) continue;
if (SHOW_TYPES.has(ev.type)) return ev.message as string;
}
return null;
});
</script>
<button class="ic-btn-reset hero-card" onclick={onTap}>
<div class="gradient-strip"></div>
<div class="inner">
<div class="header-row">
<span class="cooking-chip">
<span class="ic-live chip-dot"></span>
Cooking now
</span>
<span class="time">{relTime(item.createdAt)}</span>
</div>
<div class="body-row">
<CookingPot size={90} phase={phase(item)} animate={true} />
<div class="text-area">
<div class="phase-label">{phaseMap[item.currentPhase ?? 'extraction']}</div>
<div class="phase-hint">{phaseHints[item.currentPhase ?? 'extraction']}</div>
{#if latestMsg}
<div class="live-msg ic-pulse">{latestMsg}</div>
{/if}
<span class="source-chip">{username(item.url)}</span>
</div>
</div>
<PhaseTrack phase={phase(item)} />
</div>
</button>
<style>
.hero-card {
display: block;
width: calc(100% - 32px);
margin: 8px 16px 0;
border-radius: 28px;
overflow: hidden;
background: var(--surface);
box-shadow: var(--shadow-lg);
border: 1px solid var(--border);
position: relative;
text-align: left;
}
.gradient-strip {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 5px;
background: var(--brand-gradient);
}
.inner {
padding: 20px 20px 18px;
}
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.cooking-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px 4px 8px;
border-radius: 99px;
background: var(--brand-gradient);
color: #fff;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
font-family: var(--font-mono);
white-space: nowrap;
}
.chip-dot {
width: 6px;
height: 6px;
border-radius: 99px;
background: #fff;
display: inline-block;
}
.time {
font-size: 11px;
color: var(--muted);
font-family: var(--font-mono);
white-space: nowrap;
}
.body-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 12px;
margin-bottom: 14px;
}
.text-area {
flex: 1;
min-width: 0;
}
.phase-label {
font-family: 'Lilita One', system-ui, sans-serif;
font-size: 26px;
line-height: 1;
color: var(--ink);
margin-bottom: 6px;
letter-spacing: -0.01em;
}
.phase-hint {
font-size: 13px;
color: var(--muted);
line-height: 1.35;
margin-bottom: 6px;
}
.live-msg {
font-size: 11px;
font-family: var(--font-mono);
color: var(--pink);
line-height: 1.35;
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.source-chip {
display: inline-block;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
color: var(--ink-2);
background: var(--surface-2);
padding: 3px 8px;
border-radius: 99px;
}
</style>

View File

@@ -0,0 +1,76 @@
<script lang="ts">
interface Props {
size?: number;
phase?: 'prepping' | 'simmering' | 'plating';
animate?: boolean;
}
let { size = 80, phase = 'simmering', animate = true }: Props = $props();
const steamCount = 3;
</script>
<div class="pot-wrap" style="width:{size}px; height:{size}px;">
<!-- Steam puffs -->
{#if animate && (phase === 'prepping' || phase === 'simmering')}
{#each { length: steamCount } as _, i}
<div
class="steam"
style="
left: {30 + i * 15}%;
animation-delay: {i * 0.45}s;
--drift: {(i - 1) * 5}px;
"
></div>
{/each}
{/if}
<!-- Pot SVG -->
<svg width={size} height={size} viewBox="0 0 80 80" fill="none">
<!-- Lid -->
<rect x="22" y="24" width="36" height="5" rx="2.5" fill="#3A2A40" />
<!-- Handle -->
<rect x="36" y="15" width="8" height="9" rx="3" fill="#3A2A40" />
<!-- Body -->
<path
d="M14 30 L16 58 Q16 66 24 66 L56 66 Q64 66 64 58 L66 30 Z"
fill={phase === 'prepping' ? '#FD7E14' : phase === 'simmering' ? '#E1306C' : '#833AB4'}
/>
<!-- Side handles -->
<rect x="8" y="34" width="10" height="6" rx="3" fill="#3A2A40" />
<rect x="62" y="34" width="10" height="6" rx="3" fill="#3A2A40" />
<!-- Bubbles (simmering/plating) -->
{#if animate && phase !== 'prepping'}
<circle cx="30" cy="52" r="3" fill="white" opacity="0.5" class="ic-bubble" />
<circle cx="42" cy="54" r="2.5" fill="white" opacity="0.45" class="ic-bubble" style="animation-delay:0.4s" />
<circle cx="52" cy="50" r="2" fill="white" opacity="0.4" class="ic-bubble" style="animation-delay:0.8s" />
{/if}
</svg>
</div>
<style>
.pot-wrap {
position: relative;
display: flex;
align-items: flex-end;
}
.steam {
position: absolute;
bottom: 60%;
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.55);
animation: ic-steam 1.8s ease-in infinite;
animation-delay: var(--delay, 0s);
}
@keyframes ic-steam {
0% {
opacity: 0.7;
transform: translateY(0) translateX(0) scale(1);
}
100% {
opacity: 0;
transform: translateY(-22px) translateX(var(--drift, 0px)) scale(1.5);
}
}
</style>

View File

@@ -0,0 +1,204 @@
<script lang="ts">
import CookingPot from './CookingPot.svelte';
import Clipboard from './ic/Clipboard.svelte';
import Close from './ic/Close.svelte';
import PhasePrepping from './ic/PhasePrepping.svelte';
import PhaseSimmering from './ic/PhaseSimmering.svelte';
import PhasePlating from './ic/PhasePlating.svelte';
interface Props {
onAdd?: () => void;
showHowTo?: boolean;
onDismissHowTo?: () => void;
}
let { onAdd, showHowTo = true, onDismissHowTo }: Props = $props();
</script>
<div class="empty">
<!-- Hero gradient card -->
<div class="hero-card">
<div class="pot-deco">
<CookingPot size={160} animate={true} />
</div>
<div class="tag">Empty kitchen</div>
<h1 class="ic-display hero-title">
Cook<br />anything<br />from a link.
</h1>
<p class="hero-sub">
Paste an Instagram recipe and we'll turn it into a real, savable recipe in Tandoor.
</p>
<button class="ic-btn-reset cta-btn" onclick={onAdd}>
<Clipboard size={16} color="var(--purple)" />
Paste your first link
</button>
</div>
<!-- How it works card -->
{#if showHowTo}
<div class="how-card">
<button class="ic-btn-reset dismiss-btn" onclick={onDismissHowTo} aria-label="Dismiss">
<Close size={14} color="var(--muted)" />
</button>
<div class="how-label">How it works</div>
{#each [
{
icon: PhasePrepping,
n: '01',
t: 'Prepping',
d: 'We grab the post, caption, and any tagged ingredients off Instagram.'
},
{
icon: PhaseSimmering,
n: '02',
t: 'Simmering',
d: 'An LLM reads the caption and turns it into structured ingredients + steps.'
},
{
icon: PhasePlating,
n: '03',
t: 'Plating',
d: 'The finished recipe lands in your Tandoor cookbook, ready to cook.'
}
] as step, i}
<div class="step" class:step-border={i > 0}>
<div class="step-icon">
<step.icon size={32} animate={true} />
</div>
<div class="step-body">
<div class="step-num">STEP {step.n}</div>
<div class="step-title">{step.t}</div>
<div class="step-desc">{step.d}</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.empty {
padding: 20px 20px 100px;
}
.hero-card {
margin-top: 16px;
border-radius: 32px;
padding: 32px 24px;
background: var(--brand-gradient);
color: #fff;
position: relative;
overflow: hidden;
box-shadow: var(--shadow-lg);
}
.pot-deco {
position: absolute;
right: -30px;
top: -30px;
opacity: 0.22;
pointer-events: none;
}
.tag {
display: inline-block;
padding: 4px 10px;
border-radius: 99px;
background: rgba(255, 255, 255, 0.25);
font-size: 10px;
font-weight: 700;
letter-spacing: 1.2px;
text-transform: uppercase;
font-family: var(--font-mono);
margin-bottom: 14px;
}
.hero-title {
font-size: 40px;
line-height: 0.95;
margin: 0 0 10px;
max-width: 240px;
}
.hero-sub {
font-size: 14px;
line-height: 1.4;
margin: 0 0 18px;
max-width: 240px;
opacity: 0.95;
}
.cta-btn {
background: #fff;
color: var(--purple);
padding: 12px 18px;
border-radius: 99px;
font-weight: 700;
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 8px;
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.15);
}
.how-card {
margin-top: 24px;
border-radius: 24px;
padding: 20px;
background: var(--surface);
border: 1px solid var(--border);
position: relative;
}
.dismiss-btn {
position: absolute;
top: 12px;
right: 12px;
width: 28px;
height: 28px;
border-radius: 99px;
background: var(--surface-2);
display: flex;
align-items: center;
justify-content: center;
}
.how-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 1.2px;
text-transform: uppercase;
font-family: var(--font-mono);
color: var(--pink);
margin-bottom: 14px;
}
.step {
display: flex;
gap: 14px;
align-items: flex-start;
padding: 12px 0;
}
.step-border {
border-top: 1px solid var(--border);
}
.step-icon {
width: 44px;
height: 44px;
border-radius: 14px;
background: var(--surface-2);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.step-num {
font-size: 10px;
font-family: var(--font-mono);
color: var(--muted);
font-weight: 700;
margin-bottom: 2px;
}
.step-title {
font-weight: 700;
font-size: 15px;
margin-bottom: 3px;
font-family: 'Lilita One', system-ui, sans-serif;
color: var(--ink);
}
.step-desc {
font-size: 12px;
color: var(--muted);
line-height: 1.45;
}
</style>

View File

@@ -2,6 +2,11 @@
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { pwaInstallManager } from '$lib/client/PWAInstallManager';
import Chip from './Chip.svelte';
import Bell from './ic/Bell.svelte';
import Share from './ic/Share.svelte';
import Download from './ic/Download.svelte';
import Spark from './ic/Spark.svelte';
let showPrompt = $state(false);
let showFallback = $state(false);
@@ -11,36 +16,18 @@
let unsubscribe: (() => void) | null = null;
onMount(() => {
// Don't show if already dismissed or in standalone mode
if (pwaInstallManager.isDismissed() || pwaInstallManager.isStandalone()) {
return;
}
if (pwaInstallManager.isDismissed() || pwaInstallManager.isStandalone()) return;
// Listen for install state changes
unsubscribe = pwaInstallManager.onInstallStateChange((installable) => {
canInstall = installable;
// Show prompt after user engagement and delay
if (installable && userEngaged && !pwaInstallManager.isDismissed()) {
setTimeout(() => {
showPrompt = true;
}, 2000);
setTimeout(() => { showPrompt = true; }, 2000);
} else if (!installable && userEngaged && !pwaInstallManager.isStandalone() && !pwaInstallManager.isDismissed()) {
// Show fallback instructions for browsers without beforeinstallprompt
setTimeout(() => {
showFallback = true;
}, 5000);
setTimeout(() => { showFallback = true; }, 5000);
}
});
// Detect user engagement
const detectEngagement = () => {
userEngaged = true;
document.removeEventListener('scroll', detectEngagement);
document.removeEventListener('click', detectEngagement);
document.removeEventListener('keydown', detectEngagement);
};
const detectEngagement = () => { userEngaged = true; };
document.addEventListener('scroll', detectEngagement, { once: true });
document.addEventListener('click', detectEngagement, { once: true });
document.addEventListener('keydown', detectEngagement, { once: true });
@@ -55,18 +42,12 @@
async function handleInstall() {
installing = true;
try {
const result = await pwaInstallManager.showInstallPrompt();
if (result === 'accepted') {
showPrompt = false;
showFallback = false;
} else if (result === 'dismissed') {
handleDismiss();
}
} catch (error) {
console.error('Install failed:', error);
if (result === 'accepted') { showPrompt = false; showFallback = false; }
else if (result === 'dismissed') handleDismiss();
} catch (e) {
console.error('Install failed:', e);
} finally {
installing = false;
}
@@ -79,171 +60,201 @@
}
</script>
<!-- Main Install Prompt (for browsers with beforeinstallprompt support) -->
<!-- InstallSheet bottom sheet -->
{#if showPrompt && canInstall}
<div class="fixed bottom-0 left-0 right-0 z-50 transform transition-transform duration-300 ease-out animate-slide-up">
<div class="bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-2xl">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<!-- App Icon -->
<div class="flex-shrink-0">
<div class="w-12 h-12 bg-white rounded-xl flex items-center justify-center shadow-lg">
<svg class="w-8 h-8 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd" />
</svg>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay ic-fade" onclick={handleDismiss}>
<div class="sheet ic-slide-up" onclick={(e) => e.stopPropagation()}>
<!-- Handle -->
<div class="handle-row"><div class="handle"></div></div>
<div class="inner">
<!-- App identity -->
<div class="app-row">
<img src="/icon-256.png" alt="InstaChef" width="58" height="58" class="app-icon" />
<div>
<Chip color="var(--pink)" bg="rgba(225,48,108,0.12)" mono>
<Spark size={10} color="var(--pink)" /> INSTALL
</Chip>
<div class="ic-display app-name">Put InstaChef on your home screen</div>
</div>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-white">Install InstaRecipe</h3>
<p class="text-blue-100 text-sm">
Get faster access and offline support. Works like a native app!
</p>
<!-- Feature grid -->
<div class="feature-grid">
{#each [
{ Icon: Share, color: 'var(--purple)', t: 'Share-sheet target', d: 'Send links from Instagram in one tap.' },
{ Icon: Bell, color: 'var(--pink)', t: 'Push when ready', d: 'Buzz on save, retry, or fail.' },
{ Icon: Download, color: 'var(--orange)', t: 'Works offline', d: 'Browse saved recipes anywhere.' },
{ Icon: Spark, color: 'var(--yellow)', t: 'Faster too', d: 'Launches like a native app.' },
] as f}
<div class="feature-card">
<f.Icon size={16} color={f.color} />
<div class="feature-title">{f.t}</div>
<div class="feature-desc">{f.d}</div>
</div>
{/each}
</div>
<!-- Action Buttons -->
<div class="flex items-center space-x-2 ml-4">
<button
onclick={handleInstall}
disabled={installing}
class="bg-white text-blue-600 px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-50 transition-colors disabled:opacity-50 flex items-center space-x-2 shadow-lg"
>
<!-- Install button -->
<button class="ic-btn-reset install-btn" onclick={handleInstall} disabled={installing}>
{#if installing}
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Installing...</span>
Installing…
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span>Install</span>
<Download size={18} color="#fff" /> Install InstaChef
{/if}
</button>
<button
onclick={handleDismiss}
class="text-blue-100 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
title="Dismiss"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<!-- Features List -->
<div class="mt-3 flex flex-wrap gap-3 text-xs text-blue-100">
<div class="flex items-center space-x-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Offline access</span>
</div>
<div class="flex items-center space-x-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Push notifications</span>
</div>
<div class="flex items-center space-x-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Faster loading</span>
</div>
<div class="flex items-center space-x-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span>Home screen access</span>
</div>
</div>
<button class="ic-btn-reset later-btn" onclick={handleDismiss}>Maybe later</button>
</div>
</div>
</div>
{/if}
<!-- Fallback Instructions (for browsers without beforeinstallprompt) -->
{#if showFallback && !canInstall && !pwaInstallManager.isStandalone()}
<div class="fixed bottom-4 right-4 max-w-sm bg-white border rounded-lg shadow-xl p-4 z-40 animate-fade-in">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
<!-- Fallback hint (iOS Safari etc.) -->
{#if showFallback && !canInstall && browser && !pwaInstallManager.isStandalone()}
<div class="fallback ic-slide-up">
<div class="fallback-inner">
<div class="fallback-text">
<div class="fallback-title">Install InstaChef</div>
<div class="fallback-sub">{pwaInstallManager.getInstallInstructions()}</div>
</div>
</div>
<div class="flex-1">
<h4 class="text-sm font-semibold text-gray-900 mb-1">Install InstaRecipe</h4>
<p class="text-xs text-gray-600 mb-3">
{pwaInstallManager.getInstallInstructions()}
</p>
<!-- Browser-specific hints -->
{#if pwaInstallManager.getBrowserName() === 'safari'}
<div class="flex items-center space-x-1 text-xs text-blue-600 bg-blue-50 rounded px-2 py-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z"></path>
</svg>
<span>Use the Share button</span>
</div>
{:else}
<div class="flex items-center space-x-1 text-xs text-green-600 bg-green-50 rounded px-2 py-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span>Look for install button</span>
</div>
{/if}
</div>
<button
onclick={handleDismiss}
class="text-gray-400 hover:text-gray-500 flex-shrink-0"
title="Dismiss"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<button class="ic-btn-reset close-fallback" onclick={handleDismiss} aria-label="Dismiss"></button>
</div>
</div>
{/if}
<style>
@keyframes slide-up {
from {
transform: translateY(100%);
opacity: 0;
.overlay {
position: fixed;
inset: 0;
z-index: 70;
display: flex;
align-items: flex-end;
background: rgba(0, 0, 0, 0.42);
}
to {
transform: translateY(0);
opacity: 1;
.sheet {
width: 100%;
border-radius: 32px 32px 0 0;
background: var(--bg);
box-shadow: 0 -20px 60px rgba(0, 0, 0, 0.3);
}
.handle-row {
display: flex;
justify-content: center;
margin-bottom: 16px;
padding-top: 14px;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.95);
.handle {
width: 44px;
height: 5px;
border-radius: 99px;
background: var(--border-strong);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
.inner {
padding: 0 22px 30px;
}
.app-row {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 18px;
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
.app-icon {
border-radius: 16px;
box-shadow: 0 8px 22px rgba(225, 48, 108, 0.35);
flex-shrink: 0;
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
.app-name {
font-size: 22px;
line-height: 1.1;
color: var(--ink);
margin-top: 6px;
}
.feature-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 18px;
}
.feature-card {
padding: 12px;
border-radius: 16px;
background: var(--surface);
border: 1px solid var(--border);
}
.feature-title {
font-size: 13px;
font-weight: 700;
margin: 6px 0 2px;
color: var(--ink);
}
.feature-desc {
font-size: 11px;
color: var(--muted);
line-height: 1.35;
}
.install-btn {
width: 100%;
background: var(--brand-gradient);
color: #fff;
padding: 16px;
border-radius: 99px;
font-size: 15px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: var(--shadow-lg);
margin-bottom: 8px;
}
.later-btn {
width: 100%;
padding: 12px;
color: var(--muted);
font-size: 13px;
font-weight: 600;
}
.fallback {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
padding: 12px 16px 34px;
background: var(--bg);
border-top: 1px solid var(--border);
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.12);
}
.fallback-inner {
display: flex;
align-items: center;
gap: 12px;
}
.fallback-text {
flex: 1;
}
.fallback-title {
font-size: 14px;
font-weight: 700;
color: var(--ink);
margin-bottom: 2px;
}
.fallback-sub {
font-size: 12px;
color: var(--muted);
}
.close-fallback {
color: var(--muted);
font-size: 16px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 99px;
background: var(--surface-2);
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,322 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pushNotificationManager, type NotificationState } from '$lib/client/PushNotificationManager';
import Chevron from './ic/Chevron.svelte';
import Bell from './ic/Bell.svelte';
import BellOff from './ic/BellOff.svelte';
import Chip from './Chip.svelte';
interface Props {
onBack?: () => void;
sseConnected?: boolean;
sseLastPing?: string;
}
let { onBack, sseConnected = false, sseLastPing = '' }: Props = $props();
let notifState = $state<NotificationState>({
supported: false,
permission: 'default',
subscribed: false,
loading: false,
error: null
});
let unsub: (() => void) | null = null;
onMount(() => {
unsub = pushNotificationManager.onStateChange((s) => {
notifState = s;
});
return () => unsub?.();
});
const enabled = $derived(notifState.subscribed);
async function handleToggle() {
await pushNotificationManager.toggleSubscription();
}
</script>
<div class="screen">
<!-- Back bar -->
<div class="back-bar">
<button class="ic-btn-reset back-btn" onclick={onBack} aria-label="Back">
<Chevron size={18} dir="left" color="var(--ink)" />
</button>
<div class="screen-title">Notifications</div>
<div style="width: 40px;"></div>
</div>
<div class="content">
<!-- Big toggle card -->
<div
class="toggle-card"
style="
background: {enabled ? 'var(--brand-gradient)' : 'var(--surface)'};
color: {enabled ? '#fff' : 'var(--ink)'};
border: {enabled ? 'none' : '1px solid var(--border)'};
box-shadow: {enabled ? 'var(--shadow-lg)' : 'var(--shadow-sm)'};
"
>
<div class="bell-deco" style="opacity: {enabled ? 0.18 : 0.06}">
{#if enabled}
<Bell size={140} color="#fff" filled={true} />
{:else}
<BellOff size={120} color="var(--muted)" />
{/if}
</div>
<div class="toggle-inner">
<Chip color={enabled ? '#fff' : 'var(--muted)'} bg={enabled ? 'rgba(255,255,255,0.22)' : 'var(--surface-2)'} mono>
{enabled ? '● LIVE' : 'OFF'}
</Chip>
<h2 class="ic-display toggle-title">
{enabled ? 'Push is on.' : "Get a ping when it's ready."}
</h2>
<p class="toggle-sub" style="color: {enabled ? 'rgba(255,255,255,0.95)' : 'var(--muted)'}">
{#if enabled}
We'll buzz you the moment a recipe is saved, fails, or needs a retry.
{:else}
Don't miss a plate. Allow notifications and we'll buzz you when a recipe is saved.
{/if}
</p>
{#if notifState.error}
<div class="error-note">{notifState.error}</div>
{/if}
{#if notifState.permission === 'denied'}
<div class="denied-note">
Notifications are blocked. Enable them in your browser settings.
</div>
{:else}
<button
class="ic-btn-reset toggle-btn"
onclick={handleToggle}
disabled={!notifState.supported || notifState.loading}
style="
background: {enabled ? '#fff' : 'var(--ink)'};
color: {enabled ? 'var(--purple)' : 'var(--bg)'};
"
>
{#if notifState.loading}
Working…
{:else if enabled}
<BellOff size={14} color="var(--purple)" /> Turn off
{:else}
<Bell size={14} color="var(--bg)" /> Enable
{/if}
</button>
{/if}
</div>
</div>
<!-- What you'll hear about -->
<div class="info-card">
<div class="info-header">You'll hear about</div>
{#each [
{ color: 'var(--status-success)', title: 'Recipe saved', desc: 'Tap the notification to open it in Tandoor.' },
{ color: 'var(--status-error)', title: 'Extraction failed', desc: 'Retry directly from the notification.' },
{ color: 'var(--orange)', title: 'Long-running parses', desc: 'When a post is taking longer than usual.' }
] as row, i}
<div class="info-row" class:info-row-border={i > 0}>
<div class="info-dot" style="background: {row.color}"></div>
<div class="info-text">
<div class="info-title">{row.title}</div>
<div class="info-desc">{row.desc}</div>
</div>
</div>
{/each}
</div>
<!-- SSE status -->
<div class="sse-card">
<div class="sse-label">Live queue</div>
<div class="sse-row">
<span class="sse-dot" style="background: {sseConnected ? 'var(--status-success)' : 'var(--status-error)'}"></span>
<span class="sse-status">{sseConnected ? 'SSE connected' : 'SSE disconnected'}</span>
<span class="sse-spacer"></span>
{#if sseLastPing}
<span class="sse-ping">{sseLastPing}</span>
{/if}
</div>
</div>
</div>
</div>
<style>
.screen {
min-height: 100%;
background: var(--bg);
padding-bottom: 60px;
}
.back-bar {
position: sticky;
top: 0;
z-index: 5;
background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
padding: 60px 16px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--border);
}
.back-btn {
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--surface-2);
display: flex;
align-items: center;
justify-content: center;
}
.screen-title {
flex: 1;
font-size: 16px;
font-weight: 700;
text-align: center;
font-family: 'Lilita One', system-ui, sans-serif;
color: var(--ink);
}
.content {
padding: 20px 18px;
}
.toggle-card {
border-radius: 26px;
padding: 24px;
position: relative;
overflow: hidden;
transition: all 0.3s;
}
.bell-deco {
position: absolute;
right: -20px;
top: -20px;
pointer-events: none;
transition: opacity 0.3s;
}
.toggle-inner {
position: relative;
}
.toggle-title {
font-size: 30px;
line-height: 1;
margin: 14px 0 6px;
letter-spacing: -0.01em;
max-width: 240px;
}
.toggle-sub {
font-size: 13px;
line-height: 1.4;
margin: 0;
max-width: 260px;
}
.error-note {
margin-top: 10px;
font-size: 12px;
color: rgba(255, 80, 80, 0.9);
}
.denied-note {
margin-top: 14px;
font-size: 13px;
opacity: 0.8;
}
.toggle-btn {
margin-top: 18px;
padding: 12px 18px;
border-radius: 99px;
font-size: 14px;
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.info-card {
margin-top: 18px;
border-radius: 22px;
overflow: hidden;
background: var(--surface);
border: 1px solid var(--border);
}
.info-header {
padding: 14px 18px;
border-bottom: 1px solid var(--border);
font-size: 11px;
font-family: var(--font-mono);
font-weight: 700;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--muted);
}
.info-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
}
.info-row-border {
border-top: 1px solid var(--border);
}
.info-dot {
width: 10px;
height: 10px;
border-radius: 99px;
flex-shrink: 0;
}
.info-text {
flex: 1;
}
.info-title {
font-size: 14px;
font-weight: 600;
color: var(--ink);
}
.info-desc {
font-size: 12px;
color: var(--muted);
}
.sse-card {
margin-top: 18px;
border-radius: 22px;
padding: 18px;
background: var(--surface);
border: 1px solid var(--border);
}
.sse-label {
font-size: 11px;
font-family: var(--font-mono);
font-weight: 700;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 10px;
}
.sse-row {
display: flex;
align-items: center;
gap: 8px;
}
.sse-dot {
width: 8px;
height: 8px;
border-radius: 99px;
flex-shrink: 0;
}
.sse-status {
font-size: 14px;
font-weight: 600;
color: var(--ink);
}
.sse-spacer {
flex: 1;
}
.sse-ping {
font-size: 11px;
color: var(--muted);
font-family: var(--font-mono);
}
</style>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
type Phase = 'prepping' | 'simmering' | 'plating';
interface Props {
phase: Phase;
}
let { phase }: Props = $props();
const phases: { id: Phase; label: string }[] = [
{ id: 'prepping', label: 'Prepping' },
{ id: 'simmering', label: 'Simmering' },
{ id: 'plating', label: 'Plating' }
];
const colors: Record<Phase, string> = {
prepping: '#FD7E14',
simmering: '#E1306C',
plating: '#833AB4'
};
const phaseIndex: Record<Phase, number> = { prepping: 0, simmering: 1, plating: 2 };
const current = $derived(phaseIndex[phase]);
</script>
<div class="phase-track">
{#each phases as p, i}
{@const state = i < current ? 'done' : i === current ? 'active' : 'idle'}
<div class="segment {state}" style={state !== 'idle' ? `--clr: ${colors[p.id]}` : ''}>
<div class="bar"></div>
<span class="label">{p.label}</span>
</div>
{#if i < phases.length - 1}
<div class="connector {i < current ? 'done' : ''}"></div>
{/if}
{/each}
</div>
<style>
.phase-track {
display: flex;
align-items: flex-start;
gap: 0;
width: 100%;
}
.segment {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
flex: 1;
}
.bar {
height: 5px;
width: 100%;
border-radius: 3px;
background: var(--surface-2);
transition: background 0.35s;
}
.segment.active .bar,
.segment.done .bar {
background: var(--clr);
}
.label {
font-size: 10px;
font-weight: 600;
color: var(--ink-3);
letter-spacing: 0.2px;
transition: color 0.35s;
}
.segment.active .label {
color: var(--clr);
}
.segment.done .label {
color: var(--ink-2);
}
.connector {
width: 4px;
height: 5px;
flex-shrink: 0;
background: var(--surface-2);
margin-top: 0;
transition: background 0.35s;
}
.connector.done {
background: var(--ink-3);
}
</style>

View File

@@ -0,0 +1,464 @@
<script lang="ts">
import type { QueueItem } from '$lib/server/queue/types';
import Chip from './Chip.svelte';
import Close from './ic/Close.svelte';
import Check from './ic/Check.svelte';
import Retry from './ic/Retry.svelte';
import Link from './ic/Link.svelte';
import External from './ic/External.svelte';
import PhasePrepping from './ic/PhasePrepping.svelte';
import PhaseSimmering from './ic/PhaseSimmering.svelte';
import PhasePlating from './ic/PhasePlating.svelte';
interface Props {
item: QueueItem | null;
onClose?: () => void;
onRetry?: (id: string) => void;
onDelete?: (id: string) => void;
}
let { item, onClose, onRetry, onDelete }: Props = $props();
const PALETTE = ['#E1306C', '#FD7E14', '#FCAF45', '#833AB4', '#C13584'];
function strHash(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
function swatch(id: string): [string, string] {
const h = strHash(id);
return [PALETTE[h % PALETTE.length], PALETTE[(h + 2) % PALETTE.length]];
}
const isSuccess = $derived(item?.status === 'success');
const isError = $derived(item?.status === 'error' || item?.status === 'unhealthy');
const isCooking = $derived(item?.status === 'in_progress');
const isPending = $derived(item?.status === 'pending');
const recipe = $derived(item?.recipe ?? item?.results?.recipe);
const sw = $derived(item ? swatch(item.id) : (['#E1306C', '#FD7E14'] as [string, string]));
function statusLabel(item: QueueItem): string {
if (item.status === 'success') return 'Saved';
if (item.status === 'error' || item.status === 'unhealthy') return 'Failed';
if (item.status === 'in_progress') {
const m: Record<string, string> = { extraction: 'Prepping', parsing: 'Simmering', uploading: 'Plating' };
return m[item.currentPhase ?? 'extraction'] ?? 'Cooking';
}
return 'Pending';
}
function username(url: string) {
const m = url.match(/instagram\.com\/([^/?#]+)/);
return m ? '@' + m[1] : '@instagram';
}
const phases = [
{ name: 'extraction', label: 'Prepping', desc: 'Grabbing post + media', Icon: PhasePrepping },
{ name: 'parsing', label: 'Simmering', desc: 'LLM reads the caption', Icon: PhaseSimmering },
{ name: 'uploading', label: 'Plating', desc: 'Saving to Tandoor', Icon: PhasePlating }
];
</script>
{#if item}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay ic-fade" onclick={onClose} role="dialog" aria-modal="true">
<div class="sheet ic-slide-up" onclick={(e) => e.stopPropagation()}>
<!-- Handle -->
<div class="handle-row">
<div class="handle"></div>
</div>
<!-- Cover -->
<div class="cover" style="background: linear-gradient(135deg, {sw[0]} 0%, {sw[1]} 100%)">
<div class="cover-ring outer-ring"></div>
<div class="cover-ring inner-ring"></div>
<div class="cover-top">
<Chip color="#fff" bg="rgba(255,255,255,0.22)" mono>
{statusLabel(item).toUpperCase()}
</Chip>
<button class="ic-btn-reset close-btn" onclick={onClose} aria-label="Close">
<Close size={16} color="#fff" />
</button>
</div>
<h2 class="ic-display cover-title">
{recipe?.name || (isPending ? 'Waiting in line' : isCooking ? statusLabel(item) + '…' : 'Untitled')}
</h2>
{#if recipe?.servings}
<div class="cover-meta">Serves {recipe.servings} · from {username(item.url)}</div>
{/if}
</div>
<!-- Body -->
<div class="body">
<!-- Keywords -->
{#if recipe?.keywords?.length > 0}
<div class="tags-row">
{#each recipe.keywords as kw}
<Chip bg="var(--surface-2)" color="var(--ink-2)" mono>#{kw}</Chip>
{/each}
</div>
{/if}
<!-- Phase progress (cooking) -->
{#if isCooking}
<div class="phase-card">
<div class="phase-card-label">3-phase progress</div>
{#each phases as p, i}
{@const ph = item.phases.find((x) => x.name === p.name) || { status: 'pending' }}
{@const done = ph.status === 'completed'}
{@const active = ph.status === 'in_progress'}
<div class="phase-row" style="opacity: {done || active ? 1 : 0.4}">
<div class="phase-icon-wrap" class:phase-active={active}>
<p.Icon size={32} animate={active} />
{#if done}
<div class="phase-done-badge">
<Check size={10} strokeWidth={3.5} color="#fff" />
</div>
{/if}
</div>
<div class="phase-text">
<div class="phase-title">{p.label}</div>
<div class="phase-desc">{p.desc}</div>
</div>
{#if active}
<span class="running-tag ic-pulse">RUNNING</span>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Error detail -->
{#if isError && item.error}
<div class="error-card">
<div class="error-title">
<span class="error-badge">!</span>
Failed during {item.error.phase}
</div>
<div class="error-msg">{item.error.message}</div>
<button class="ic-btn-reset retry-btn" onclick={() => onRetry?.(item!.id)}>
<Retry size={14} color="var(--status-error)" /> Try again
</button>
</div>
{/if}
<!-- Source URL -->
<div class="source-card">
<div class="source-icon">
<Link size={16} color="#fff" />
</div>
<div class="source-info">
<div class="source-label">SOURCE</div>
<div class="source-url">{item.url}</div>
</div>
</div>
<!-- Open in Tandoor -->
{#if isSuccess && (item.results?.tandoorUrl ?? item.tandoorRecipeId)}
{@const tandoorUrl =
item.results?.tandoorUrl ??
`/api/v1/recipe/${item.results?.tandoorRecipeId ?? item.tandoorRecipeId}/`}
<a href={tandoorUrl} target="_blank" rel="noopener" class="ic-btn-reset tandoor-btn">
Open in Tandoor <External size={16} color="#fff" />
</a>
{/if}
<!-- Delete button -->
{#if onDelete}
<button class="ic-btn-reset delete-btn" onclick={() => { onDelete?.(item!.id); onClose?.(); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
Remove from queue
</button>
{/if}
</div>
</div>
</div>
{/if}
<style>
.overlay {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: flex-end;
background: rgba(0, 0, 0, 0.42);
}
.sheet {
background: var(--bg);
width: 100%;
border-radius: 32px 32px 0 0;
max-height: 94vh;
overflow-y: auto;
box-shadow: 0 -20px 60px rgba(0, 0, 0, 0.3);
overscroll-behavior: contain;
}
.handle-row {
display: flex;
justify-content: center;
padding: 10px 0 4px;
}
.handle {
width: 44px;
height: 5px;
border-radius: 99px;
background: var(--border-strong);
}
.cover {
margin: 8px 16px 0;
border-radius: 24px;
padding: 24px 22px;
position: relative;
overflow: hidden;
color: #fff;
min-height: 180px;
}
.cover-ring {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.outer-ring {
right: -60px;
bottom: -60px;
width: 220px;
height: 220px;
border: 2px solid rgba(255, 255, 255, 0.25);
}
.inner-ring {
right: -30px;
bottom: -30px;
width: 160px;
height: 160px;
background: rgba(255, 255, 255, 0.12);
}
.cover-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 80px;
position: relative;
}
.close-btn {
width: 32px;
height: 32px;
border-radius: 99px;
background: rgba(255, 255, 255, 0.22);
display: flex;
align-items: center;
justify-content: center;
}
.cover-title {
font-size: 32px;
line-height: 1;
margin: 0;
letter-spacing: -0.01em;
position: relative;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.cover-meta {
margin-top: 14px;
font-size: 13px;
font-family: var(--font-mono);
opacity: 0.92;
position: relative;
}
.body {
padding: 22px 22px 44px;
}
.tags-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 20px;
}
.phase-card {
border-radius: 20px;
padding: 18px;
margin-bottom: 16px;
background: var(--surface);
border: 1px solid var(--border);
}
.phase-card-label {
font-size: 11px;
font-family: var(--font-mono);
font-weight: 700;
color: var(--muted);
letter-spacing: 1.2px;
text-transform: uppercase;
margin-bottom: 14px;
}
.phase-row {
display: flex;
align-items: center;
gap: 14px;
padding: 8px 0;
transition: opacity 0.3s;
}
.phase-icon-wrap {
width: 44px;
height: 44px;
border-radius: 14px;
background: var(--surface-2);
display: flex;
align-items: center;
justify-content: center;
position: relative;
flex-shrink: 0;
}
.phase-active {
background: var(--bg-tint);
}
.phase-done-badge {
position: absolute;
bottom: -2px;
right: -2px;
width: 18px;
height: 18px;
border-radius: 99px;
background: var(--status-success);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--surface);
}
.phase-title {
font-weight: 700;
font-size: 14px;
font-family: 'Lilita One', system-ui, sans-serif;
color: var(--ink);
}
.phase-desc {
font-size: 12px;
color: var(--muted);
}
.running-tag {
font-size: 10px;
font-family: var(--font-mono);
font-weight: 700;
color: var(--pink);
letter-spacing: 0.6px;
}
.error-card {
border-radius: 20px;
padding: 18px;
background: #ffe9e9;
border: 1px solid #f8c2c2;
color: #7e1717;
margin-bottom: 16px;
}
.error-title {
font-weight: 700;
font-size: 14px;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.error-badge {
width: 20px;
height: 20px;
border-radius: 99px;
background: var(--status-error);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 13px;
}
.error-msg {
font-size: 13px;
line-height: 1.4;
}
.retry-btn {
margin-top: 14px;
padding: 10px 16px;
border-radius: 99px;
background: #fff;
color: var(--status-error);
font-weight: 700;
font-size: 13px;
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid #f8c2c2;
}
.source-card {
border-radius: 20px;
padding: 14px 16px;
background: var(--surface);
border: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.source-icon {
width: 36px;
height: 36px;
border-radius: 12px;
background: var(--brand-gradient);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.source-info {
flex: 1;
min-width: 0;
}
.source-label {
font-size: 11px;
color: var(--muted);
font-family: var(--font-mono);
font-weight: 600;
margin-bottom: 2px;
}
.source-url {
font-size: 13px;
font-family: var(--font-mono);
color: var(--ink-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tandoor-btn {
width: 100%;
padding: 16px;
background: var(--brand-gradient);
color: #fff;
border-radius: 99px;
font-weight: 700;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: var(--shadow-lg);
text-decoration: none;
}
.delete-btn {
width: 100%;
margin-top: 12px;
padding: 14px;
border-radius: 99px;
background: transparent;
color: var(--muted);
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid var(--border);
}
.delete-btn:active {
background: rgba(255, 59, 48, 0.08);
color: var(--status-error);
border-color: var(--status-error);
}
</style>

View File

@@ -0,0 +1,86 @@
<script lang="ts">
// Deterministic 2-color gradient thumbnail from a string id.
const PALETTE = ['#E1306C', '#FD7E14', '#FCAF45', '#833AB4', '#C13584'];
function strHash(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
interface Props {
id: string;
size?: number;
emoji?: string;
}
let { id, size = 52, emoji }: Props = $props();
const swatch = $derived.by(() => {
const h = strHash(id);
const a = PALETTE[h % PALETTE.length];
const b = PALETTE[(h + 2) % PALETTE.length];
return [a, b] as [string, string];
});
</script>
<div class="recipe-thumb" style="width:{size}px; height:{size}px;">
<div
class="gradient"
style="background: linear-gradient(135deg, {swatch[0]}, {swatch[1]});"
>
<div class="ring outer"></div>
<div class="ring inner"></div>
{#if emoji}
<span class="emoji" style="font-size:{size * 0.38}px">{emoji}</span>
{:else}
<svg class="fork" width={size * 0.42} height={size * 0.42} viewBox="0 0 24 24" fill="none">
<path
d="M9 3v6a3 3 0 01-3 3h0a3 3 0 01-3-3V3M6 12v9M15 3v3a3 3 0 003 3h0v9"
stroke="rgba(255,255,255,0.8)"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
{/if}
</div>
</div>
<style>
.recipe-thumb {
flex-shrink: 0;
border-radius: 14px;
overflow: hidden;
}
.gradient {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.ring {
position: absolute;
border-radius: 50%;
border: 1.5px solid rgba(255, 255, 255, 0.25);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.outer {
width: 90%;
height: 90%;
}
.inner {
width: 68%;
height: 68%;
}
.emoji {
position: relative;
z-index: 1;
}
.fork {
position: relative;
z-index: 1;
}
</style>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
interface Props {
children: import('svelte').Snippet;
emoji?: string;
}
let { children, emoji }: Props = $props();
</script>
<div class="section-head">
{#if emoji}<span class="emoji">{emoji}</span>{/if}
<span class="label">{@render children()}</span>
</div>
<style>
.section-head {
display: flex;
align-items: center;
gap: 5px;
padding: 0 16px 6px;
}
.emoji {
font-size: 14px;
}
.label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.6px;
text-transform: uppercase;
color: var(--ink-2);
}
</style>

View File

@@ -0,0 +1,195 @@
<script lang="ts">
import type { QueueItem } from '$lib/server/queue/types';
import RecipeThumb from './RecipeThumb.svelte';
import Retry from './ic/Retry.svelte';
import Chevron from './ic/Chevron.svelte';
import Check from './ic/Check.svelte';
interface Props {
item: QueueItem;
onTap?: () => void;
onRetry?: (id: string) => void;
queuePosition?: number;
}
let { item, onTap, onRetry, queuePosition }: Props = $props();
const isError = $derived(item.status === 'error' || item.status === 'unhealthy');
const isSuccess = $derived(item.status === 'success');
const isPending = $derived(item.status === 'pending');
function username(url: string) {
const m = url.match(/instagram\.com\/([^/?#]+)/);
return m ? '@' + m[1] : '@instagram';
}
function relTime(iso: string | undefined): string {
if (!iso) return 'just now';
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
if (isNaN(diff) || diff < 0) return 'just now';
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
}
const recipe = $derived(item.recipe ?? item.results?.recipe);
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="row" role="button" tabindex="0" onclick={onTap} onkeydown={(e) => e.key === 'Enter' && onTap?.()}>
<!-- Thumbnail -->
<div class="thumb-wrap">
{#if isPending}
<div class="pending-thumb">
<span class="queue-pos">#{queuePosition ?? 1}</span>
</div>
{:else}
<RecipeThumb id={item.id} size={56} />
{/if}
{#if isError}
<div class="badge badge-error">!</div>
{:else if isSuccess}
<div class="badge badge-success">
<Check size={12} strokeWidth={3.4} color="#fff" />
</div>
{/if}
</div>
<!-- Body -->
<div class="body">
<div class="title">
{recipe?.name || (isPending ? 'Waiting in line…' : 'Untitled recipe')}
</div>
<div class="meta">
<span class="uname">{username(item.url)}</span>
<span class="sep">·</span>
<span>{relTime(item.createdAt ?? item.enqueuedAt)}</span>
</div>
{#if isError && item.error}
<div class="error-line">{item.error.message?.slice(0, 60)}</div>
{/if}
</div>
<!-- Tail -->
<div class="tail">
{#if isError}
<button
class="ic-btn-reset retry-btn"
onclick={(e) => { e.stopPropagation(); onRetry?.(item.id); }}
aria-label="Retry"
>
<Retry size={18} color="var(--pink)" />
</button>
{:else}
<Chevron size={18} color="var(--muted-2)" dir="right" />
{/if}
</div>
</div>
<style>
.row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
cursor: pointer;
width: 100%;
background: transparent;
-webkit-tap-highlight-color: transparent;
}
.row:active {
background: var(--surface-2);
}
.thumb-wrap {
position: relative;
flex-shrink: 0;
}
.pending-thumb {
width: 56px;
height: 56px;
border-radius: 16px;
background: var(--surface-2);
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--border-strong);
}
.queue-pos {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
color: var(--muted);
}
.badge {
position: absolute;
bottom: -4px;
right: -4px;
width: 20px;
height: 20px;
border-radius: 99px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--bg);
font-size: 13px;
font-weight: 800;
}
.badge-error {
background: var(--status-error);
color: #fff;
}
.badge-success {
background: var(--status-success);
}
.body {
flex: 1;
min-width: 0;
}
.title {
font-size: 15px;
font-weight: 600;
color: var(--ink);
margin-bottom: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--muted);
}
.uname {
font-family: var(--font-mono);
color: var(--ink-2);
font-weight: 600;
}
.sep {
opacity: 0.5;
}
.error-line {
font-size: 12px;
color: var(--status-error);
margin-top: 4px;
font-weight: 500;
}
.tail {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.retry-btn {
width: 36px;
height: 36px;
border-radius: 12px;
background: var(--surface-2);
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import Bell from './ic/Bell.svelte';
interface Props {
count?: number;
notifCount?: number;
onNotifications?: () => void;
}
let { count = 0, notifCount = 0, onNotifications }: Props = $props();
</script>
<div class="topbar">
<div class="brand">
<img src="/icon-256.png" alt="InstaChef" width="38" height="38" class="logo" />
<div>
<div class="ic-display app-name">InstaChef</div>
<div class="sub">
<span class="dot ic-live"></span>
LIVE · {count} RECIPES
</div>
</div>
</div>
<div class="actions">
<button class="ic-btn-reset bell-btn" onclick={onNotifications} aria-label="Notifications">
<Bell size={20} />
{#if notifCount > 0}
<span class="notif-dot"></span>
{/if}
</button>
</div>
</div>
<style>
.topbar {
position: sticky;
top: 0;
z-index: 5;
background: color-mix(in srgb, var(--bg) 92%, transparent);
backdrop-filter: blur(14px) saturate(160%);
-webkit-backdrop-filter: blur(14px) saturate(160%);
padding: 60px 18px 12px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.logo {
border-radius: 11px;
box-shadow: 0 2px 8px rgba(225, 48, 108, 0.25);
flex-shrink: 0;
}
.app-name {
font-size: 22px;
color: var(--ink);
line-height: 1;
white-space: nowrap;
}
.sub {
font-size: 10px;
color: var(--muted);
font-family: var(--font-mono);
letter-spacing: 0.6px;
margin-top: 3px;
white-space: nowrap;
display: flex;
align-items: center;
gap: 5px;
}
.dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 99px;
background: var(--status-success);
}
.actions {
display: flex;
gap: 4px;
}
.bell-btn {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--ink);
position: relative;
}
.notif-dot {
position: absolute;
top: 6px;
right: 6px;
width: 8px;
height: 8px;
border-radius: 99px;
background: var(--pink);
border: 2px solid var(--bg);
}
</style>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
let { size = 20, color = 'currentColor', filled = false }: { size?: number; color?: string; filled?: boolean } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill={filled ? color : 'none'} stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 8a6 6 0 0112 0c0 6 2 7 2 9H4c0-2 2-3 2-9z" />
<path d="M10 20a2 2 0 004 0" />
</svg>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
let { size = 20, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 8a6 6 0 019.4-4.9M6 8c0 6-2 7-2 9h12" />
<path d="M18 14V8a6 6 0 00-.3-1.9" />
<path d="M10 20a2 2 0 004 0" />
<path d="M3 3l18 18" />
</svg>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
let { size = 18, color = 'currentColor', strokeWidth = 2.4 }: { size?: number; color?: string; strokeWidth?: number } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width={strokeWidth} stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12l5 5L20 7" />
</svg>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
let { size = 18, color = 'currentColor', dir = 'right' }: { size?: number; color?: string; dir?: 'right' | 'left' | 'up' | 'down' } = $props();
const rot = { right: 0, left: 180, up: -90, down: 90 }[dir];
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate({rot}deg)">
<path d="M9 6l6 6-6 6" />
</svg>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
let { size = 18, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="6" y="4" width="12" height="18" rx="2" />
<path d="M9 4V3a1 1 0 011-1h4a1 1 0 011 1v1" />
</svg>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
let { size = 20, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2.4" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
let { size = 20, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 4v12M8 12l4 4 4-4" />
<path d="M20 18v2H4v-2" />
</svg>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
let { size = 16, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 4h6v6M10 14L20 4M19 13v6a1 1 0 01-1 1H5a1 1 0 01-1-1V6a1 1 0 011-1h6" />
</svg>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
let { size = 18, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 5h16M7 12h10M10 19h4" />
</svg>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
let { size = 18, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 14a5 5 0 007 0l3-3a5 5 0 00-7-7l-1 1" />
<path d="M14 10a5 5 0 00-7 0l-3 3a5 5 0 007 7l1-1" />
</svg>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
let { size = 40, animate = false, color = '#833AB4' }: { size?: number; animate?: boolean; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 48 48" fill="none">
<ellipse cx="24" cy="34" rx="18" ry="5" fill={color} opacity="0.2" />
<ellipse cx="24" cy="32" rx="18" ry="5" fill={color} />
<ellipse cx="24" cy="31" rx="14" ry="3" fill="white" opacity="0.5" />
<circle cx="20" cy="30" r="3.5" fill="#FCAF45" />
<circle cx="26" cy="29" r="3" fill="#FD7E14" />
<circle cx="28" cy="32" r="2.5" fill="#E1306C" />
<path d="M18 18 Q14 12 18 9 Q22 5 24 9 Q26 5 30 9 Q34 12 30 18 Z" fill="white" stroke={color} stroke-width="1.4" />
<rect x="18" y="18" width="12" height="4" rx="1" fill="white" stroke={color} stroke-width="1.4" />
</svg>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
let { size = 40, animate = false, color = '#FD7E14' }: { size?: number; animate?: boolean; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 48 48" fill="none">
<rect x="6" y="28" width="36" height="10" rx="3" fill={color} opacity="0.18" />
<rect x="6" y="28" width="36" height="3" rx="1.5" fill={color} opacity="0.32" />
<circle cx="14" cy="26" r="2.4" fill={color} />
<circle cx="20" cy="26" r="2.4" fill={color} />
<circle cx="26" cy="26" r="2.4" fill={color} opacity="0.6" />
<g class={animate ? 'ic-chop' : ''} style="transform-origin: 38px 26px">
<rect x="32" y="12" width="3.5" height="14" rx="1.2" fill="#3A2A40" />
<path d="M35.5 12 L40 8 L40 24 L35.5 26 Z" fill="#C8CDD4" stroke="#3A2A40" stroke-width="0.8" />
</g>
</svg>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
let { size = 40, animate = false, color = '#E1306C' }: { size?: number; animate?: boolean; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 48 48" fill="none">
{#if animate}
<circle cx="20" cy="14" r="2.5" fill={color} opacity="0.5" class="ic-steam" style="--delay: 0s; --drift: 4px" />
<circle cx="26" cy="12" r="2" fill={color} opacity="0.5" class="ic-steam" style="--delay: 0.6s; --drift: -4px" />
<circle cx="23" cy="16" r="1.8" fill={color} opacity="0.4" class="ic-steam" style="--delay: 1.2s; --drift: 6px" />
{/if}
<ellipse cx="24" cy="20" rx="16" ry="3" fill="#3A2A40" />
<rect x="22" y="14" width="4" height="5" rx="1.5" fill="#3A2A40" />
<path d="M8 21 L10 38 Q10 42 14 42 L34 42 Q38 42 38 38 L40 21 Z" fill={color} />
{#if animate}
<circle cx="18" cy="32" r="1.5" fill="white" opacity="0.7" class="ic-bubble" style="animation-delay: 0.3s" />
<circle cx="26" cy="34" r="1.2" fill="white" opacity="0.6" class="ic-bubble" style="animation-delay: 0.7s" />
<circle cx="30" cy="32" r="1" fill="white" opacity="0.6" class="ic-bubble" style="animation-delay: 1.1s" />
{/if}
<rect x="4" y="24" width="6" height="3" rx="1.5" fill="#3A2A40" />
<rect x="38" y="24" width="6" height="3" rx="1.5" fill="#3A2A40" />
</svg>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
let { size = 20, color = 'currentColor', strokeWidth = 2.4 }: { size?: number; color?: string; strokeWidth?: number } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width={strokeWidth} stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
let { size = 20, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1015-7l3 1M21 4v5h-5" />
</svg>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
let { size = 20, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2.2" stroke-linecap="round">
<circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" />
</svg>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
let { size = 20, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 00.3 1.8l.1.1a2 2 0 11-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.8-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 11-4 0v-.1a1.7 1.7 0 00-1.1-1.5 1.7 1.7 0 00-1.8.3l-.1.1a2 2 0 11-2.8-2.8l.1-.1a1.7 1.7 0 00.3-1.8 1.7 1.7 0 00-1.5-1H3a2 2 0 110-4h.1a1.7 1.7 0 001.5-1.1 1.7 1.7 0 00-.3-1.8l-.1-.1a2 2 0 112.8-2.8l.1.1a1.7 1.7 0 001.8.3H9a1.7 1.7 0 001-1.5V3a2 2 0 114 0v.1a1.7 1.7 0 001 1.5 1.7 1.7 0 001.8-.3l.1-.1a2 2 0 112.8 2.8l-.1.1a1.7 1.7 0 00-.3 1.8V9a1.7 1.7 0 001.5 1H21a2 2 0 110 4h-.1a1.7 1.7 0 00-1.5 1z" />
</svg>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
let { size = 20, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 16V4M8 8l4-4 4 4" />
<path d="M20 16v3a2 2 0 01-2 2H6a2 2 0 01-2-2v-3" />
</svg>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
let { size = 18, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<path d="M12 2l1.6 6.4L20 10l-6.4 1.6L12 18l-1.6-6.4L4 10l6.4-1.6L12 2z" />
</svg>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
let { size = 18, color = 'currentColor' }: { size?: number; color?: string } = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />
</svg>

View File

@@ -1 +1,172 @@
@import 'tailwindcss';
/* ─── InstaChef design system ─────────────────────────────────────────────── */
/* Brand + shared tokens (theme-independent) */
:root {
--grad-1: #833AB4;
--grad-2: #C13584;
--grad-3: #E1306C;
--grad-4: #FD7E14;
--grad-5: #FCAF45;
--brand-gradient: linear-gradient(135deg, var(--grad-1) 0%, var(--grad-3) 45%, var(--grad-4) 75%, var(--grad-5) 100%);
--brand-gradient-soft: linear-gradient(135deg, #FCE9F3 0%, #FFEAD8 100%);
--purple: #833AB4;
--pink: #E1306C;
--orange: #FD7E14;
--yellow: #FCAF45;
--berry: #C13584;
--status-pending: #FCAF45;
--status-success: #2EA56A;
--status-error: #E64B4B;
--font-display: "Lilita One", "Caprasimo", system-ui, sans-serif;
--font-body: "DM Sans", -apple-system, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
}
/* Light theme */
.ic-root[data-theme="light"] {
--bg: #FFF8F5;
--bg-tint: #FFEFE4;
--surface: #FFFFFF;
--surface-2: #FDF1EC;
--surface-3: #F7E5DC;
--ink: #1A0B1F;
--ink-2: #3A2A40;
--muted: #7A6B7D;
--muted-2: #A8989C;
--border: rgba(26, 11, 31, 0.08);
--border-strong: rgba(26, 11, 31, 0.14);
--shadow-sm: 0 1px 2px rgba(26, 11, 31, 0.04), 0 2px 8px rgba(26, 11, 31, 0.04);
--shadow-md: 0 4px 12px rgba(26, 11, 31, 0.06), 0 12px 32px rgba(26, 11, 31, 0.05);
--shadow-lg: 0 12px 28px rgba(193, 53, 132, 0.18), 0 24px 60px rgba(131, 58, 180, 0.15);
}
/* Dark theme */
.ic-root[data-theme="dark"] {
--bg: #110510;
--bg-tint: #1A0A1F;
--surface: #1F0F24;
--surface-2: #2A1730;
--surface-3: #371E3E;
--ink: #FCEFE5;
--ink-2: #E0D2DA;
--muted: #A38FA8;
--muted-2: #6E5A73;
--border: rgba(255, 235, 245, 0.08);
--border-strong: rgba(255, 235, 245, 0.16);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4), 0 12px 32px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 12px 28px rgba(225, 48, 108, 0.35), 0 24px 60px rgba(131, 58, 180, 0.3);
}
/* ─── Base reset ──────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
height: 100%;
}
.ic-root {
font-family: var(--font-body);
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
min-height: 100dvh;
}
/* ─── Utility classes ─────────────────────────────────────────────────────── */
.ic-display {
font-family: var(--font-display);
font-weight: 400;
letter-spacing: -0.005em;
line-height: 1;
}
.ic-grad-text {
background: var(--brand-gradient);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.ic-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
.ic-scroll::-webkit-scrollbar { display: none; }
button.ic-btn,
button.ic-btn-reset {
background: none;
border: 0;
padding: 0;
cursor: pointer;
font: inherit;
color: inherit;
-webkit-tap-highlight-color: transparent;
}
/* ─── Animations ──────────────────────────────────────────────────────────── */
@keyframes ic-steam {
0% { transform: translateY(0) translateX(0) scale(0.6); opacity: 0; }
25% { opacity: 0.85; }
100% { transform: translateY(-44px) translateX(var(--drift, 4px)) scale(1.4); opacity: 0; }
}
.ic-steam {
animation: ic-steam 2.4s ease-out infinite;
animation-delay: var(--delay, 0s);
}
@keyframes ic-bubble {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-2px) scale(1.05); }
}
.ic-bubble { animation: ic-bubble 1.6s ease-in-out infinite; }
@keyframes ic-chop {
0%, 100% { transform: rotate(-30deg) translateY(0); }
50% { transform: rotate(-8deg) translateY(-2px); }
}
.ic-chop { animation: ic-chop 0.9s cubic-bezier(.7,0,.3,1) infinite; transform-origin: bottom right; }
@keyframes ic-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(200%); }
}
.ic-shimmer { animation: ic-shimmer 2s ease-in-out infinite; }
@keyframes ic-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(0.95); }
}
.ic-pulse { animation: ic-pulse 1.4s ease-in-out infinite; }
@keyframes ic-slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.ic-slide-up { animation: ic-slide-up 0.32s cubic-bezier(.2,.7,.2,1); }
@keyframes ic-fade {
from { opacity: 0; }
to { opacity: 1; }
}
.ic-fade { animation: ic-fade 0.24s ease-out; }
@keyframes ic-pop {
0% { transform: scale(0.6); opacity: 0; }
60% { transform: scale(1.08); opacity: 1; }
100% { transform: scale(1); }
}
.ic-pop { animation: ic-pop 0.4s cubic-bezier(.2,1.4,.4,1); }
@keyframes ic-live {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.ic-live { animation: ic-live 1.4s ease-in-out infinite; }

View File

@@ -4,10 +4,11 @@ import { render } from 'vitest-browser-svelte';
import Page from './+page.svelte';
describe('/+page.svelte', () => {
it('should render h1', async () => {
it('should render the InstaChef app shell', async () => {
render(Page);
const heading = page.getByRole('heading', { level: 1 });
await expect.element(heading).toBeInTheDocument();
// The app logo text is the primary visible brand element in the TopBar
const logo = page.getByText('InstaChef');
await expect.element(logo).toBeInTheDocument();
});
});

View File

@@ -2,13 +2,11 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import UrlInputSection from './components/UrlInputSection.svelte';
import AddUrlScreen from '../components/AddUrlScreen.svelte';
let status = $state('idle');
let logs = $state<string[]>([]);
let status = $state<'idle' | 'enqueuing' | 'success' | 'error'>('idle');
// URL param parsing for Share Target
// Instagram typically shares text that contains the URL, so we might need to parse it out
let sharedText = $derived($page.url.searchParams.get('text') || '');
let sharedUrl = $derived($page.url.searchParams.get('url') || '');
@@ -17,32 +15,27 @@
return match ? match[0] : null;
}
let targetUrl = $derived(sharedUrl || extractUrl(sharedText));
let targetUrl = $derived(sharedUrl || extractUrl(sharedText) || '');
// Track if we've already auto-processed to prevent duplicate processing
let hasAutoProcessed = $state(false);
// Auto-process URL if provided via share target
// Use onMount instead of $effect for side effects (SvelteKit best practice)
onMount(() => {
if (targetUrl && status === 'idle' && !hasAutoProcessed) {
hasAutoProcessed = true;
process();
process(targetUrl);
}
});
async function process(url?: string) {
const urlToProcess = url || targetUrl;
if (!urlToProcess) return;
async function process(url: string) {
if (!url) return;
status = 'enqueuing';
logs = [...logs, '🚀 Enqueuing extraction from: ' + urlToProcess];
try {
// Enqueue URL for background processing
const response = await fetch('/api/queue', {
method: 'POST',
body: JSON.stringify({ url: urlToProcess }),
body: JSON.stringify({ url }),
headers: { 'Content-Type': 'application/json' }
});
@@ -52,88 +45,100 @@
}
const queueItem = await response.json();
logs = [...logs, `✅ URL enqueued successfully with ID: ${queueItem.id}`];
logs = [...logs, '🔄 Redirecting to queue dashboard...'];
status = 'success';
// Small delay to show the success message
setTimeout(() => {
// Redirect to homepage (queue dashboard) with the queue item ID highlighted
goto(`/?highlight=${queueItem.id}`);
}, 1500);
}, 800);
} catch (e) {
status = 'error';
const errorMessage = e instanceof Error ? e.message : 'Unknown error';
logs = [...logs, `❌ Error: ${errorMessage}`];
console.error('Failed to enqueue:', e);
}
}
function retry() {
status = 'idle';
logs = [...logs, 'Retrying...'];
process();
}
</script>
<svelte:head>
<title>Share to InstaRecipe</title>
<meta name="description" content="Share Instagram recipes for extraction" />
<title>Add Recipe — InstaChef</title>
<meta name="description" content="Share Instagram recipes to InstaChef" />
</svelte:head>
<div class="mx-auto p-6 max-w-4xl">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2 text-center">Share to InstaRecipe</h1>
<p class="text-gray-600 text-center">
{#if targetUrl}
Processing your shared recipe...
{:else}
Paste an Instagram recipe URL to extract it
{/if}
</p>
</div>
{#if !targetUrl}
<UrlInputSection onProcess={process} />
{:else}
<!-- Status indicator for shared URLs -->
<div class="max-w-2xl mx-auto mb-8">
<div class="bg-white p-6 rounded-lg shadow-md border">
<h3 class="font-semibold mb-2">Processing URL:</h3>
<p class="text-sm text-gray-600 mb-4 break-all">{targetUrl}</p>
{#if status === 'enqueuing' || status === 'success'}
<!-- Transitional feedback while enqueuing -->
<div class="enqueue-screen">
<div class="enqueue-card">
{#if status === 'enqueuing'}
<div class="flex items-center space-x-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
<span class="text-blue-600">Enqueuing for processing...</span>
</div>
{:else if status === 'error'}
<div class="flex items-center space-x-2 mb-4">
<span class="text-red-600">❌ Error occurred</span>
</div>
<button
onclick={retry}
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
>
Retry
</button>
<div class="spinner"></div>
<div class="enqueue-title">Adding to queue…</div>
{:else}
<div class="text-green-600">✅ Ready to process</div>
<div class="enqueue-check"></div>
<div class="enqueue-title">Added! Redirecting…</div>
{/if}
<div class="enqueue-url">{targetUrl}</div>
</div>
</div>
{:else}
<AddUrlScreen
initialUrl={targetUrl}
onBack={() => goto('/')}
onSubmit={process}
/>
{/if}
<!-- Log viewer for feedback -->
{#if logs.length > 0}
<div class="max-w-2xl mx-auto mt-8">
<div class="bg-gray-50 p-4 rounded-lg border">
<h3 class="font-semibold mb-2">Process Log:</h3>
<div class="space-y-1 text-sm">
{#each logs as log}
<div class="text-gray-700">{log}</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
<style>
.enqueue-screen {
min-height: 100vh;
min-height: 100dvh;
background: var(--bg);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.enqueue-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 28px;
padding: 40px 32px;
text-align: center;
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.spinner {
width: 44px;
height: 44px;
border-radius: 50%;
border: 4px solid var(--border);
border-top-color: var(--pink);
animation: spin 0.75s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.enqueue-check {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--status-success);
color: #fff;
font-size: 22px;
display: flex;
align-items: center;
justify-content: center;
}
.enqueue-title {
font-family: 'Lilita One', system-ui, sans-serif;
font-size: 22px;
color: var(--ink);
}
.enqueue-url {
font-size: 12px;
font-family: var(--font-mono);
color: var(--muted);
word-break: break-all;
max-width: 300px;
}
</style>

View File

@@ -13,6 +13,16 @@ vi.mock('openai', () => ({
}
}));
// Mock env so tests pass in environments without .env (e.g. Docker CI builds)
vi.mock('$env/dynamic/private', () => ({
env: {
OPENAI_BASE_URL: 'http://localhost:11434/v1',
OPENAI_API_KEY: 'test-key',
LLM_MODEL: 'test-model',
LLM_REQUEST_TIMEOUT_MS: undefined
}
}));
// Import AFTER mocking
import { checkLLMHealth, checkModelAvailability } from '$lib/server/llm';

BIN
static/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 42 KiB

BIN
static/icon-256-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
static/icon-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
static/icon-512-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 138 KiB

View File

@@ -4,20 +4,26 @@
"start_url": "/",
"scope": "/",
"display": "standalone",
"theme_color": "#ffffff",
"background_color": "#ffffff",
"theme_color": "#FFF8F5",
"background_color": "#FFF8F5",
"icons": [
{
"src": "/favicon.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
"purpose": "any"
},
{
"src": "/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"share_target": {