Compare commits
23 Commits
feature/RE
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecd2aef971 | ||
|
|
61876f18e5 | ||
|
|
a389b0db15 | ||
|
|
d09bf80088 | ||
|
|
226b2e7f15 | ||
|
|
73e10730dc | ||
|
|
c9f5300272 | ||
|
|
958353d15a | ||
|
|
10c4f78ace | ||
|
|
8c25bce400 | ||
|
|
22280d5536 | ||
|
|
9e14613746 | ||
|
|
561c2843b1 | ||
|
|
1f3bfe2119 | ||
|
|
8d979a9305 | ||
|
|
040ae17c12 | ||
|
|
91aca8d35a | ||
|
|
bd00595ded | ||
|
|
d36629d5f0 | ||
|
|
573cf49ac5 | ||
|
|
0b9f598c7d | ||
|
|
97355d859f | ||
|
|
b4edfe2ac1 |
46
.gitea/workflows/build.yml
Normal 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
@@ -18,6 +18,9 @@ Thumbs.db
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Secrets (never commit cookies, tokens or credentials)
|
||||
secrets/
|
||||
|
||||
# Local certificates
|
||||
.ssl/
|
||||
|
||||
|
||||
27
Dockerfile
@@ -1,4 +1,23 @@
|
||||
FROM node:24-alpine
|
||||
# ── stage: tester ────────────────────────────────────────────────────────────
|
||||
FROM node:24-alpine AS tester
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
# Run only server-side unit tests; browser (Playwright) tests require a full
|
||||
# Chromium environment that is not available in the Alpine build stage.
|
||||
RUN npm run test:unit -- --run --project=server
|
||||
|
||||
# ── stage: builder ───────────────────────────────────────────────────────────
|
||||
FROM node:24-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ── stage: runner ────────────────────────────────────────────────────────────
|
||||
FROM node:24-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Install yt-dlp (primary Instagram extractor) and Playwright system dependencies (fallback)
|
||||
@@ -12,10 +31,8 @@ RUN apk add --no-cache \
|
||||
pip3 install --break-system-packages yt-dlp
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
RUN npm ci --omit=dev
|
||||
COPY --from=builder /app/build ./build
|
||||
|
||||
EXPOSE 3000
|
||||
ENV NODE_ENV=production
|
||||
|
||||
240
docs/FINDINGS.md
@@ -3145,3 +3145,243 @@ Footer component needs null-safe access since initial state is `null`:
|
||||
**Document Version:** 3.0
|
||||
**Last Updated by:** Planner Agent (RECIPE-0009 Iteration 1)
|
||||
**Next Update:** Developer Agent
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# Session Findings: Instagram Extraction & Production Lessons
|
||||
|
||||
*Recorded during active development sessions (2025–2026). 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
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "csrftoken",
|
||||
"value": "SDRORLyWEsWWty2ZoVGdER",
|
||||
"domain": ".instagram.com",
|
||||
"path": "/",
|
||||
"expires": 1805950837.432368,
|
||||
"httpOnly": false,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "datr",
|
||||
"value": "isQuaeXe5-2mFvFSOdcgVq0u",
|
||||
"domain": ".instagram.com",
|
||||
"path": "/",
|
||||
"expires": 1799232653.525143,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "None"
|
||||
},
|
||||
{
|
||||
"name": "ig_did",
|
||||
"value": "5650C8B9-B8D8-4102-9B49-F0668CE34202",
|
||||
"domain": ".instagram.com",
|
||||
"path": "/",
|
||||
"expires": 1796208680.653147,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "mid",
|
||||
"value": "aS7EigALAAHxXAxrkYg18Fzi-SR7",
|
||||
"domain": ".instagram.com",
|
||||
"path": "/",
|
||||
"expires": 1799232653.525191,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "None"
|
||||
},
|
||||
{
|
||||
"name": "ds_user_id",
|
||||
"value": "59661903731",
|
||||
"domain": ".instagram.com",
|
||||
"path": "/",
|
||||
"expires": 1779166837.432468,
|
||||
"httpOnly": false,
|
||||
"secure": true,
|
||||
"sameSite": "None"
|
||||
},
|
||||
{
|
||||
"name": "sessionid",
|
||||
"value": "59661903731%3AbekaIlo4nn7x2n%3A29%3AAYhv1LJUsfRtBSH-WmDLVrxiM7T9UotIOM3XY3iHKQ",
|
||||
"domain": ".instagram.com",
|
||||
"path": "/",
|
||||
"expires": 1797910987.674116,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "wd",
|
||||
"value": "1280x720",
|
||||
"domain": ".instagram.com",
|
||||
"path": "/",
|
||||
"expires": 1771995638,
|
||||
"httpOnly": false,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
},
|
||||
{
|
||||
"name": "rur",
|
||||
"value": "\"CLN\\05459661903731\\0541802926837:01fecdef958a382ffda59c31905f1176573c8f80e9cf231a912f3a861e2b46301946954f\"",
|
||||
"domain": ".instagram.com",
|
||||
"path": "/",
|
||||
"expires": -1,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "https://www.instagram.com",
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "chatd-deviceid",
|
||||
"value": "2190f1d6-0ca8-465c-aa86-533cb7538906"
|
||||
},
|
||||
{
|
||||
"name": "hb_timestamp",
|
||||
"value": "1771389939252"
|
||||
},
|
||||
{
|
||||
"name": "IGSession",
|
||||
"value": "d498hi:1771392639144"
|
||||
},
|
||||
{
|
||||
"name": "pixel_fire_ts",
|
||||
"value": "1771121302843"
|
||||
},
|
||||
{
|
||||
"name": "signal_flush_timestamp",
|
||||
"value": "1771389939261"
|
||||
},
|
||||
{
|
||||
"name": "Session",
|
||||
"value": "czylty:1771390874144"
|
||||
},
|
||||
{
|
||||
"name": "has_interop_upgraded",
|
||||
"value": "{\"lastCheckedAt\":1766366944051,\"status\":false}"
|
||||
},
|
||||
{
|
||||
"name": "ig_boost_on_web_campaign_upsell_shown",
|
||||
"value": "false"
|
||||
},
|
||||
{
|
||||
"name": "banzai:last_storage_flush",
|
||||
"value": "1771366998859.2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1386,22 +1386,29 @@ export async function extractTextAndThumbnail(
|
||||
});
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// If we intercepted a full caption, use it immediately
|
||||
if (interceptedCaption) {
|
||||
console.log('[Extractor] Using intercepted caption from network traffic');
|
||||
const thumbnail = await extractThumbnailStealth(page, onProgress);
|
||||
onProgress?.({
|
||||
type: 'complete',
|
||||
message: 'Extraction completed via GraphQL interception',
|
||||
method: 'graphql-intercept',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
return { bodyText: cleanText(interceptedCaption), thumbnail };
|
||||
// Always use DOM extraction (HTML Section) — it clicks "… more" in
|
||||
// the browser and gets the fully expanded caption. The GraphQL
|
||||
// interception is unreliable: Instagram often truncates captions
|
||||
// in API responses without any "…." marker, so we cannot trust
|
||||
// the intercepted text to be complete.
|
||||
const capturedCaption = interceptedCaption as string | null;
|
||||
if (capturedCaption) {
|
||||
console.log(
|
||||
`[Extractor] Intercepted GraphQL caption (${capturedCaption.length} chars) — always using DOM extraction for full text`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await extractWithStrategies(url, page, context, onProgress);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
// DOM extraction failed — fall back to intercepted caption if available
|
||||
if (capturedCaption) {
|
||||
console.log(
|
||||
'[Extractor] DOM extraction failed — using intercepted GraphQL caption as fallback'
|
||||
);
|
||||
const thumbnail = await extractThumbnailStealth(page, onProgress);
|
||||
return { bodyText: cleanText(capturedCaption), thumbnail };
|
||||
}
|
||||
throw new Error(result.error || 'Extraction failed');
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { logError } from './utils/logger';
|
||||
import type { ExtractedContent, ProgressCallback } from './extraction';
|
||||
|
||||
@@ -20,9 +20,56 @@ const IMAGE_FETCH_TIMEOUT_MS = 10_000;
|
||||
const USER_AGENT =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||
|
||||
const AUTH_PATHS = ['/app/secrets/auth.json', './secrets/auth.json'];
|
||||
const COOKIE_PATHS = ['/app/secrets/cookies.txt', './secrets/cookies.txt'];
|
||||
|
||||
interface PlaywrightCookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
httpOnly?: boolean;
|
||||
secure?: boolean;
|
||||
}
|
||||
|
||||
/** Convert Playwright auth.json → Netscape cookies.txt next to it. */
|
||||
function maybeConvertAuthJson(): void {
|
||||
for (let i = 0; i < AUTH_PATHS.length; i++) {
|
||||
const authPath = AUTH_PATHS[i];
|
||||
const cookiePath = COOKIE_PATHS[i];
|
||||
if (!existsSync(authPath)) continue;
|
||||
|
||||
// Always regenerate from auth.json so yt-dlp cannot overwrite our session cookies
|
||||
try {
|
||||
const auth = JSON.parse(readFileSync(authPath, 'utf8')) as {
|
||||
cookies?: PlaywrightCookie[];
|
||||
};
|
||||
const cookies: PlaywrightCookie[] = auth.cookies ?? [];
|
||||
const lines = [
|
||||
'# Netscape HTTP Cookie File',
|
||||
'# Auto-generated from auth.json by InstaChef.',
|
||||
''
|
||||
];
|
||||
for (const c of cookies) {
|
||||
const domain = c.domain.startsWith('.') ? c.domain : `.${c.domain}`;
|
||||
const includeSubdomains = 'TRUE';
|
||||
const secure = c.secure ? 'TRUE' : 'FALSE';
|
||||
const expiry = Math.floor(c.expires > 0 ? c.expires : 0);
|
||||
lines.push(
|
||||
[domain, includeSubdomains, c.path ?? '/', secure, expiry, c.name, c.value].join('\t')
|
||||
);
|
||||
}
|
||||
writeFileSync(cookiePath, lines.join('\n') + '\n', 'utf8');
|
||||
} catch {
|
||||
// Non-fatal: yt-dlp will just run without cookies
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCookiePath(): string | null {
|
||||
maybeConvertAuthJson();
|
||||
for (const p of COOKIE_PATHS) {
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
|
||||
@@ -49,11 +49,17 @@ export async function detectRecipe(text: string): Promise<boolean> {
|
||||
content: `Does this text contain a recipe?\n\n${text}`
|
||||
}
|
||||
],
|
||||
max_tokens: 10,
|
||||
// 1024 gives thinking models room to reason before answering
|
||||
max_tokens: 1024,
|
||||
temperature: 0
|
||||
});
|
||||
|
||||
const detectionResult = detectionResponse.choices[0].message.content?.toLowerCase() ?? '';
|
||||
const msg = detectionResponse.choices[0].message;
|
||||
// Some local models (e.g. Gemma thinking variants) return the answer in
|
||||
// reasoning_content instead of content when max_tokens is tight.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const reasoning: string = (msg as any).reasoning_content ?? '';
|
||||
const detectionResult = (msg.content ?? reasoning).toLowerCase();
|
||||
console.log('[LLM] Detection response:', detectionResult);
|
||||
|
||||
return detectionResult.includes('yes');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -19,22 +19,35 @@ import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/t
|
||||
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService';
|
||||
import { queueConfig } from './config';
|
||||
import { logError } from '../utils/logger';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { ProgressEvent, ExtractedContent, ProgressCallback } from '$lib/server/extraction';
|
||||
import type { QueueItem } from './types';
|
||||
|
||||
// Feature flag: pick which Instagram extractor backend to invoke.
|
||||
// Default to yt-dlp; set EXTRACTOR_BACKEND=playwright to fall back to the
|
||||
// legacy stealth scraper while we verify the new path.
|
||||
const extractTextAndThumbnail = (
|
||||
/**
|
||||
* Extract caption via Playwright (full, untruncated) and thumbnail via yt-dlp
|
||||
* (fast, reliable CDN URL). Both run in parallel; yt-dlp failure is non-fatal.
|
||||
*/
|
||||
async function extractTextAndThumbnail(
|
||||
url: string,
|
||||
cb?: ProgressCallback
|
||||
): Promise<ExtractedContent> => {
|
||||
const backend = (env.EXTRACTOR_BACKEND ?? 'ytdlp').toLowerCase();
|
||||
return backend === 'playwright'
|
||||
? extractWithPlaywright(url, cb)
|
||||
: extractWithYtDlp(url, cb);
|
||||
};
|
||||
): Promise<ExtractedContent> {
|
||||
// Run Playwright (caption) and yt-dlp (thumbnail) concurrently
|
||||
const [ytdlpResult, playwrightResult] = await Promise.allSettled([
|
||||
extractWithYtDlp(url),
|
||||
extractWithPlaywright(url, cb)
|
||||
]);
|
||||
|
||||
if (playwrightResult.status === 'rejected') {
|
||||
throw playwrightResult.reason;
|
||||
}
|
||||
|
||||
// Prefer yt-dlp thumbnail; fall back to whatever Playwright captured
|
||||
const thumbnail =
|
||||
ytdlpResult.status === 'fulfilled' && ytdlpResult.value.thumbnail
|
||||
? ytdlpResult.value.thumbnail
|
||||
: playwrightResult.value.thumbnail;
|
||||
|
||||
return { bodyText: playwrightResult.value.bodyText, thumbnail };
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue processor with configurable concurrency
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -3,42 +3,82 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { QueueItem, QueueStatusUpdate } from '$lib/server/queue/types';
|
||||
import QueueItemCard from './components/QueueItemCard.svelte';
|
||||
import NotificationSettings from './components/NotificationSettings.svelte';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import { pushNotificationManager } from '$lib/client/PushNotificationManager';
|
||||
import type { NotificationState } from '$lib/client/PushNotificationManager';
|
||||
|
||||
import TopBar from './components/TopBar.svelte';
|
||||
import CookingHero from './components/CookingHero.svelte';
|
||||
import TimelineRow from './components/TimelineRow.svelte';
|
||||
import EmptyState from './components/EmptyState.svelte';
|
||||
import RecipeSheet from './components/RecipeSheet.svelte';
|
||||
import SectionHead from './components/SectionHead.svelte';
|
||||
import AddUrlScreen from './components/AddUrlScreen.svelte';
|
||||
import NotificationsScreen from './components/NotificationsScreen.svelte';
|
||||
|
||||
// ── State ──────────────────────────────────────────────────
|
||||
let items = $state<QueueItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let filter = $state<string>('all');
|
||||
let loadError = $state<string | null>(null);
|
||||
let filter = $state<'all' | 'in_progress' | 'success' | 'error'>('all');
|
||||
let eventSource = $state<EventSource | null>(null);
|
||||
let connectionStatus = $state<'connecting' | 'connected' | 'disconnected'>('disconnected');
|
||||
let lastPing = $state<string | null>(null);
|
||||
let hasAttemptedAutoSubscribe = $state(false);
|
||||
let notificationViewModel = $state<NotificationState | null>(null);
|
||||
|
||||
// Screen router
|
||||
let screen = $state<'home' | 'addurl' | 'notifications'>('home');
|
||||
// Recipe detail sheet
|
||||
let selectedItem = $state<QueueItem | null>(null);
|
||||
// "How it works" dismissible card
|
||||
let showHowTo = $state(true);
|
||||
|
||||
// Get highlighted item ID from URL params (when redirected from Share page)
|
||||
let highlightId = $derived($page.url.searchParams.get('highlight'));
|
||||
const highlightId = $derived($page.url.searchParams.get('highlight'));
|
||||
|
||||
// Available filters - derived to be reactive
|
||||
let filters = $derived([
|
||||
{ id: 'all', name: 'All Items', count: items.length },
|
||||
{ id: 'pending', name: 'Pending', count: items.filter(item => item.status === 'pending').length },
|
||||
{ id: 'in_progress', name: 'Processing', count: items.filter(item => item.status === 'in_progress').length },
|
||||
{ id: 'success', name: 'Complete', count: items.filter(item => item.status === 'success').length },
|
||||
{ id: 'error', name: 'Failed', count: items.filter(item => item.status === 'error' || item.status === 'unhealthy').length }
|
||||
]);
|
||||
|
||||
// Filter items based on selected filter
|
||||
// Using $derived.by to execute the function and derive the result array
|
||||
let filteredItems = $derived.by(() => {
|
||||
// ── Derived ────────────────────────────────────────────────
|
||||
const filteredItems = $derived.by(() => {
|
||||
if (filter === 'all') return items;
|
||||
if (filter === 'error') return items.filter(item => item.status === 'error' || item.status === 'unhealthy');
|
||||
return items.filter(item => item.status === filter);
|
||||
if (filter === 'error') return items.filter((i) => i.status === 'error' || i.status === 'unhealthy');
|
||||
return items.filter((i) => i.status === filter);
|
||||
});
|
||||
|
||||
const sseLastPing = $derived(
|
||||
lastPing ? relTime(lastPing) + ' ago' : ''
|
||||
);
|
||||
|
||||
function relTime(iso: string): string {
|
||||
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
if (diff < 60) return Math.round(diff) + 's';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm';
|
||||
return Math.floor(diff / 3600) + 'h';
|
||||
}
|
||||
|
||||
// ── groupByDate ────────────────────────────────────────────
|
||||
type Group = 'Cooking now' | 'In line' | 'Today' | 'Yesterday' | 'Earlier';
|
||||
function groupByDate(list: QueueItem[]): Record<Group, QueueItem[]> {
|
||||
const g: Record<Group, QueueItem[]> = {
|
||||
'Cooking now': [],
|
||||
'In line': [],
|
||||
Today: [],
|
||||
Yesterday: [],
|
||||
Earlier: []
|
||||
};
|
||||
const now = Date.now();
|
||||
for (const it of list) {
|
||||
if (it.status === 'in_progress') { g['Cooking now'].push(it); continue; }
|
||||
if (it.status === 'pending') { g['In line'].push(it); continue; }
|
||||
const age = (now - new Date(it.createdAt).getTime()) / 1000;
|
||||
if (age < 86400) g['Today'].push(it);
|
||||
else if (age < 172800) g['Yesterday'].push(it);
|
||||
else g['Earlier'].push(it);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
const groups = $derived(groupByDate(filteredItems));
|
||||
const cooking = $derived(groups['Cooking now'][0] ?? null);
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────
|
||||
let unsubscribeNotifications: (() => void) | undefined;
|
||||
|
||||
onMount(async () => {
|
||||
@@ -46,384 +86,384 @@
|
||||
if (browser) {
|
||||
startSSEConnection();
|
||||
setupAutoSubscribe();
|
||||
unsubscribeNotifications = pushNotificationManager.onStateChange((newState) => {
|
||||
notificationViewModel = newState;
|
||||
});
|
||||
unsubscribeNotifications = pushNotificationManager.onStateChange(() => {});
|
||||
|
||||
// Open RecipeSheet for highlighted item
|
||||
if (highlightId) {
|
||||
const found = items.find((i) => i.id === highlightId);
|
||||
if (found) { selectedItem = found; clearHighlight(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (eventSource) {
|
||||
console.log('[SSE] Closing connection on component destroy');
|
||||
eventSource.close();
|
||||
connectionStatus = 'disconnected';
|
||||
}
|
||||
// Add notification state cleanup
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup automatic notification subscription on first user interaction
|
||||
*
|
||||
* Follows Web Push API best practices: subscription requires user gesture.
|
||||
* Listens for first click/touch anywhere on page, checks if notifications
|
||||
* are supported but not subscribed, then auto-subscribes.
|
||||
*/
|
||||
function setupAutoSubscribe() {
|
||||
if (hasAttemptedAutoSubscribe) return;
|
||||
|
||||
const attemptSubscribe = async () => {
|
||||
const attempt = async () => {
|
||||
if (hasAttemptedAutoSubscribe) return;
|
||||
hasAttemptedAutoSubscribe = true;
|
||||
|
||||
const state = pushNotificationManager.getState();
|
||||
|
||||
// Only auto-subscribe if:
|
||||
// - Browser supports notifications
|
||||
// - Permission is not denied
|
||||
// - Not already subscribed
|
||||
if (state.supported && state.permission !== 'denied' && !state.subscribed) {
|
||||
console.log('[HomePage] Auto-subscribing to notifications on first interaction');
|
||||
await pushNotificationManager.subscribe();
|
||||
}
|
||||
|
||||
// Remove listener after first attempt
|
||||
document.removeEventListener('click', attemptSubscribe);
|
||||
document.removeEventListener('touchstart', attemptSubscribe);
|
||||
};
|
||||
|
||||
// Listen for first user interaction
|
||||
document.addEventListener('click', attemptSubscribe, { once: true });
|
||||
document.addEventListener('touchstart', attemptSubscribe, { once: true });
|
||||
document.addEventListener('click', attempt, { once: true });
|
||||
document.addEventListener('touchstart', attempt, { once: true });
|
||||
}
|
||||
|
||||
function updateQueueItem(update: QueueStatusUpdate) {
|
||||
// Find and update the item in the list
|
||||
const itemIndex = items.findIndex(item => item.id === update.itemId);
|
||||
const idx = items.findIndex((i) => i.id === update.itemId);
|
||||
if (idx >= 0) {
|
||||
const prev = items[idx];
|
||||
// Append progress events (type: 'progress' carries data.event)
|
||||
const newEvents = update.data?.event
|
||||
? [...(prev.progressEvents ?? []), update.data.event]
|
||||
: (prev.progressEvents ?? []);
|
||||
|
||||
if (itemIndex >= 0) {
|
||||
// Update existing item
|
||||
items[itemIndex] = {
|
||||
...items[itemIndex],
|
||||
items[idx] = {
|
||||
...prev,
|
||||
status: update.status,
|
||||
phases: update.progress || items[itemIndex].phases,
|
||||
results: update.results || items[itemIndex].results,
|
||||
error: update.error || items[itemIndex].error,
|
||||
// currentPhase is sent as update.phase on status_change events
|
||||
currentPhase: update.phase ?? prev.currentPhase,
|
||||
phases: update.progress || prev.phases,
|
||||
progressEvents: newEvents,
|
||||
results: update.results || prev.results,
|
||||
error: update.error || prev.error,
|
||||
updatedAt: update.timestamp
|
||||
};
|
||||
// Keep selectedItem in sync
|
||||
if (selectedItem?.id === update.itemId) selectedItem = items[idx];
|
||||
} else {
|
||||
// New item - fetch full details from API
|
||||
fetchQueueItem(update.itemId);
|
||||
}
|
||||
|
||||
// Trigger reactivity
|
||||
items = [...items];
|
||||
}
|
||||
|
||||
async function fetchQueueItem(id: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/queue/${id}`);
|
||||
if (response.ok) {
|
||||
const item = await response.json();
|
||||
items = [item, ...items]; // Add to top of list
|
||||
}
|
||||
if (response.ok) items = [await response.json(), ...items];
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch queue item:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────
|
||||
async function retryItem(id: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/queue/${id}/retry`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/queue/${id}/retry`, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to retry item');
|
||||
const err = await response.json();
|
||||
throw new Error(err.message || 'Failed to retry');
|
||||
}
|
||||
|
||||
// Item will be updated via SSE
|
||||
console.log('Retry initiated for item:', id);
|
||||
} catch (e) {
|
||||
console.error('Failed to retry item:', e);
|
||||
// Could show a toast notification here
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(id: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/queue/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to remove item');
|
||||
}
|
||||
|
||||
// Item will be removed from local state via SSE update
|
||||
// but remove immediately for better UX
|
||||
items = items.filter(item => item.id !== id);
|
||||
console.log('Item removed successfully:', id);
|
||||
await fetch(`/api/queue/${id}`, { method: 'DELETE' });
|
||||
} catch (e) {
|
||||
console.error('Failed to remove item:', e);
|
||||
// Fallback: remove from local state anyway
|
||||
items = items.filter(item => item.id !== id);
|
||||
} finally {
|
||||
items = items.filter((i) => i.id !== id);
|
||||
if (selectedItem?.id === id) selectedItem = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearHighlight() {
|
||||
// Remove highlight parameter from URL without navigation
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('highlight');
|
||||
replaceState(url, {});
|
||||
}
|
||||
|
||||
// Queue positions for pending items
|
||||
function queuePos(item: QueueItem): number {
|
||||
return items.filter((i) => i.status === 'pending').indexOf(item) + 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>InstaRecipe Queue Dashboard</title>
|
||||
<meta name="description" content="Monitor your recipe extraction queue in real-time" />
|
||||
<title>InstaChef</title>
|
||||
<meta name="description" content="Cook anything from an Instagram link." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto p-6 max-w-6xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold mb-2">Recipe Queue Dashboard</h1>
|
||||
<p class="text-gray-600">Monitor your Instagram recipe extractions in real-time</p>
|
||||
</div>
|
||||
<div class="app-root">
|
||||
<!-- ── Home screen ────────────────────────────────────────── -->
|
||||
{#if screen === 'home'}
|
||||
<div class="ic-scroll home-scroll">
|
||||
<TopBar
|
||||
count={items.length}
|
||||
notifCount={0}
|
||||
onNotifications={() => (screen = 'notifications')}
|
||||
/>
|
||||
|
||||
<!-- Action Bar -->
|
||||
<div class="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
|
||||
<div class="flex items-center gap-4 w-full sm:w-auto">
|
||||
<!-- Filter Dropdown -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="filter-select" class="text-sm font-medium text-gray-700">Filter:</label>
|
||||
<select
|
||||
id="filter-select"
|
||||
bind:value={filter}
|
||||
class="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
|
||||
>
|
||||
{#each filters as filterOption}
|
||||
<option value={filterOption.id}>
|
||||
{filterOption.name} ({filterOption.count})
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Refresh Button (moved to same row) -->
|
||||
<button
|
||||
onclick={loadQueueItems}
|
||||
disabled={loading}
|
||||
title="Refresh queue"
|
||||
aria-label="Refresh queue"
|
||||
class="flex items-center p-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5 {loading ? 'animate-spin' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add Recipe Button (icon-only, visible when items exist) -->
|
||||
{#if items.length > 0}
|
||||
<a
|
||||
href="/share"
|
||||
title="Add recipe URL"
|
||||
aria-label="Add recipe URL"
|
||||
class="inline-flex items-center p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
{#if loading}
|
||||
<div class="flex justify-center items-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<span class="ml-3 text-gray-600">Loading queue items...</span>
|
||||
</div>
|
||||
{/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" data-notification-settings>
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
|
||||
<!-- Footer Status Bar (icons only) -->
|
||||
<div class="fixed bottom-0 left-0 right-0 bg-white border-t shadow-lg z-50">
|
||||
<div class="mx-auto max-w-6xl px-6 py-3 flex items-center justify-between">
|
||||
<!-- Notification Status Icon (left) -->
|
||||
<button
|
||||
onclick={() => {
|
||||
// Scroll to NotificationSettings component
|
||||
document.querySelector('[data-notification-settings]')?.scrollIntoView({ behavior: 'smooth' });
|
||||
}}
|
||||
title={notificationViewModel?.subscribed ? 'Notifications enabled' : notificationViewModel?.supported ? 'Notifications disabled' : 'Notifications not supported'}
|
||||
aria-label="Notification status"
|
||||
class="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
{#if !notificationViewModel?.supported || notificationViewModel?.permission === 'denied'}
|
||||
<!-- Not supported / denied - bell with slash -->
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636m12.728 12.728L18.364 5.636M5.636 18.364l12.728-12.728"></path>
|
||||
</svg>
|
||||
{:else if notificationViewModel?.subscribed}
|
||||
<!-- Enabled - bell icon (green) -->
|
||||
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-5-5-5 5h5v-8a1 1 0 011-1h8a1 1 0 011 1v1a1 1 0 01-1 1h-7v7z"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Disabled - bell icon (gray) -->
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-5-5-5 5h5v-8a1 1 0 011-1h8a1 1 0 011 1v1a1 1 0 01-1 1h-7v7z"></path>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Live Update Indicator (right) -->
|
||||
<div
|
||||
title={connectionStatus === 'connected' ? 'Live updates active' : connectionStatus === 'connecting' ? 'Connecting to live updates...' : 'Live updates disconnected'}
|
||||
aria-label="Live update status"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<div class="w-2 h-2 rounded-full {
|
||||
connectionStatus === 'connected' ? 'bg-green-600' :
|
||||
connectionStatus === 'connecting' ? 'bg-yellow-600' :
|
||||
'bg-red-600'
|
||||
}"></div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -58,12 +58,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
return json({
|
||||
duplicate: true,
|
||||
message: 'This recipe is already in the queue',
|
||||
item: {
|
||||
id: existingItem.id,
|
||||
url: existingItem.url,
|
||||
status: existingItem.status,
|
||||
enqueuedAt: existingItem.enqueuedAt
|
||||
}
|
||||
item: existingItem
|
||||
}, { status: 200 }); // 200 OK, not an error
|
||||
}
|
||||
|
||||
@@ -73,12 +68,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
// Return success response
|
||||
return json({
|
||||
duplicate: false,
|
||||
item: {
|
||||
id: queueItem.id,
|
||||
url: queueItem.url,
|
||||
status: queueItem.status,
|
||||
enqueuedAt: queueItem.enqueuedAt
|
||||
}
|
||||
item: queueItem
|
||||
});
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
|
||||
@@ -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
|
||||
|
||||
276
src/routes/components/AddUrlScreen.svelte
Normal 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>
|
||||
38
src/routes/components/Chip.svelte
Normal 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>
|
||||
188
src/routes/components/CookingHero.svelte
Normal 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>
|
||||
76
src/routes/components/CookingPot.svelte
Normal 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>
|
||||
204
src/routes/components/EmptyState.svelte
Normal 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>
|
||||
@@ -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>
|
||||
322
src/routes/components/NotificationsScreen.svelte
Normal 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>
|
||||
87
src/routes/components/PhaseTrack.svelte
Normal 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>
|
||||
464
src/routes/components/RecipeSheet.svelte
Normal 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>
|
||||
86
src/routes/components/RecipeThumb.svelte
Normal 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>
|
||||
31
src/routes/components/SectionHead.svelte
Normal 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>
|
||||
195
src/routes/components/TimelineRow.svelte
Normal 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>
|
||||
106
src/routes/components/TopBar.svelte
Normal 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>
|
||||
7
src/routes/components/ic/Bell.svelte
Normal 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>
|
||||
9
src/routes/components/ic/BellOff.svelte
Normal 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>
|
||||
6
src/routes/components/ic/Check.svelte
Normal 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>
|
||||
7
src/routes/components/ic/Chevron.svelte
Normal 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>
|
||||
7
src/routes/components/ic/Clipboard.svelte
Normal 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>
|
||||
6
src/routes/components/ic/Close.svelte
Normal 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>
|
||||
7
src/routes/components/ic/Download.svelte
Normal 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>
|
||||
6
src/routes/components/ic/External.svelte
Normal 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>
|
||||
6
src/routes/components/ic/Filter.svelte
Normal 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>
|
||||
7
src/routes/components/ic/Link.svelte
Normal 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>
|
||||
13
src/routes/components/ic/PhasePlating.svelte
Normal 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>
|
||||
14
src/routes/components/ic/PhasePrepping.svelte
Normal 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>
|
||||
20
src/routes/components/ic/PhaseSimmering.svelte
Normal 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>
|
||||
6
src/routes/components/ic/Plus.svelte
Normal 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>
|
||||
6
src/routes/components/ic/Retry.svelte
Normal 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>
|
||||
6
src/routes/components/ic/Search.svelte
Normal 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>
|
||||
7
src/routes/components/ic/Settings.svelte
Normal 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>
|
||||
7
src/routes/components/ic/Share.svelte
Normal 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>
|
||||
6
src/routes/components/ic/Spark.svelte
Normal 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>
|
||||
6
src/routes/components/ic/Trash.svelte
Normal 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>
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -13,6 +13,16 @@ vi.mock('openai', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock env so tests pass in environments without .env (e.g. Docker CI builds)
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: {
|
||||
OPENAI_BASE_URL: 'http://localhost:11434/v1',
|
||||
OPENAI_API_KEY: 'test-key',
|
||||
LLM_MODEL: 'test-model',
|
||||
LLM_REQUEST_TIMEOUT_MS: undefined
|
||||
}
|
||||
}));
|
||||
|
||||
// Import AFTER mocking
|
||||
import { checkLLMHealth, checkModelAvailability } from '$lib/server/llm';
|
||||
|
||||
|
||||
BIN
static/favicon-32.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 42 KiB |
BIN
static/icon-256-dark.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
static/icon-256-transparent.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
static/icon-256.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
static/icon-512-dark.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
static/icon-512-maskable.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
static/icon-512-transparent.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 138 KiB |
@@ -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": {
|
||||
|
||||