28 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
Giancarmine Salucci
5b5bb947ef feat: replace Playwright extractor with yt-dlp subprocess
- Add instagram-extractor.ts: yt-dlp subprocess backend for Instagram
  caption extraction. No in-process browser state, maintained against
  Instagram frontend churn, supports cookies.txt for auth-walled reels.
- Add feature flag EXTRACTOR_BACKEND (ytdlp|playwright) in QueueProcessor
  so the old Playwright path remains available as fallback.
- Add 9 unit tests and 2 live-network integration tests for the new extractor.
- Dockerfile: install yt-dlp via pip3 alongside existing Chromium deps.
- docker-compose: expose EXTRACTOR_BACKEND env var (default: ytdlp).

Also in this commit:
- LLM: configurable per-request timeout via LLM_REQUEST_TIMEOUT_MS (default 120s);
  set maxRetries=0 to surface errors immediately; llama-swap /running health probe.
- QueueProcessor: thread progress callback through parser phase.
- LlmHealthIndicator: surface llama-swap loaded-model name.
- Logging: improve error serialization in queue-processor tests.
- .env.example: document llama-swap endpoint and model options.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 20:46:31 +02:00
Giancarmine Salucci
6849a1fb26 feat(RECIPE-0009): complete iteration 2 — ARIA-compliant footer icon contrast
Updated footer status bar icon colors from Tailwind 400-level to 600-level
variants to meet WCAG 2.1 SC 1.4.11 (3:1 minimum contrast ratio).

Changes:
- Notification icons: text-gray-400 → text-gray-600 (4.54:1 contrast)
- Status dots: bg-{green,yellow,red}-400 → bg-{green,yellow,red}-600
  (3.94:1, 4.02:1, 4.69:1 contrast respectively)

All footer icon states now exceed WCAG AA requirements by 31%+.
Build: PASSED | Tests: 278/278 PASSED
2026-02-19 10:06:57 +01:00
Giancarmine Salucci
08602073ac feat(RECIPE-0009): complete iteration 1 — footer status bar, icon-only buttons 2026-02-18 10:35:51 +01:00
Giancarmine Salucci
c98a2407a7 chore(RECIPE-0009): update FINDINGS.md for iteration 1 planning 2026-02-18 10:15:43 +01:00
Giancarmine Salucci
dfca35bde2 feat(RECIPE-0009): complete iteration 0 — deduplication, notifications, UI improvements 2026-02-18 06:00:48 +01:00
80 changed files with 5252 additions and 1078 deletions

View File

@@ -7,15 +7,23 @@
# ==============================================================================
# LLM Configuration (REQUIRED)
# ==============================================================================
# OpenAI-compatible API endpoint (OpenAI, LM Studio, Ollama, LiteLLM, etc.)
OPENAI_BASE_URL=http://localhost:1234/v1
# OpenAI-compatible API endpoint. Production: llama-swap on ideapad.
# llama-swap loads models on demand and unloads them after globalTTL (10 min).
OPENAI_BASE_URL=http://192.168.1.50:8080/v1
# API key for authentication
OPENAI_API_KEY=your-api-key-here
# API key for authentication (llama-swap accepts any non-empty value).
OPENAI_API_KEY=sk-llama-local
# Model to use for recipe extraction
# Examples: gpt-4o, gpt-4o-mini, llama-3.1, mistral, etc.
LLM_MODEL=google/gemma-3-4b
# Model to use for recipe extraction. Available on the ideapad llama-swap stack:
# gemma4-e4b-q6k (recommended — 4B, 65k ctx, 31 TPS)
# gemma4-e2b-q8_0 (faster — 2B, 65k ctx, 55 TPS)
# qwen3.5-4b-q8_0 (fallback — 22 TPS)
# phi4-mini-q8_0, granite-3.3-8b-q6k, plus larger MoE variants
LLM_MODEL=gemma4-e4b-q6k
# Per-request LLM timeout in ms. Must cover llama-swap cold-load (~530s for
# small models) plus generation time. Default 120000.
LLM_REQUEST_TIMEOUT_MS=120000
# ==============================================================================
# Queue Configuration (OPTIONAL)
@@ -55,9 +63,23 @@ VAPID_PUBLIC_KEY=BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-l
VAPID_PRIVATE_KEY=JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680
# ==============================================================================
# Authentication Scheduler (OPTIONAL)
# Instagram Extraction Backend
# ==============================================================================
# Enable automatic Instagram authentication renewal
# Which extractor to use:
# ytdlp (default) — yt-dlp subprocess, stateless, Sablier-safe
# playwright — legacy Playwright stealth scraper, requires
# secrets/auth.json + AUTH_SCHEDULER_* below
EXTRACTOR_BACKEND=ytdlp
# Optional Netscape-format cookies file for login-walled reels.
# yt-dlp picks it up automatically if it exists at /app/secrets/cookies.txt
# (Docker) or ./secrets/cookies.txt (local). No automation; export from a
# browser when an extraction starts hitting login walls.
# ==============================================================================
# Authentication Scheduler (LEGACY — only relevant when EXTRACTOR_BACKEND=playwright)
# ==============================================================================
# Enable automatic Instagram authentication renewal (Playwright backend only)
AUTH_SCHEDULER_ENABLED=true
# Renewal interval in minutes (default: 720 = 12 hours)

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,18 +1,38 @@
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 Playwright system dependencies
# Install yt-dlp (primary Instagram extractor) and Playwright system dependencies (fallback)
RUN apk add --no-cache \
python3 \
py3-pip \
chromium \
font-liberation \
font-noto \
font-noto-cjk
font-noto-cjk && \
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

@@ -32,6 +32,9 @@ services:
# Playwright Configuration
- DISPLAY=:99
# Extractor backend: 'ytdlp' (default) or 'playwright' (legacy fallback)
- EXTRACTOR_BACKEND=${EXTRACTOR_BACKEND:-ytdlp}
# Node.js Environment
- NODE_ENV=production
security_opt:

View File

@@ -2446,3 +2446,942 @@ npm audit # Must show 0 vulnerabilities (preserved from iteration 0)
- Verification: Sequential (after all fixes applied)
---
### [Planner] Research Notes - RECIPE-0009 (2026-02-18)
**Task:** Implement URL deduplication, automatic notification subscription, UI improvements, and notification redirect fix
#### Web Push API Permission Requirements - RECIPE-0009
**Research Date:** 2026-02-18
**Source:** W3C Push API Specification, MDN Web Docs, browser security policies, existing PushNotificationManager.ts implementation
**Security Requirement:**
Per W3C Push API specification, `Notification.requestPermission()` **requires user gesture** - cannot be called programmatically without user interaction.
**Browser Behavior:**
- **Permission States**: `"default"` (not requested), `"granted"` (allowed), `"denied"` (blocked)
- **User Gesture Required**: Click, tap, keypress triggers permission prompt
- **No Automatic Subscription**: Calling `requestPermission()` on page load fails silently or throws error in strict mode
- **Best Practice**: Attach to meaningful user action (button click preferred)
**Implementation Pattern for "Automatic" Subscription:**
Since true automatic subscription violates browser security policy, the approach is:
1. Listen for **first user interaction** (click/touch) anywhere on page
2. Check notification state: supported, not denied, not subscribed
3. Call `pushNotificationManager.subscribe()` on first interaction
4. Remove listener after first attempt (one-shot behavior)
**Code Pattern:**
```typescript
function setupAutoSubscribe() {
const attemptSubscribe = async () => {
const state = pushNotificationManager.getState();
if (state.supported && state.permission !== 'denied' && !state.subscribed) {
await pushNotificationManager.subscribe();
}
};
// Listen for first user interaction
document.addEventListener('click', attemptSubscribe, { once: true });
document.addEventListener('touchstart', attemptSubscribe, { once: true });
}
```
**Why This is "Best Practice" Automatic:**
- Requires minimal user action (any click/touch, not explicit "Enable" button)
- Non-intrusive (happens in background after natural interaction)
- Complies with W3C security requirements
- Avoids annoying permission prompts on page load
- Mobile-friendly (touchstart event)
**Alternative Approaches Considered:**
1. **Prompt on page load** — REJECTED: Violates security policy, creates poor UX
2. **Delay with setTimeout** — REJECTED: Still violates user gesture requirement
3. **IntersectionObserver trick** — REJECTED: Does not satisfy user gesture requirement
4. **Explicit "Enable Notifications" button** — VALID but less automatic than requested
**Conclusion:** First-interaction subscription is the most automatic approach allowed by browser standards while maintaining user control.
**References:**
- W3C Push API: https://www.w3.org/TR/push-api/
- MDN Notification.requestPermission: https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission
- Existing implementation: [src/lib/client/PushNotificationManager.ts](src/lib/client/PushNotificationManager.ts#L123-161)
---
#### Queue URL Deduplication Strategy - RECIPE-0009
**Research Date:** 2026-02-18
**Source:** QueueManager.ts architecture analysis, types.ts interface definitions, existing queue operations
**Current Queue Structure:**
```typescript
// QueueManager.ts line 44-45
private items: Map<string, QueueItem> = new Map();
```
- Storage: `Map<string, QueueItem>` with UUID keys
- No secondary index: URL lookups require linear search through values
- In-memory only: No persistence across server restarts
- Typical size: < 100 items (based on usage patterns)
**Deduplication Requirements:**
1. Check if URL already exists in queue before creating new item
2. If duplicate found: Return existing item, do NOT create new entry
3. API layer: Respond with `duplicate: true` and existing item details
4. Message level: Info (not error) - duplicate is expected behavior
**Implementation Approach:**
**Option A - Linear Search (Chosen):**
```typescript
findByUrl(url: string): QueueItem | undefined {
for (const item of this.items.values()) {
if (item.url === url) {
return item;
}
}
return undefined;
}
```
- **Complexity**: O(n) where n = queue size
- **Performance**: Acceptable for n < 100 (~1-2ms on modern hardware)
- **Simplicity**: No additional data structures, no risk of index desync
- **Consistency**: Single source of truth (items Map)
**Option B - Secondary URL Index (Rejected):**
```typescript
private items: Map<string, QueueItem> = new Map();
private urlIndex: Map<string, string> = new Map(); // url -> id
```
- **Complexity**: O(1) lookup, but requires maintaining two structures
- **Risk**: Index desync if remove() doesn't clean both Maps
- **Overhead**: 2x memory for keys, more complex implementation
- **Benefit**: Marginal for queue size < 1000
**Design Decision:** Option A (linear search) chosen for simplicity and reliability at current scale.
**API Response Format:**
```typescript
// Duplicate detected
{
duplicate: true,
message: "This recipe is already in the queue",
item: { id, url, status, enqueuedAt }
}
// New item
{
duplicate: false,
item: { id, url, status, enqueuedAt }
}
```
**User Experience:**
- Frontend checks `response.duplicate === true`
- Shows info toast: "This recipe is already in queue [View]"
- No error state, no failed request
- Links to existing queue item
**Edge Cases Handled:**
1. **Multiple rapid requests**: First wins, rest return duplicate
2. **URL normalization**: URLs compared as-is (no normalization in v1)
3. **Completed items**: Duplicates found even if status is success/error
4. **Retry scenario**: Retry uses existing queue item ID, not new URL submission
**Future Considerations:**
- URL normalization (trailing slash, query params, fragments)
- Time-based deduplication window (only check items from last N hours)
- Content-based deduplication (recipe fingerprint from parsed data)
**References:**
- QueueManager implementation: [src/lib/server/queue/QueueManager.ts](src/lib/server/queue/QueueManager.ts#L44-95)
- QueueItem type definition: [src/lib/server/queue/types.ts](src/lib/server/queue/types.ts#L57-100)
---
#### Service Worker Notification Data Flow - RECIPE-0009
**Research Date:** 2026-02-18
**Source:** Code analysis of notification pipeline from QueueProcessor → PushNotificationService → Service Worker
**Notification Payload Journey:**
**Step 1: QueueProcessor sends notification (Line 418-420)**
```typescript
await pushNotificationService.notifySuccess(
item.id,
item.results?.recipe?.name,
item.results?.tandoorUrl // ← tandoorUrl passed here
);
```
**Step 2: PushNotificationService creates payload (Lines 162-181)**
```typescript
const payload: NotificationPayload = {
type: 'success',
itemId,
recipeName,
body: recipeName ? `Recipe "${recipeName}" has been extracted...` : ...,
tag: `recipe-success-${itemId}`,
requireInteraction: true,
analytics: { ... }
};
if (tandoorUrl) {
payload.body += ' View it in Tandoor.';
// Note: tandoorUrl NOT explicitly added to payload object
}
```
**Issue Found:** `tandoorUrl` parameter received but **not stored in payload object**!
**Step 3: Service Worker receives push event (Line 123)**
```typescript
data = event.data.json(); // ← Payload becomes data object
```
**Step 4: Notification created with data (Lines 130-136)**
```typescript
const options: NotificationOptions = {
body: data.body,
data: data, // ← Full payload stored in data field
// ...
};
```
**Step 5: Click handler accesses data (Line 183-191)**
```typescript
const data = event.notification.data;
const action = event.action;
if (action === 'view' && data?.itemId) {
url = `/?highlight=${data.itemId}`;
}
```
**Current Bug:** `data.tandoorUrl` is undefined because `PushNotificationService.notifySuccess()` doesn't add it to payload.
**Fix Required in PushNotificationService.ts (Line 162-181):**
```typescript
const payload: NotificationPayload = {
type: 'success',
itemId,
recipeName,
tandoorUrl, // ← Add this line
body: recipeName ? `Recipe "${recipeName}" has been extracted...` : ...,
tag: `recipe-success-${itemId}`,
requireInteraction: true,
analytics: { ... }
};
```
**Then Service Worker Can Use It:**
```typescript
if (action === 'view' && data?.tandoorUrl) {
url = data.tandoorUrl; // Redirect to Tandoor
} else if (action === 'view' && data?.itemId) {
url = `/?highlight=${data.itemId}`; // Fallback to dashboard
}
```
**NotificationPayload Interface Update Required:**
```typescript
// Line 20-28 in PushNotificationService.ts
interface NotificationPayload {
title?: string;
body: string;
type: 'success' | 'error' | 'progress';
itemId: string;
recipeName?: string;
tandoorUrl?: string; // ← Add this line
tag?: string;
requireInteraction?: boolean;
analytics?: any;
}
```
**Verification:**
- QueueProcessor already passes `item.results?.tandoorUrl` correctly
- `item.results.tandoorUrl` is set by QueueProcessor line 329-331 when Tandoor upload succeeds
- Format: `${TANDOOR_BASE_URL}/view/recipe/${recipeId}`
- Example: `https://tandoor.example.com/view/recipe/123`
**References:**
- QueueProcessor notification call: [src/lib/server/queue/QueueProcessor.ts](src/lib/server/queue/QueueProcessor.ts#L418-420)
- PushNotificationService: [src/lib/server/notifications/PushNotificationService.ts](src/lib/server/notifications/PushNotificationService.ts#L158-183)
- Service Worker push handler: [src/service-worker.ts](src/service-worker.ts#L112-170)
- Service Worker click handler: [src/service-worker.ts](src/service-worker.ts#L176-207)
---
#### Homepage UI Component Visibility Analysis - RECIPE-0009
**Research Date:** 2026-02-18
**Source:** +page.svelte component structure analysis
**Current Behavior:**
**Add Recipe Component Locations:**
1. **Empty State** (Lines 280-302): Shows when `!loading && filteredItems.length === 0`
```svelte
{#if !loading && filteredItems.length === 0}
<div class="text-center py-12">
<!-- ... -->
<a href="/share" class="...">
Add Recipe URL
</a>
</div>
{/if}
```
2. **No Persistent Component**: When queue has items, no "Add Recipe" button visible
**User Complaint:** "Do not hide the add recipe component when there are items in the queue"
**Issue:** Add recipe link only appears in empty state conditional block.
**Solution:** Add persistent "Add Recipe" button to action bar (always visible)
**Implementation Location:** Lines 224-254 (Action Bar section)
**Before:**
```svelte
<div class="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
<div class="flex flex-wrap gap-2">
<!-- Filter Tabs -->
{#each filters as filterOption}
<button>...</button>
{/each}
</div>
<!-- Refresh Button -->
<button>...</button>
</div>
```
**After:**
```svelte
<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">
<!-- Filter Dropdown -->
<select>...</select>
<!-- Refresh Button -->
<button>...</button>
</div>
<!-- Add Recipe Button (ALWAYS VISIBLE) -->
<a href="/share" class="...">
Add Recipe URL
</a>
</div>
```
**Benefits:**
- Always accessible regardless of queue state
- Consistent UI (no disappearing elements)
- Better UX for power users (add multiple recipes quickly)
- Maintains empty state link for discoverability
**Filter Consolidation Rationale:**
Current filter tabs take significant horizontal space (5 buttons). Consolidating to dropdown:
- Frees space for persistent "Add Recipe" button
- Keeps filter + refresh on same row (per requirement)
- Mobile-friendly (dropdown vs. wrapping buttons)
- Still shows item counts in dropdown options
**References:**
- Homepage component: [src/routes/+page.svelte](src/routes/+page.svelte#L215-302)
- Empty state section: [src/routes/+page.svelte](src/routes/+page.svelte#L280-302)
---
### [Planner] Research Notes - RECIPE-0009 Iteration 1 (2026-02-18)
**Task:** UI enhancements - footer status bar, icon-only buttons, toggle Add Recipe visibility
#### Current Homepage UI Structure Analysis
**Research Date:** 2026-02-18
**Source:** Analysis of [src/routes/+page.svelte](src/routes/+page.svelte), iteration 0 implementation
**Current Implementation (Iteration 0)**:
1. **Connection Status Widget** (lines 369-383):
- Fixed position: bottom-right (`fixed bottom-4 right-4`)
- Shows connection status with colored dot + text label
- Shows last ping timestamp
- Will be REMOVED and replaced with footer bar
2. **Action Bar** (lines 263-297):
- Filter dropdown (lines 266-276)
- Refresh button with icon + text (lines 277-285)
- Add Recipe button with icon + text (lines 288-297)
- Currently: Add Recipe button ALWAYS visible (iteration 0 requirement)
3. **Empty State** (lines 310-342):
- Shows when `!loading && filteredItems.length === 0`
- Contains "Add Recipe URL" link
**Changes Required for Iteration 1**:
1. Remove floating connection status widget
2. Add footer status bar (icons only)
3. Convert refresh button to icon-only
4. Convert Add Recipe button to icon-only
5. Toggle Add Recipe button visibility (hide when empty, show when has items)
---
#### Footer Status Bar Design - RECIPE-0009 Iteration 1
**Research Date:** 2026-02-18
**Source:** Web PWA patterns, existing codebase styling patterns
**Design Requirements**:
- **Position**: Fixed at bottom (`fixed bottom-0 left-0 right-0`)
- **Layout**: Full width with max-width container matching page layout (`max-w-6xl`)
- **Content**: Two sections (notification status left, live updates right)
- **Display**: Icons only, no text labels
- **Accessibility**: title and aria-label attributes on interactive elements
- **Z-index**: `z-50` to ensure visibility above all content
- **Visual**: White background, top border, shadow for lift effect
**State Integration**:
Footer needs access to two state sources:
1. **Notification Status**: Via `pushNotificationManager.getState()`
- Need to add `notificationViewModel` state variable in +page.svelte
- Subscribe to state changes in `onMount`
- Cleanup subscription in `onDestroy`
2. **Connection Status**: Already exists as `connectionStatus` state
- Reuse existing variable
- States: 'connecting' | 'connected' | 'disconnected'
**Notification Icon Logic**:
```typescript
if (!supported || permission === 'denied') {
// Show bell with slash (not supported/denied)
icon = 'bell-slash';
color = 'text-gray-400';
} else if (subscribed) {
// Show bell icon (enabled)
icon = 'bell';
color = 'text-green-600';
} else {
// Show bell icon (available but not enabled)
icon = 'bell';
color = 'text-gray-400';
}
```
**Live Update Indicator Logic**:
```typescript
if (connectionStatus === 'connected') {
dotColor = 'bg-green-400';
title = 'Live updates active';
} else if (connectionStatus === 'connecting') {
dotColor = 'bg-yellow-400';
title = 'Connecting to live updates...';
} else {
dotColor = 'bg-red-400';
title = 'Live updates disconnected';
}
```
**Click Behavior**:
Clicking notification icon scrolls to NotificationSettings component:
```typescript
onclick={() => {
document.querySelector('[data-notification-settings]')?.scrollIntoView({ behavior: 'smooth' });
}}
```
Requires adding `data-notification-settings` attribute to NotificationSettings wrapper.
---
#### Icon-Only Button Patterns - RECIPE-0009 Iteration 1
**Research Date:** 2026-02-18
**Source:** Existing codebase button styles, Tailwind CSS documentation, WCAG 2.1 guidelines
**Current Button Pattern (with text)**:
```svelte
<button class="flex items-center space-x-2 px-4 py-2 ...">
<svg class="w-4 h-4" ... />
<span>Button Text</span>
</button>
```
- Padding: `px-4 py-2` (horizontal + vertical)
- Icon size: `w-4 h-4` (16x16px)
- Spacing: `space-x-2` (gap between icon and text)
**Icon-Only Button Pattern**:
```svelte
<button
title="Button description"
aria-label="Button description"
class="p-2 ..."
>
<svg class="w-5 h-5" ... />
</button>
```
**Changes**:
- Padding: `p-2` (square/circular button)
- Icon size: `w-5 h-5` (20x20px - slightly larger for better visibility)
- Remove: `space-x-2` class (no text to space from)
- Add: `title` attribute (tooltip on hover)
- Add: `aria-label` attribute (screen reader accessibility)
**Accessibility Requirements** (WCAG 2.1):
1. **Title Attribute**: Provides tooltip text for sighted users on hover
2. **Aria-label Attribute**: Provides accessible name for screen readers
3. **Minimum Touch Target**: 24x24px recommended (20x20px icon + 8px padding = 36x36px total ✓)
4. **Color Contrast**: Must meet 3:1 ratio for non-text (icons)
**Examples**:
Refresh button:
```svelte
<button
title="Refresh queue"
aria-label="Refresh queue"
class="p-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 ..."
>
<svg class="w-5 h-5" ... />
</button>
```
Add Recipe button:
```svelte
<a
href="/share"
title="Add recipe URL"
aria-label="Add recipe URL"
class="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 ..."
>
<svg class="w-5 h-5" ... />
</a>
```
---
#### Add Recipe Button Visibility Logic - RECIPE-0009 Iteration 1
**Research Date:** 2026-02-18
**Source:** context_compact.yaml requirement analysis, UX patterns
**Iteration 0 Implementation**:
- Add Recipe button ALWAYS visible in controls bar
- Rationale: User complained "do not hide the add recipe component when there are items in the queue"
**Iteration 1 Requirement**:
> "Toggle "Add Recipe" button visibility in controls bar (hide when queue empty, show when items exist - opposite of placeholder rule)"
**Interpretation**:
"Opposite of placeholder rule":
- Placeholder (empty state) shows when: `items.length === 0`
- Add Recipe button in controls shows when: `items.length > 0` (opposite condition)
**Logic**:
```svelte
{#if items.length > 0}
<a href="/share" title="Add recipe URL" aria-label="Add recipe URL" ...>
<svg ... />
</a>
{/if}
```
**Rationale**:
1. **Empty State**: When queue is empty, user sees empty state with centered "Add Recipe URL" link
2. **Non-Empty State**: When queue has items, controls bar shows Add Recipe button (icon-only)
3. **No Redundancy**: Button doesn't appear when empty state link is already visible
4. **Consistent Access**: User always has access to "Add Recipe" via either empty state link OR controls bar button
**UX Benefits**:
- Cleaner UI when queue is empty (no redundant button)
- Convenient access when queue has items (quick add more recipes)
- Fulfills opposite condition of empty state placeholder
---
#### Svelte 5 Notification State Management
**Research Date:** 2026-02-18
**Source:** Existing iteration 0 implementation, [PushNotificationManager.ts](src/lib/client/PushNotificationManager.ts)
**NotificationState Type**:
```typescript
interface NotificationState {
supported: boolean;
permission: NotificationPermission; // 'default' | 'granted' | 'denied'
subscribed: boolean;
loading: boolean;
error: string | null;
}
```
**State Subscription Pattern**:
```typescript
// Import type
import type { NotificationState } from '$lib/client/PushNotificationManager';
// Declare state
let notificationViewModel = $state<NotificationState | null>(null);
// Subscribe in onMount
onMount(() => {
// ... existing code ...
const unsubscribeNotifications = pushNotificationManager.onStateChange((newState) => {
notificationViewModel = newState;
});
return () => {
unsubscribeNotifications?.();
};
});
```
**Cleanup in onDestroy**:
Current onDestroy only cleans up `eventSource`. Need to also cleanup notification subscription:
```typescript
onDestroy(() => {
if (eventSource) {
console.log('[SSE] Closing connection on component destroy');
eventSource.close();
connectionStatus = 'disconnected';
}
// No cleanup needed - handled by onMount return callback
});
```
**Note**: Svelte 5's `onMount` return function handles cleanup automatically when component unmounts.
**State Access in Footer**:
Footer component needs null-safe access since initial state is `null`:
```svelte
{#if notificationViewModel}
{#if !notificationViewModel.supported || notificationViewModel.permission === 'denied'}
<!-- Show disabled icon -->
{:else if notificationViewModel.subscribed}
<!-- Show enabled icon -->
{:else}
<!-- Show available icon -->
{/if}
{:else}
<!-- Loading state - show gray icon -->
<svg class="w-5 h-5 text-gray-400" ... />
{/if}
```
**Initial State Handling**:
`pushNotificationManager.onStateChange()` sends initial state immediately on subscription, so `notificationViewModel` will be populated almost instantly after component mount.
---
**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,135 +0,0 @@
{
"cookies": [
{
"name": "csrftoken",
"value": "SDRORLyWEsWWty2ZoVGdER",
"domain": ".instagram.com",
"path": "/",
"expires": 1805935216.410097,
"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": 1779151216.410198,
"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": 1771980018,
"httpOnly": false,
"secure": true,
"sameSite": "Lax"
},
{
"name": "rur",
"value": "\"CLN\\05459661903731\\0541802911216:01fe0df629634929a5afb2d329011423972fad13b80d44aa8d827064e1ffa5112234bd5f\"",
"domain": ".instagram.com",
"path": "/",
"expires": -1,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://www.instagram.com",
"localStorage": [
{
"name": "chatd-deviceid",
"value": "47cc5bd4-431f-4054-8aaf-3e05aa303bd0"
},
{
"name": "hb_timestamp",
"value": "1771374318548"
},
{
"name": "IGSession",
"value": "k75336:1771377018401"
},
{
"name": "mutex_polaris_banzai",
"value": "dvrlku:1771375219401"
},
{
"name": "pixel_fire_ts",
"value": "1771121302843"
},
{
"name": "signal_flush_timestamp",
"value": "1771375100382"
},
{
"name": "Session",
"value": "2wm58s:1771375253401"
},
{
"name": "has_interop_upgraded",
"value": "{\"lastCheckedAt\":1766366944051,\"status\":false}"
},
{
"name": "ig_boost_on_web_campaign_upsell_shown",
"value": "false"
},
{
"name": "mutex_banzai",
"value": "dvrlku:1771375219401"
},
{
"name": "banzai:last_storage_flush",
"value": "1771366998859.2"
}
]
}
]
}

View File

@@ -26,7 +26,14 @@ type CaptionCandidate = {
brCount: number;
};
export type ProgressEventType = 'status' | 'method' | 'retry' | 'error' | 'thumbnail' | 'complete';
export type ProgressEventType =
| 'status'
| 'method'
| 'retry'
| 'error'
| 'thumbnail'
| 'complete'
| 'model_loading';
export interface ProgressEvent {
type: ProgressEventType;
@@ -1379,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

@@ -0,0 +1,240 @@
/**
* Instagram extractor — yt-dlp subprocess implementation.
*
* Replaces the Playwright-based scraper. yt-dlp is maintained against
* Instagram's frontend churn, has no in-process state, and works on public
* reels without authentication. Login-walled reels can be supported by
* dropping a Netscape-format cookies file at the path under SECRETS_DIR.
*/
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { logError } from './utils/logger';
import type { ExtractedContent, ProgressCallback } from './extraction';
const execFileAsync = promisify(execFile);
const YTDLP_TIMEOUT_MS = 60_000;
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;
}
return null;
}
interface YtDlpJson {
description?: string | null;
title?: string | null;
thumbnail?: string | null;
thumbnails?: Array<{ url?: string }>;
}
function pickThumbnailUrl(data: YtDlpJson): string | null {
if (data.thumbnail) return data.thumbnail;
const first = (data.thumbnails ?? []).find((t) => t?.url);
return first?.url ?? null;
}
async function fetchImageAsBase64(imageUrl: string): Promise<string | null> {
try {
const response = await fetch(imageUrl, {
signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS)
});
if (response.status !== 200) return null;
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.startsWith('image/')) return null;
const buf = Buffer.from(await response.arrayBuffer());
return `data:${contentType};base64,${buf.toString('base64')}`;
} catch (e) {
logError('[ytdlp] Thumbnail fetch failed', e);
return null;
}
}
function classifyYtDlpError(stderr: string): { recoverable: boolean; reason: string } {
const lower = stderr.toLowerCase();
if (
lower.includes('login required') ||
lower.includes('login_required') ||
lower.includes('private') ||
lower.includes('rate-limit') ||
lower.includes('rate limit')
) {
return {
recoverable: false,
reason:
'Instagram requires authentication for this reel. Drop a Netscape cookies.txt at secrets/cookies.txt and retry.'
};
}
if (lower.includes('unsupported url')) {
return { recoverable: false, reason: 'URL not recognised by yt-dlp.' };
}
if (lower.includes('http error 404') || lower.includes('does not exist')) {
return { recoverable: false, reason: 'Reel not found (404).' };
}
return { recoverable: true, reason: stderr.split('\n').filter(Boolean).slice(-2).join(' ') };
}
/**
* Extract caption text + thumbnail data-URL from an Instagram reel.
*
* Mirrors the signature of the legacy Playwright extractor so QueueProcessor
* needs no contract change. ProgressCallback events use existing types
* (`status`, `method`, `error`) so the SSE consumers do not need updates.
*/
export async function extractTextAndThumbnail(
url: string,
progressCallback?: ProgressCallback
): Promise<ExtractedContent> {
progressCallback?.({
type: 'status',
message: 'Invoking yt-dlp...',
timestamp: new Date().toISOString()
});
const cookies = resolveCookiePath();
if (cookies) {
progressCallback?.({
type: 'status',
message: `Using cookies from ${cookies}`,
timestamp: new Date().toISOString()
});
}
const args = [
'--dump-single-json',
'--skip-download',
'--no-warnings',
'--no-call-home',
'--socket-timeout',
'20',
'--user-agent',
USER_AGENT,
...(cookies ? ['--cookies', cookies] : []),
url
];
let stdout: string;
try {
const result = await execFileAsync('yt-dlp', args, {
timeout: YTDLP_TIMEOUT_MS,
maxBuffer: 10 * 1024 * 1024
});
stdout = result.stdout;
} catch (e: any) {
const stderr = String(e?.stderr ?? e?.message ?? '');
const code = e?.code;
if (code === 'ENOENT') {
throw new Error(
'yt-dlp is not installed in this container. Add it to the Dockerfile.'
);
}
const { recoverable, reason } = classifyYtDlpError(stderr);
progressCallback?.({
type: 'error',
message: `yt-dlp failed: ${reason}`,
timestamp: new Date().toISOString()
});
const err = new Error(`yt-dlp extraction failed: ${reason}`);
// QueueProcessor.isRecoverableError() classifies on message; surface keywords.
if (!recoverable) (err as any).nonRecoverable = true;
throw err;
}
let data: YtDlpJson;
try {
data = JSON.parse(stdout);
} catch (e) {
logError('[ytdlp] Failed to parse yt-dlp JSON output', e);
throw new Error('yt-dlp returned invalid JSON');
}
const bodyText = (data.description ?? data.title ?? '').trim();
if (!bodyText) {
throw new Error('yt-dlp returned no description for this reel');
}
progressCallback?.({
type: 'status',
message: `Caption extracted (${bodyText.length} chars)`,
timestamp: new Date().toISOString()
});
let thumbnail: string | null = null;
const thumbUrl = pickThumbnailUrl(data);
if (thumbUrl) {
progressCallback?.({
type: 'thumbnail',
message: 'Fetching thumbnail...',
timestamp: new Date().toISOString()
});
thumbnail = await fetchImageAsBase64(thumbUrl);
progressCallback?.({
type: 'status',
message: thumbnail ? 'Thumbnail fetched' : 'Thumbnail fetch failed (continuing without)',
timestamp: new Date().toISOString()
});
}
progressCallback?.({
type: 'complete',
message: 'Extraction complete',
timestamp: new Date().toISOString()
});
return { bodyText, thumbnail };
}

View File

@@ -2,15 +2,24 @@ import OpenAI from 'openai';
import { env } from '$env/dynamic/private';
import { logError } from './utils/logger';
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
const parseTimeoutMs = (raw: string | undefined): number => {
if (!raw) return DEFAULT_REQUEST_TIMEOUT_MS;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS;
};
export const createLLM = () => {
// Detect if we are using Ollama or OpenAI based on URL
const baseURL = env.OPENAI_BASE_URL;
const apiKey = env.OPENAI_API_KEY;
const model = env.LLM_MODEL || 'gpt-4o';
const timeout = parseTimeoutMs(env.LLM_REQUEST_TIMEOUT_MS);
console.log('[LLM] Initializing client...');
console.log('[LLM] Base URL:', baseURL);
console.log('[LLM] Model:', model);
console.log('[LLM] Request timeout (ms):', timeout);
if (!baseURL) {
throw new Error('OPENAI_BASE_URL environment variable is not set');
@@ -22,7 +31,9 @@ export const createLLM = () => {
const client = new OpenAI({
apiKey,
baseURL
baseURL,
timeout,
maxRetries: 0
});
return { client, model };
@@ -43,6 +54,47 @@ export async function checkLLMHealth(): Promise<boolean> {
}
}
/**
* Strip a trailing /v1 (or /v1/) from a base URL to get the llama-swap root.
* llama-swap exposes both /v1/* (OpenAI-compatible) and /running, /upstream, etc.
* at the bare root.
*/
function llamaSwapRoot(baseURL: string): string {
return baseURL.replace(/\/v1\/?$/, '').replace(/\/$/, '');
}
interface RunningModelEntry {
model: string;
state?: string;
}
/**
* Query llama-swap's /running endpoint and report whether `model` is currently
* loaded and ready to serve. Returns false on any error (treat as cold).
*
* Why we don't fold this into checkModelAvailability(): /v1/models lists every
* model llama-swap is configured to swap to (not just loaded ones), while
* /running returns only the in-VRAM instance. Both signals are useful.
*/
export async function isModelLoaded(model: string): Promise<boolean> {
const baseURL = env.OPENAI_BASE_URL;
if (!baseURL) return false;
try {
const url = `${llamaSwapRoot(baseURL)}/running`;
const response = await fetch(url, {
signal: AbortSignal.timeout(5_000)
});
if (!response.ok) return false;
const data = (await response.json()) as { running?: RunningModelEntry[] };
const running = data.running ?? [];
return running.some((m) => m.model === model && (m.state ?? 'ready') === 'ready');
} catch (e) {
logError('[LLM] isModelLoaded check failed', e);
return false;
}
}
/**
* Check if a specific model is available in the OpenAI-compatible API
* @param model - The model ID to check for availability

View File

@@ -1,8 +1,9 @@
import { createLLM, checkModelAvailability } from './llm';
import { createLLM, checkModelAvailability, isModelLoaded } from './llm';
import { zodResponseFormat } from 'openai/helpers/zod';
import { z } from 'zod';
import { RECIPE_DETECTION_PROMPT, RECIPE_EXTRACTION_PROMPT } from './prompts/recipe-extraction';
import { logError } from './utils/logger';
import type { ProgressCallback } from './extraction';
const RecipeSchema = z.object({
name: z.string(),
@@ -48,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');
@@ -144,11 +151,33 @@ export async function parseRecipe(text: string): Promise<Recipe> {
}
/**
* Complete workflow: detect recipe and parse if found
* Complete workflow: detect recipe and parse if found.
*
* Emits a `model_loading` progress event (if a callback is supplied) when the
* configured llama-swap model is not yet warm — the first request after idle
* blocks for several seconds while llama-swap loads the model into VRAM.
*
* @param text - The text to analyze
* @param progressCallback - Optional callback for surfacing cold-load state
* @returns Parsed recipe object if detected, null otherwise
*/
export async function extractRecipe(text: string): Promise<Recipe | null> {
export async function extractRecipe(
text: string,
progressCallback?: ProgressCallback
): Promise<Recipe | null> {
if (progressCallback) {
const { model } = createLLM();
const warm = await isModelLoaded(model);
if (!warm) {
progressCallback({
type: 'model_loading',
message: `Inference server cold — loading ${model} into VRAM (530s)...`,
data: { model },
timestamp: new Date().toISOString()
});
}
}
const isRecipe = await detectRecipe(text);
if (!isRecipe) {

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

@@ -47,6 +47,21 @@ export class QueueManager {
/** Set of subscriber callbacks */
private subscribers: Set<QueueUpdateCallback> = new Set();
/**
* Find queue item by URL
*
* @param url - Instagram URL to search for
* @returns Existing queue item or undefined
*/
findByUrl(url: string): QueueItem | undefined {
for (const item of this.items.values()) {
if (item.url === url) {
return item;
}
}
return undefined;
}
/**
* Add URL to processing queue
*
@@ -60,6 +75,13 @@ export class QueueManager {
* ```
*/
enqueue(url: string): QueueItem {
// Check for duplicate URL
const existingItem = this.findByUrl(url);
if (existingItem) {
console.log(`[QueueManager] Duplicate URL detected: ${url}, returning existing item ${existingItem.id}`);
return existingItem;
}
const now = new Date().toISOString();
const item: QueueItem = {
id: uuidv4(),

View File

@@ -12,15 +12,43 @@
*/
import { queueManager } from './QueueManager';
import { extractTextAndThumbnail } from '$lib/server/extraction';
import { extractTextAndThumbnail as extractWithPlaywright } from '$lib/server/extraction';
import { extractTextAndThumbnail as extractWithYtDlp } from '$lib/server/instagram-extractor';
import { extractRecipe } from '$lib/server/parser';
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService';
import { queueConfig } from './config';
import { logError } from '../utils/logger';
import type { ProgressEvent } from '$lib/server/extraction';
import type { ProgressEvent, ExtractedContent, ProgressCallback } from '$lib/server/extraction';
import type { QueueItem } from './types';
/**
* 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> {
// 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
*
@@ -250,7 +278,9 @@ export class QueueProcessor {
});
console.log(`[QueueProcessor] Parsing recipe: ${item.id}`);
const recipe = await extractRecipe(item.extractedText);
const recipe = await extractRecipe(item.extractedText, (event) => {
queueManager.addProgressEvent(item.id, event);
});
if (!recipe) {
throw new Error('Failed to parse recipe from extracted text');

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>
{@render children()}
<div class="ic-root" data-theme="light" id="ic-root">
{@render children()}
</div>
<!-- PWA Install Prompt -->
<InstallPrompt />

View File

@@ -3,341 +3,467 @@
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 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);
// 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 () => {
await loadQueueItems();
if (browser) {
startSSEConnection();
setupAutoSubscribe();
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();
connectionStatus = 'disconnected';
}
eventSource?.close();
connectionStatus = 'disconnected';
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';
}
}
function setupAutoSubscribe() {
if (hasAttemptedAutoSubscribe) return;
const attempt = async () => {
if (hasAttemptedAutoSubscribe) return;
hasAttemptedAutoSubscribe = true;
const state = pushNotificationManager.getState();
if (state.supported && state.permission !== 'denied' && !state.subscribed) {
await pushNotificationManager.subscribe();
}
};
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);
if (itemIndex >= 0) {
// Update existing item
items[itemIndex] = {
...items[itemIndex],
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 ?? []);
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">
<!-- Filter Tabs -->
<div class="flex flex-wrap gap-2">
{#each filters as filterOption}
<button
onclick={() => filter = filterOption.id}
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {filter === filterOption.id
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
>
{filterOption.name}
{#if filterOption.count > 0}
<span class="ml-1 {filter === filterOption.id ? 'text-blue-100' : 'text-gray-500'}">
({filterOption.count})
</span>
{/if}
</button>
{/each}
</div>
<!-- Refresh Button -->
<button
onclick={loadQueueItems}
disabled={loading}
class="flex items-center space-x-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 disabled:opacity-50 transition-colors"
>
<svg class="w-4 h-4 {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>
<span>Refresh</span>
</button>
</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>
{/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}
No items match the selected filter
{/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"
>
<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>
</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}
{#if loading}
<div class="loading-wrap">
<div class="spinner"></div>
</div>
{: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)}
/>
{/each}
{:else}
<!-- Cooking hero -->
{#if cooking && filter !== 'success' && filter !== 'error'}
<CookingHero item={cooking} onTap={() => (selectedItem = cooking)} />
{/if}
<!-- 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)}
>
{f.label}
<span class="chip-count">{f.count}</span>
</button>
{/each}
</div>
<!-- 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}
<!-- 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>
Paste Instagram link
</button>
</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}
<!-- Notification Settings - Always visible -->
<div class="mt-8">
<NotificationSettings />
</div>
<!-- Connection Status -->
<div class="fixed bottom-4 right-4">
<div class="flex items-center space-x-2 px-3 py-2 bg-white border rounded-lg shadow-sm text-sm">
<div class="w-2 h-2 rounded-full {
connectionStatus === 'connected' ? 'bg-green-400' :
connectionStatus === 'connecting' ? 'bg-yellow-400' :
'bg-red-400'
}"></div>
<span class="text-gray-600">
{connectionStatus === 'connected' ? 'Live updates' :
connectionStatus === 'connecting' ? 'Connecting...' :
'Disconnected'}
</span>
{#if lastPing}
<span class="text-xs text-gray-400">
({new Date(lastPing).toLocaleTimeString()})
</span>
{/if}
</div>
</div>
<!-- ── 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

@@ -1,34 +1,48 @@
import { json } from '@sveltejs/kit';
import { checkLLMHealth } from '$lib/server/llm';
import { env } from '$env/dynamic/private';
import { checkLLMHealth, isModelLoaded } from '$lib/server/llm';
/**
* Health check endpoint for LLM service
* Tests connectivity to LM Studio or OpenAI-compatible endpoint
* Health check endpoint for the LLM service (llama-swap on ideapad).
*
* Three states:
* - ok → endpoint reachable AND configured model is loaded in VRAM
* - warming → endpoint reachable but configured model not yet loaded
* (next request will trigger a cold load)
* - error → endpoint unreachable
*/
export async function GET() {
try {
const isHealthy = await checkLLMHealth();
const reachable = await checkLLMHealth();
const configuredModel = env.LLM_MODEL || 'gpt-4o';
if (isHealthy) {
return json({
status: 'healthy',
message: 'LLM service is accessible'
});
} else {
if (!reachable) {
return json(
{
status: 'unhealthy',
message: 'LLM service is not accessible'
status: 'error',
message: 'LLM service is not accessible',
configuredModel
},
{ status: 503 }
);
}
const warm = await isModelLoaded(configuredModel);
return json({
status: warm ? 'ok' : 'warming',
message: warm
? `Model ${configuredModel} loaded and ready`
: `Model ${configuredModel} configured; next request will trigger a cold load`,
configuredModel,
loaded: warm
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return json(
{
status: 'error',
message: errorMessage
message: errorMessage,
configuredModel: env.LLM_MODEL || 'gpt-4o'
},
{ status: 500 }
);

View File

@@ -50,15 +50,25 @@ export const POST: RequestHandler = async ({ request }) => {
throw new ValidationError(validation.error || 'Invalid Instagram URL');
}
// Enqueue the URL
// Check for duplicate before enqueueing
const existingItem = queueManager.findByUrl(url);
if (existingItem) {
// Return info response for duplicate
return json({
duplicate: true,
message: 'This recipe is already in the queue',
item: existingItem
}, { status: 200 }); // 200 OK, not an error
}
// Enqueue new URL
const queueItem = queueManager.enqueue(url);
// Return minimal response (full details available at GET /api/queue/{id})
// Return success response
return json({
id: queueItem.id,
url: queueItem.url,
status: queueItem.status,
enqueuedAt: queueItem.enqueuedAt
duplicate: false,
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>
</div>
</div>
<!-- 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>
<!-- 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>
</div>
</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"
>
{#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>
{: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>
{/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 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>
<!-- 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>
<!-- 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>
<!-- Install button -->
<button class="ic-btn-reset install-btn" onclick={handleInstall} disabled={installing}>
{#if installing}
Installing…
{:else}
<Download size={18} color="#fff" /> Install InstaChef
{/if}
</button>
<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>
</div>
<!-- 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 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;
}
to {
transform: translateY(0);
opacity: 1;
}
.overlay {
position: fixed;
inset: 0;
z-index: 70;
display: flex;
align-items: flex-end;
background: rgba(0, 0, 0, 0.42);
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
.sheet {
width: 100%;
border-radius: 32px 32px 0 0;
background: var(--bg);
box-shadow: 0 -20px 60px rgba(0, 0, 0, 0.3);
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
.handle-row {
display: flex;
justify-content: center;
margin-bottom: 16px;
padding-top: 14px;
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
.handle {
width: 44px;
height: 5px;
border-radius: 99px;
background: var(--border-strong);
}
.inner {
padding: 0 22px 30px;
}
.app-row {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 18px;
}
.app-icon {
border-radius: 16px;
box-shadow: 0 8px 22px rgba(225, 48, 108, 0.35);
flex-shrink: 0;
}
.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

@@ -12,10 +12,6 @@
let unsubscribe: (() => void) | null = null;
// Test notification state
let testLoading = $state<boolean>(false);
let testMessage = $state<string | null>(null);
onMount(() => {
// Subscribe to state changes
unsubscribe = pushNotificationManager.onStateChange((newState) => {
@@ -54,35 +50,6 @@
function canToggle(): boolean {
return viewModel.supported && viewModel.permission !== 'denied' && !viewModel.loading;
}
async function sendTestNotification(type: 'success' | 'error' | 'progress') {
testLoading = true;
testMessage = null;
try {
const response = await fetch('/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type })
});
if (!response.ok) {
throw new Error('Failed to send test notification');
}
const result = await response.json();
testMessage = `✓ Test ${type} notification sent to ${result.subscriberCount} subscriber(s)`;
} catch (error) {
testMessage = `✗ Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
} finally {
testLoading = false;
// Auto-dismiss message after 3 seconds
setTimeout(() => {
testMessage = null;
}, 3000);
}
}
</script>
<div class="bg-white border rounded-lg shadow-sm p-6">
@@ -212,54 +179,4 @@
</button>
</div>
</div>
<!-- Test Notification Buttons (only shown when subscribed) -->
{#if viewModel.subscribed}
<div class="mt-6 pt-6 border-t border-gray-200">
<h4 class="text-sm font-medium text-gray-900 mb-3">Test Notifications</h4>
<p class="text-sm text-gray-600 mb-4">
Send a test notification to verify your subscription is working correctly.
</p>
<div class="flex flex-wrap gap-2">
<button
onclick={() => sendTestNotification('success')}
disabled={testLoading || viewModel.loading}
class="px-3 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{testLoading ? 'Sending...' : 'Test Success'}
</button>
<button
onclick={() => sendTestNotification('error')}
disabled={testLoading || viewModel.loading}
class="px-3 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{testLoading ? 'Sending...' : 'Test Error'}
</button>
<button
onclick={() => sendTestNotification('progress')}
disabled={testLoading || viewModel.loading}
class="px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{testLoading ? 'Sending...' : 'Test Progress'}
</button>
</div>
<!-- Test Message -->
{#if testMessage}
<div class="mt-4 p-3 rounded-lg {testMessage.startsWith('✓') ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}">
<div class="flex items-start space-x-2">
<svg class="w-4 h-4 flex-shrink-0 mt-0.5 {testMessage.startsWith('✓') ? 'text-green-400' : 'text-red-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={testMessage.startsWith('✓') ? "M5 13l4 4L19 7" : "M6 18L18 6M6 6l12 12"}></path>
</svg>
<div class="text-sm {testMessage.startsWith('✓') ? 'text-green-800' : 'text-red-800'}">
{testMessage}
</div>
</div>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -1,246 +0,0 @@
import { page } from 'vitest/browser';
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import NotificationSettings from './NotificationSettings.svelte';
import { pushNotificationManager } from '$lib/client/PushNotificationManager';
// Mock the pushNotificationManager
vi.mock('$lib/client/PushNotificationManager', () => ({
pushNotificationManager: {
onStateChange: vi.fn(),
toggleSubscription: vi.fn()
}
}));
describe('NotificationSettings test buttons', () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock fetch using vi.stubGlobal for browser environment
vi.stubGlobal('fetch', vi.fn());
});
test('should not show test buttons when not subscribed', async () => {
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
callback({
supported: true,
permission: 'granted',
subscribed: false,
loading: false,
error: null
});
return () => {};
});
render(NotificationSettings);
// Test Notifications section should not be visible
const testSection = page.getByText('Test Notifications');
await expect.element(testSection).not.toBeInTheDocument();
});
test('should show test buttons when subscribed', async () => {
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
callback({
supported: true,
permission: 'granted',
subscribed: true,
loading: false,
error: null
});
return () => {};
});
render(NotificationSettings);
await expect.element(page.getByText('Test Success')).toBeInTheDocument();
await expect.element(page.getByText('Test Error')).toBeInTheDocument();
await expect.element(page.getByText('Test Progress')).toBeInTheDocument();
});
test('should send test success notification on button click', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ success: true, subscriberCount: 1 })
} as Response);
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
callback({
supported: true,
permission: 'granted',
subscribed: true,
loading: false,
error: null
});
return () => {};
});
render(NotificationSettings);
const button = page.getByText('Test Success');
await button.click();
expect(fetch).toHaveBeenCalledWith('/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'success' })
});
const successMessage = page.getByText(/✓ Test success notification sent to 1 subscriber/i);
await expect.element(successMessage).toBeInTheDocument();
});
test('should send test error notification on button click', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ success: true, subscriberCount: 2 })
} as Response);
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
callback({
supported: true,
permission: 'granted',
subscribed: true,
loading: false,
error: null
});
return () => {};
});
render(NotificationSettings);
const button = page.getByText('Test Error');
await button.click();
expect(fetch).toHaveBeenCalledWith('/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'error' })
});
const successMessage = page.getByText(/✓ Test error notification sent/i);
await expect.element(successMessage).toBeInTheDocument();
});
test('should send test progress notification on button click', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ success: true, subscriberCount: 1 })
} as Response);
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
callback({
supported: true,
permission: 'granted',
subscribed: true,
loading: false,
error: null
});
return () => {};
});
render(NotificationSettings);
const button = page.getByText('Test Progress');
await button.click();
expect(fetch).toHaveBeenCalledWith('/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'progress' })
});
const successMessage = page.getByText(/✓ Test progress notification sent/i);
await expect.element(successMessage).toBeInTheDocument();
});
test('should display error message on failed request', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 500
} as Response);
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
callback({
supported: true,
permission: 'granted',
subscribed: true,
loading: false,
error: null
});
return () => {};
});
render(NotificationSettings);
const button = page.getByText('Test Success');
await button.click();
const errorMessage = page.getByText(/✗ Error:/i);
await expect.element(errorMessage).toBeInTheDocument();
});
test('should auto-dismiss message after 3 seconds', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ success: true, subscriberCount: 1 })
} as Response);
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
callback({
supported: true,
permission: 'granted',
subscribed: true,
loading: false,
error: null
});
return () => {};
});
render(NotificationSettings);
const button = page.getByText('Test Success');
await button.click();
// Message should appear
const successMessage = page.getByText(/✓ Test success notification sent to 1 subscriber/i);
await expect.element(successMessage).toBeInTheDocument();
});
test('should disable buttons during loading', async () => {
// Create a promise that we can control
let resolvePromise: ((value: any) => void) | undefined;
const fetchPromise = new Promise((resolve) => {
resolvePromise = resolve;
});
vi.mocked(fetch).mockReturnValue(fetchPromise as any);
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
callback({
supported: true,
permission: 'granted',
subscribed: true,
loading: false,
error: null
});
return () => {};
});
render(NotificationSettings);
const successButton = page.getByRole('button', { name: 'Test Success' });
// Click a button to start loading
await successButton.click();
// Button should show "Sending..." text
const sendingButton = page.getByRole('button', { name: 'Sending...' }).first();
await expect.element(sendingButton).toBeInTheDocument();
// Cleanup - resolve the promise
resolvePromise?.({
ok: true,
json: async () => ({ success: true, subscriberCount: 1 })
});
});
});

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...
{#if status === 'enqueuing' || status === 'success'}
<!-- Transitional feedback while enqueuing -->
<div class="enqueue-screen">
<div class="enqueue-card">
{#if status === 'enqueuing'}
<div class="spinner"></div>
<div class="enqueue-title">Adding to queue…</div>
{:else}
Paste an Instagram recipe URL to extract it
<div class="enqueue-check"></div>
<div class="enqueue-title">Added! Redirecting…</div>
{/if}
</p>
<div class="enqueue-url">{targetUrl}</div>
</div>
</div>
{:else}
<AddUrlScreen
initialUrl={targetUrl}
onBack={() => goto('/')}
onSubmit={process}
/>
{/if}
{#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'}
<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>
{:else}
<div class="text-green-600">✅ Ready to process</div>
{/if}
</div>
</div>
{/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

@@ -1,9 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
type HealthStatus = 'checking' | 'ok' | 'warming' | 'error';
interface HealthState {
status: 'checking' | 'healthy' | 'unhealthy' | 'error';
status: HealthStatus;
message: string;
configuredModel: string;
lastChecked: Date | null;
}
@@ -14,6 +17,7 @@
let health = $state<HealthState>({
status: 'checking',
message: '',
configuredModel: '',
lastChecked: null
});
@@ -21,24 +25,26 @@
try {
const res = await fetch('/api/llm-health');
const data = await res.json();
const status: HealthStatus =
data.status === 'ok' ? 'ok' : data.status === 'warming' ? 'warming' : 'error';
health = {
status: data.status === 'healthy' ? 'healthy' : 'unhealthy',
message: data.message,
status,
message: data.message ?? '',
configuredModel: data.configuredModel ?? '',
lastChecked: new Date()
};
} catch (e) {
health = {
status: 'error',
message: e instanceof Error ? e.message : 'Network error',
configuredModel: '',
lastChecked: new Date()
};
}
}
// Use onMount instead of $effect for timer-based side effects
// onMount only runs in browser, no SSR guard needed
onMount(() => {
checkHealth(); // Initial check
checkHealth();
const interval = setInterval(checkHealth, pollInterval);
return () => clearInterval(interval);
});
@@ -48,12 +54,12 @@
<div class="flex items-center gap-1">
{#if health.status === 'checking'}
🟡 <span>Checking LLM...</span>
{:else if health.status === 'healthy'}
{:else if health.status === 'ok'}
🟢 <span class="text-green-600">LLM Ready</span>
{:else if health.status === 'unhealthy'}
🔴 <span class="text-red-600">LLM Unavailable</span>
{:else if health.status === 'warming'}
🟡 <span class="text-yellow-600">LLM Cold ({health.configuredModel})</span>
{:else}
🔴 <span class="text-red-600">LLM Error</span>
🔴 <span class="text-red-600">LLM Unavailable</span>
{/if}
</div>
<div class="text-xs text-gray-500" title={health.message}>

View File

@@ -0,0 +1,131 @@
<script lang="ts">
import { pushNotificationManager, type NotificationState } from '$lib/client/PushNotificationManager';
import { onMount } from 'svelte';
let viewModel = $state<NotificationState>({
supported: false,
permission: 'default',
subscribed: false,
loading: false,
error: null
});
let testLoading = $state<boolean>(false);
let testMessage = $state<string | null>(null);
let unsubscribe: (() => void) | null = null;
onMount(() => {
unsubscribe = pushNotificationManager.onStateChange((newState) => {
viewModel = newState;
});
return () => {
unsubscribe?.();
};
});
async function sendTestNotification(type: 'success' | 'error' | 'progress') {
testLoading = true;
testMessage = null;
try {
const response = await fetch('/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type })
});
if (!response.ok) {
throw new Error('Failed to send test notification');
}
const result = await response.json();
testMessage = `✓ Test ${type} notification sent to ${result.subscriberCount} subscriber(s)`;
} catch (error) {
testMessage = `✗ Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
} finally {
testLoading = false;
setTimeout(() => {
testMessage = null;
}, 3000);
}
}
</script>
<svelte:head>
<title>InstaRecipe - Notification Tests</title>
</svelte:head>
<div class="mx-auto p-6 max-w-4xl">
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2">Notification Testing</h1>
<p class="text-gray-600">Debug endpoint for testing push notifications</p>
</div>
{#if !viewModel.subscribed}
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<div class="flex items-start space-x-2">
<svg class="w-5 h-5 text-yellow-400 mt-0.5" 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>
<div>
<div class="text-sm font-medium text-yellow-800">Not Subscribed</div>
<div class="text-sm text-yellow-700">
You must enable push notifications on the <a href="/" class="underline">homepage</a> before testing.
</div>
</div>
</div>
</div>
{/if}
<div class="bg-white border rounded-lg shadow-sm p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">Test Notifications</h2>
<p class="text-sm text-gray-600 mb-6">
Send test notifications to verify your subscription is working correctly.
</p>
<div class="flex flex-wrap gap-3 mb-6">
<button
onclick={() => sendTestNotification('success')}
disabled={testLoading || !viewModel.subscribed}
class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{testLoading ? 'Sending...' : 'Test Success'}
</button>
<button
onclick={() => sendTestNotification('error')}
disabled={testLoading || !viewModel.subscribed}
class="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{testLoading ? 'Sending...' : 'Test Error'}
</button>
<button
onclick={() => sendTestNotification('progress')}
disabled={testLoading || !viewModel.subscribed}
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{testLoading ? 'Sending...' : 'Test Progress'}
</button>
</div>
{#if testMessage}
<div class="p-3 rounded-lg {testMessage.startsWith('✓') ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}">
<div class="flex items-start space-x-2">
<svg class="w-4 h-4 flex-shrink-0 mt-0.5 {testMessage.startsWith('✓') ? 'text-green-400' : 'text-red-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={testMessage.startsWith('✓') ? "M5 13l4 4L19 7" : "M6 18L18 6M6 6l12 12"}></path>
</svg>
<div class="text-sm {testMessage.startsWith('✓') ? 'text-green-800' : 'text-red-800'}">
{testMessage}
</div>
</div>
</div>
{/if}
</div>
<div class="mt-6">
<a href="/" class="text-sm text-blue-600 hover:text-blue-700">← Back to Dashboard</a>
</div>
</div>

View File

@@ -183,22 +183,36 @@ self.addEventListener('notificationclick', (event) => {
let url = '/';
if (action === 'view' && data?.itemId) {
url = `/?highlight=${data.itemId}`;
// Handle 'view' action - redirect to Tandoor if available
if (action === 'view') {
if (data?.tandoorUrl) {
// Success notification with Tandoor URL - redirect to recipe
url = data.tandoorUrl;
} else if (data?.itemId) {
// Fallback to dashboard highlight
url = `/?highlight=${data.itemId}`;
}
} else if (action === 'retry' && data?.itemId) {
// Navigate to dashboard and trigger retry via postMessage
// Navigate to dashboard and trigger retry
url = `/?highlight=${data.itemId}&action=retry`;
} else if (data?.itemId) {
// Default: highlight item in dashboard
url = `/?highlight=${data.itemId}`;
}
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientsList) => {
// Check if there's already a window/tab open
// For external URLs (Tandoor), always open new window
if (url.startsWith('http') && !url.includes(self.location.origin)) {
if (clients.openWindow) {
return clients.openWindow(url);
}
}
// For internal URLs, check for existing window
for (const client of clientsList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
return client.focus().then(() => {
// Send message to the client about the action
return client.postMessage({
type: 'notification-action',
action: action,
@@ -208,7 +222,7 @@ self.addEventListener('notificationclick', (event) => {
}
}
// If no window is open, open a new one
// No window open, open new one
if (clients.openWindow) {
return clients.openWindow(url);
}

View File

@@ -0,0 +1,49 @@
/**
* E2E integration test for the yt-dlp Instagram extractor.
*
* Makes real network calls (yt-dlp + Instagram CDN). Requires:
* - yt-dlp installed on PATH
* - Network access to instagram.com
* - EXTRACTOR_E2E=1 env var (safety guard to avoid running in normal test runs)
*
* Run with:
* EXTRACTOR_E2E=1 npm test -- src/tests/instagram-extractor.e2e.spec.ts
*/
import { describe, it, expect } from 'vitest';
import { extractTextAndThumbnail } from '$lib/server/instagram-extractor';
const E2E = !!process.env.EXTRACTOR_E2E;
describe.skipIf(!E2E)('instagram-extractor E2E (requires yt-dlp + network)', () => {
// Public reels that have previously been in the app queue
const TEST_REELS = [
{
url: 'https://www.instagram.com/reel/DX4XEDZt3qT/',
expectKeyword: 'pizza'
},
{
url: 'https://www.instagram.com/reel/DUtHm2EiD26/',
expectKeyword: 'noodles'
}
];
for (const { url, expectKeyword } of TEST_REELS) {
it(`extracts caption from ${url}`, async () => {
const events: { type: string; message: string }[] = [];
const result = await extractTextAndThumbnail(url, (e) =>
events.push(e as { type: string; message: string })
);
expect(result.bodyText.length).toBeGreaterThan(20);
expect(result.bodyText.toLowerCase()).toContain(expectKeyword);
if (result.thumbnail !== null) {
expect(result.thumbnail).toMatch(/^data:image\//);
}
expect(events.some((e) => e.type === 'complete')).toBe(true);
expect(events.some((e) => e.type === 'status' && e.message.includes('yt-dlp'))).toBe(true);
}, 90_000);
}
});

View File

@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock node:child_process before importing the SUT. The SUT uses
// promisify(execFile); without the Node-internal special handling, promisify
// would only forward the first callback arg. We sidestep that by returning a
// pre-promisified function tagged with util.promisify.custom that resolves
// to {stdout, stderr}.
import * as util from 'node:util';
const execFileMock = vi.fn();
vi.mock('node:child_process', () => {
const execFile: any = () => {
throw new Error('callback form not used in tests');
};
execFile[util.promisify.custom] = (cmd: string, args: string[], opts: any) =>
execFileMock(cmd, args, opts);
return { execFile };
});
const existsSyncMock = vi.fn();
vi.mock('node:fs', () => ({
existsSync: (p: string) => existsSyncMock(p)
}));
import { extractTextAndThumbnail } from '../lib/server/instagram-extractor';
describe('instagram-extractor (yt-dlp backend)', () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
execFileMock.mockReset();
existsSyncMock.mockReset();
existsSyncMock.mockReturnValue(false);
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('parses yt-dlp JSON and returns bodyText + thumbnail data URI', async () => {
execFileMock.mockResolvedValue({
stdout: JSON.stringify({
description: 'Pasta carbonara: 200g spaghetti, 100g pancetta, 2 eggs.',
thumbnail: 'https://example.com/thumb.jpg'
}),
stderr: ''
});
globalThis.fetch = vi.fn().mockResolvedValue({
status: 200,
headers: { get: () => 'image/jpeg' },
arrayBuffer: () => Promise.resolve(new Uint8Array([1, 2, 3]).buffer)
}) as unknown as typeof fetch;
const result = await extractTextAndThumbnail('https://www.instagram.com/reel/abc123/');
expect(result.bodyText).toContain('carbonara');
expect(result.thumbnail).toMatch(/^data:image\/jpeg;base64,/);
});
it('falls back to first thumbnails entry when top-level thumbnail is absent', async () => {
execFileMock.mockResolvedValue({
stdout: JSON.stringify({
description: 'Recipe text',
thumbnails: [{ url: 'https://example.com/alt-thumb.jpg' }]
}),
stderr: ''
});
globalThis.fetch = vi.fn().mockResolvedValue({
status: 200,
headers: { get: () => 'image/png' },
arrayBuffer: () => Promise.resolve(new Uint8Array([4, 5, 6]).buffer)
}) as unknown as typeof fetch;
const result = await extractTextAndThumbnail('https://www.instagram.com/reel/abc/');
expect(result.thumbnail).toMatch(/^data:image\/png;base64,/);
});
it('returns null thumbnail when fetch fails', async () => {
execFileMock.mockResolvedValue({
stdout: JSON.stringify({
description: 'Recipe text',
thumbnail: 'https://example.com/missing.jpg'
}),
stderr: ''
});
globalThis.fetch = vi.fn().mockResolvedValue({
status: 404,
headers: { get: () => 'text/html' },
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0))
}) as unknown as typeof fetch;
const result = await extractTextAndThumbnail('https://www.instagram.com/reel/abc/');
expect(result.bodyText).toBe('Recipe text');
expect(result.thumbnail).toBeNull();
});
it('passes --cookies flag when secrets/cookies.txt exists', async () => {
existsSyncMock.mockImplementation((p: string) => p.endsWith('cookies.txt'));
execFileMock.mockResolvedValue({
stdout: JSON.stringify({ description: 'x', thumbnail: null }),
stderr: ''
});
await extractTextAndThumbnail('https://www.instagram.com/reel/abc/');
const [, args] = execFileMock.mock.calls[0];
expect(args).toContain('--cookies');
const idx = (args as string[]).indexOf('--cookies');
expect((args as string[])[idx + 1]).toMatch(/cookies\.txt$/);
});
it('omits --cookies flag when no cookie file is present', async () => {
existsSyncMock.mockReturnValue(false);
execFileMock.mockResolvedValue({
stdout: JSON.stringify({ description: 'x', thumbnail: null }),
stderr: ''
});
await extractTextAndThumbnail('https://www.instagram.com/reel/abc/');
const [, args] = execFileMock.mock.calls[0];
expect(args).not.toContain('--cookies');
});
it('throws non-recoverable error on "Login required" stderr', async () => {
const err: any = new Error('yt-dlp failed');
err.stderr = 'ERROR: [Instagram] xyz: Login required to access this post.';
execFileMock.mockRejectedValue(err);
await expect(
extractTextAndThumbnail('https://www.instagram.com/reel/private/')
).rejects.toThrow(/authentication/);
});
it('throws clear error when yt-dlp binary is missing (ENOENT)', async () => {
const err: any = new Error('not found');
err.code = 'ENOENT';
execFileMock.mockRejectedValue(err);
await expect(
extractTextAndThumbnail('https://www.instagram.com/reel/abc/')
).rejects.toThrow(/yt-dlp is not installed/);
});
it('throws when description is empty', async () => {
execFileMock.mockResolvedValue({
stdout: JSON.stringify({ description: '', thumbnail: null }),
stderr: ''
});
await expect(
extractTextAndThumbnail('https://www.instagram.com/reel/empty/')
).rejects.toThrow(/no description/);
});
it('emits progress events through the callback', async () => {
execFileMock.mockResolvedValue({
stdout: JSON.stringify({ description: 'x', thumbnail: null }),
stderr: ''
});
const events: any[] = [];
await extractTextAndThumbnail('https://www.instagram.com/reel/abc/', (e) =>
events.push(e)
);
expect(events.some((e) => e.type === 'status' && e.message.includes('yt-dlp'))).toBe(true);
expect(events.some((e) => e.type === 'complete')).toBe(true);
});
});

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';

View File

@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import HomePage from '../routes/+page.svelte';
import { pushNotificationManager } from '$lib/client/PushNotificationManager';
// Mock $app/environment
vi.mock('$app/environment', () => ({
browser: true
}));
// Mock fetch for queue API calls
globalThis.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ items: [], stats: { total: 0, pending: 0, in_progress: 0, completed: 0, error: 0 } })
} as Response)
);
// Mock EventSource for SSE
globalThis.EventSource = vi.fn(() => ({
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
close: vi.fn(),
onerror: null,
onmessage: null,
onopen: null,
readyState: 0,
url: '',
withCredentials: false,
CONNECTING: 0,
OPEN: 1,
CLOSED: 2,
dispatchEvent: vi.fn()
})) as any;
vi.mock('$lib/client/PushNotificationManager', () => ({
pushNotificationManager: {
getState: vi.fn(),
subscribe: vi.fn(),
onStateChange: vi.fn(() => () => {})
}
}));
describe('Automatic Notification Subscription', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(pushNotificationManager.getState).mockReturnValue({
supported: true,
permission: 'default',
subscribed: false,
loading: false,
error: null
});
});
it('should auto-subscribe on first user click when supported and not subscribed', async () => {
render(HomePage);
// Wait for component to mount and set up event listeners
await new Promise(resolve => setTimeout(resolve, 100));
// Simulate user click anywhere on page
document.body.click();
// Wait for event handlers to process
await new Promise(resolve => setTimeout(resolve, 50));
expect(pushNotificationManager.subscribe).toHaveBeenCalledTimes(1);
});
it('should not auto-subscribe if permission denied', async () => {
vi.mocked(pushNotificationManager.getState).mockReturnValue({
supported: true,
permission: 'denied',
subscribed: false,
loading: false,
error: null
});
render(HomePage);
await new Promise(resolve => setTimeout(resolve, 100));
document.body.click();
await new Promise(resolve => setTimeout(resolve, 50));
expect(pushNotificationManager.subscribe).not.toHaveBeenCalled();
});
it('should not auto-subscribe if already subscribed', async () => {
vi.mocked(pushNotificationManager.getState).mockReturnValue({
supported: true,
permission: 'granted',
subscribed: true,
loading: false,
error: null
});
render(HomePage);
await new Promise(resolve => setTimeout(resolve, 100));
document.body.click();
await new Promise(resolve => setTimeout(resolve, 50));
expect(pushNotificationManager.subscribe).not.toHaveBeenCalled();
});
it('should only attempt subscription once', async () => {
render(HomePage);
await new Promise(resolve => setTimeout(resolve, 100));
document.body.click();
document.body.click();
document.body.click();
await new Promise(resolve => setTimeout(resolve, 50));
expect(pushNotificationManager.subscribe).toHaveBeenCalledTimes(1);
});
});

View File

@@ -37,13 +37,14 @@ describe('Queue API Endpoints', () => {
expect(response.status).toBe(200);
const data = await response.json();
expect(data.id).toBeTruthy();
expect(data.url).toBe('https://instagram.com/p/ABC123');
expect(data.status).toBe('pending');
expect(data.enqueuedAt).toBeTruthy();
expect(data.duplicate).toBe(false);
expect(data.item.id).toBeTruthy();
expect(data.item.url).toBe('https://instagram.com/p/ABC123');
expect(data.item.status).toBe('pending');
expect(data.item.enqueuedAt).toBeTruthy();
// Verify item exists in queue
const item = queueManager.get(data.id);
// Verify item exists in queue
const item = queueManager.get(data.item.id);
expect(item).toBeTruthy();
expect(item?.url).toBe('https://instagram.com/p/ABC123');
});
@@ -63,10 +64,11 @@ describe('Queue API Endpoints', () => {
expect(response.status).toBe(200);
const data = await response.json();
expect(data.url).toBe('https://www.instagram.com/p/XYZ789');
expect(data.duplicate).toBe(false);
expect(data.item.url).toBe('https://www.instagram.com/p/XYZ789');
// Verify item exists in queue
const item = queueManager.get(data.id);
// Verify item exists in queue
const item = queueManager.get(data.item.id);
expect(item).toBeTruthy();
expect(item?.url).toBe('https://www.instagram.com/p/XYZ789');
});
@@ -83,7 +85,8 @@ describe('Queue API Endpoints', () => {
const response = await queuePOST({ request } as any);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.url).toBe('https://instagram.com/reel/ABC123');
expect(data.duplicate).toBe(false);
expect(data.item.url).toBe('https://instagram.com/reel/ABC123');
});
it('should accept Instagram URLs with query parameters', async () => {
@@ -98,7 +101,8 @@ describe('Queue API Endpoints', () => {
const response = await queuePOST({ request } as any);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.url).toBe(
expect(data.duplicate).toBe(false);
expect(data.item.url).toBe(
'https://www.instagram.com/reel/DSevV5CDcNm/?utm_source=ig_web_copy_link'
);
});
@@ -234,6 +238,37 @@ describe('Queue API Endpoints', () => {
});
});
describe('POST /api/queue deduplication', () => {
it('should return duplicate flag when URL already exists', async () => {
const url = 'https://instagram.com/p/DUP123';
// First request
const request1 = new Request('http://localhost/api/queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const response1 = await queuePOST({ request: request1 } as any);
const data1 = await response1.json();
expect(data1.duplicate).toBe(false);
// Second request (duplicate)
const request2 = new Request('http://localhost/api/queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const response2 = await queuePOST({ request: request2 } as any);
const data2 = await response2.json();
expect(response2.status).toBe(200);
expect(data2.duplicate).toBe(true);
expect(data2.message).toContain('already in the queue');
expect(data2.item.id).toBe(data1.item.id);
});
});
describe('GET /api/queue', () => {
it('should return empty list when no items', async () => {
const url = new URL('http://localhost/api/queue');

View File

@@ -353,4 +353,28 @@ describe('QueueManager', () => {
expect(callback3).toHaveBeenCalled();
});
});
describe('deduplication', () => {
it('should return existing item when enqueueing duplicate URL', () => {
const url = 'https://instagram.com/p/ABC123';
const firstItem = queueManager.enqueue(url);
const secondItem = queueManager.enqueue(url);
expect(secondItem.id).toBe(firstItem.id);
expect(queueManager.getAll()).toHaveLength(1);
});
it('should find item by URL', () => {
const url = 'https://instagram.com/p/TEST123';
const item = queueManager.enqueue(url);
const found = queueManager.findByUrl(url);
expect(found?.id).toBe(item.id);
});
it('should return undefined when URL not found', () => {
const found = queueManager.findByUrl('https://instagram.com/p/NOTFOUND');
expect(found).toBeUndefined();
});
});
});

View File

@@ -18,7 +18,7 @@ vi.mock('$lib/server/tandoor', () => ({
}));
import { queueManager } from '$lib/server/queue/QueueManager';
import * as extraction from '$lib/server/extraction';
import * as instagramExtractor from '$lib/server/instagram-extractor';
import { queueProcessor } from '$lib/server/queue/QueueProcessor';
describe('QueueProcessor logging', () => {
@@ -50,8 +50,8 @@ describe('QueueProcessor logging', () => {
(complexError as any).code = 'ERR_TEST';
(complexError as any).details = { phase: 'extraction', retries: 3 };
// Mock extraction to fail BEFORE starting processor
const extractSpy = vi.spyOn(extraction, 'extractTextAndThumbnail');
// Mock extraction to fail BEFORE starting processor (default backend = ytdlp)
const extractSpy = vi.spyOn(instagramExtractor, 'extractTextAndThumbnail');
extractSpy.mockRejectedValueOnce(complexError);
const item = queueManager.enqueue('https://instagram.com/p/TEST');

View File

@@ -35,13 +35,21 @@ vi.mock('$lib/server/queue/config', () => ({
}
}));
// Mock external dependencies BEFORE importing QueueProcessor
// Mock external dependencies BEFORE importing QueueProcessor.
// QueueProcessor.extractionPhase picks between two extractor modules based on
// EXTRACTOR_BACKEND; mock both so behavior is identical regardless of default.
vi.mock('$lib/server/extraction', () => ({
extractTextAndThumbnail: vi.fn().mockResolvedValue({
bodyText: 'Default recipe text',
thumbnail: null
})
}));
vi.mock('$lib/server/instagram-extractor', () => ({
extractTextAndThumbnail: vi.fn().mockResolvedValue({
bodyText: 'Default recipe text',
thumbnail: null
})
}));
vi.mock('$lib/server/parser', () => ({
extractRecipe: vi.fn().mockResolvedValue({
@@ -62,11 +70,16 @@ vi.mock('$lib/server/tandoor', () => ({
})
}));
import { extractTextAndThumbnail } from '$lib/server/extraction';
import { extractTextAndThumbnail as extractFromExtraction } from '$lib/server/extraction';
import { extractTextAndThumbnail as extractFromYtDlp } from '$lib/server/instagram-extractor';
import { extractRecipe } from '$lib/server/parser';
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
import * as configModule from '$lib/server/queue/config';
// Alias used by existing assertions; default backend is ytdlp so the new
// instagram-extractor mock is what the processor actually invokes.
const extractTextAndThumbnail = extractFromYtDlp;
// Import processor AFTER mocks - it will auto-start (imported for side effects)
import '$lib/server/queue/QueueProcessor';
@@ -78,8 +91,13 @@ describe('QueueProcessor Integration Tests', () => {
// Reset mocks and their implementations
vi.resetAllMocks();
// Set default mock implementations
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
// Set default mock implementations on BOTH backend modules so the test
// behavior is invariant to EXTRACTOR_BACKEND.
vi.mocked(extractFromExtraction).mockResolvedValue({
bodyText: 'Default recipe text',
thumbnail: null
});
vi.mocked(extractFromYtDlp).mockResolvedValue({
bodyText: 'Default recipe text',
thumbnail: null
});

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": {