Compare commits
28 Commits
40e3fb0c1b
...
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 | ||
|
|
5b5bb947ef | ||
|
|
6849a1fb26 | ||
|
|
08602073ac | ||
|
|
c98a2407a7 | ||
|
|
dfca35bde2 |
40
.env.example
@@ -7,15 +7,23 @@
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# LLM Configuration (REQUIRED)
|
# LLM Configuration (REQUIRED)
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# OpenAI-compatible API endpoint (OpenAI, LM Studio, Ollama, LiteLLM, etc.)
|
# OpenAI-compatible API endpoint. Production: llama-swap on ideapad.
|
||||||
OPENAI_BASE_URL=http://localhost:1234/v1
|
# llama-swap loads models on demand and unloads them after globalTTL (10 min).
|
||||||
|
OPENAI_BASE_URL=http://192.168.1.50:8080/v1
|
||||||
|
|
||||||
# API key for authentication
|
# API key for authentication (llama-swap accepts any non-empty value).
|
||||||
OPENAI_API_KEY=your-api-key-here
|
OPENAI_API_KEY=sk-llama-local
|
||||||
|
|
||||||
# Model to use for recipe extraction
|
# Model to use for recipe extraction. Available on the ideapad llama-swap stack:
|
||||||
# Examples: gpt-4o, gpt-4o-mini, llama-3.1, mistral, etc.
|
# gemma4-e4b-q6k (recommended — 4B, 65k ctx, 31 TPS)
|
||||||
LLM_MODEL=google/gemma-3-4b
|
# gemma4-e2b-q8_0 (faster — 2B, 65k ctx, 55 TPS)
|
||||||
|
# qwen3.5-4b-q8_0 (fallback — 22 TPS)
|
||||||
|
# phi4-mini-q8_0, granite-3.3-8b-q6k, plus larger MoE variants
|
||||||
|
LLM_MODEL=gemma4-e4b-q6k
|
||||||
|
|
||||||
|
# Per-request LLM timeout in ms. Must cover llama-swap cold-load (~5–30s for
|
||||||
|
# small models) plus generation time. Default 120000.
|
||||||
|
LLM_REQUEST_TIMEOUT_MS=120000
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Queue Configuration (OPTIONAL)
|
# Queue Configuration (OPTIONAL)
|
||||||
@@ -55,9 +63,23 @@ VAPID_PUBLIC_KEY=BNextdcB_fQ0BVvyGioM5L8Tf9vKQjs-WnF-rUbnU8MdWIZQYfggIHxBnW21I-l
|
|||||||
VAPID_PRIVATE_KEY=JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680
|
VAPID_PRIVATE_KEY=JwxI_KcsBcehYcTOufMcbVWJjCq1QbH5FJmSyQuG680
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Authentication Scheduler (OPTIONAL)
|
# Instagram Extraction Backend
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Enable automatic Instagram authentication renewal
|
# Which extractor to use:
|
||||||
|
# ytdlp (default) — yt-dlp subprocess, stateless, Sablier-safe
|
||||||
|
# playwright — legacy Playwright stealth scraper, requires
|
||||||
|
# secrets/auth.json + AUTH_SCHEDULER_* below
|
||||||
|
EXTRACTOR_BACKEND=ytdlp
|
||||||
|
|
||||||
|
# Optional Netscape-format cookies file for login-walled reels.
|
||||||
|
# yt-dlp picks it up automatically if it exists at /app/secrets/cookies.txt
|
||||||
|
# (Docker) or ./secrets/cookies.txt (local). No automation; export from a
|
||||||
|
# browser when an extraction starts hitting login walls.
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Authentication Scheduler (LEGACY — only relevant when EXTRACTOR_BACKEND=playwright)
|
||||||
|
# ==============================================================================
|
||||||
|
# Enable automatic Instagram authentication renewal (Playwright backend only)
|
||||||
AUTH_SCHEDULER_ENABLED=true
|
AUTH_SCHEDULER_ENABLED=true
|
||||||
|
|
||||||
# Renewal interval in minutes (default: 720 = 12 hours)
|
# Renewal interval in minutes (default: 720 = 12 hours)
|
||||||
|
|||||||
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.example
|
||||||
!.env.test
|
!.env.test
|
||||||
|
|
||||||
|
# Secrets (never commit cookies, tokens or credentials)
|
||||||
|
secrets/
|
||||||
|
|
||||||
# Local certificates
|
# Local certificates
|
||||||
.ssl/
|
.ssl/
|
||||||
|
|
||||||
|
|||||||
34
Dockerfile
@@ -1,18 +1,38 @@
|
|||||||
FROM node:24-alpine
|
# ── stage: tester ────────────────────────────────────────────────────────────
|
||||||
|
FROM node:24-alpine AS tester
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
# Run only server-side unit tests; browser (Playwright) tests require a full
|
||||||
|
# Chromium environment that is not available in the Alpine build stage.
|
||||||
|
RUN npm run test:unit -- --run --project=server
|
||||||
|
|
||||||
|
# ── stage: builder ───────────────────────────────────────────────────────────
|
||||||
|
FROM node:24-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── stage: runner ────────────────────────────────────────────────────────────
|
||||||
|
FROM node:24-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Playwright system dependencies
|
# Install yt-dlp (primary Instagram extractor) and Playwright system dependencies (fallback)
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
|
python3 \
|
||||||
|
py3-pip \
|
||||||
chromium \
|
chromium \
|
||||||
font-liberation \
|
font-liberation \
|
||||||
font-noto \
|
font-noto \
|
||||||
font-noto-cjk
|
font-noto-cjk && \
|
||||||
|
pip3 install --break-system-packages yt-dlp
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci --omit=dev
|
||||||
|
COPY --from=builder /app/build ./build
|
||||||
COPY . .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ services:
|
|||||||
# Playwright Configuration
|
# Playwright Configuration
|
||||||
- DISPLAY=:99
|
- DISPLAY=:99
|
||||||
|
|
||||||
|
# Extractor backend: 'ytdlp' (default) or 'playwright' (legacy fallback)
|
||||||
|
- EXTRACTOR_BACKEND=${EXTRACTOR_BACKEND:-ytdlp}
|
||||||
|
|
||||||
# Node.js Environment
|
# Node.js Environment
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
security_opt:
|
security_opt:
|
||||||
|
|||||||
939
docs/FINDINGS.md
@@ -2446,3 +2446,942 @@ npm audit # Must show 0 vulnerabilities (preserved from iteration 0)
|
|||||||
- Verification: Sequential (after all fixes applied)
|
- Verification: Sequential (after all fixes applied)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### [Planner] Research Notes - RECIPE-0009 (2026-02-18)
|
||||||
|
|
||||||
|
**Task:** Implement URL deduplication, automatic notification subscription, UI improvements, and notification redirect fix
|
||||||
|
|
||||||
|
#### Web Push API Permission Requirements - RECIPE-0009
|
||||||
|
|
||||||
|
**Research Date:** 2026-02-18
|
||||||
|
**Source:** W3C Push API Specification, MDN Web Docs, browser security policies, existing PushNotificationManager.ts implementation
|
||||||
|
|
||||||
|
**Security Requirement:**
|
||||||
|
|
||||||
|
Per W3C Push API specification, `Notification.requestPermission()` **requires user gesture** - cannot be called programmatically without user interaction.
|
||||||
|
|
||||||
|
**Browser Behavior:**
|
||||||
|
|
||||||
|
- **Permission States**: `"default"` (not requested), `"granted"` (allowed), `"denied"` (blocked)
|
||||||
|
- **User Gesture Required**: Click, tap, keypress triggers permission prompt
|
||||||
|
- **No Automatic Subscription**: Calling `requestPermission()` on page load fails silently or throws error in strict mode
|
||||||
|
- **Best Practice**: Attach to meaningful user action (button click preferred)
|
||||||
|
|
||||||
|
**Implementation Pattern for "Automatic" Subscription:**
|
||||||
|
|
||||||
|
Since true automatic subscription violates browser security policy, the approach is:
|
||||||
|
|
||||||
|
1. Listen for **first user interaction** (click/touch) anywhere on page
|
||||||
|
2. Check notification state: supported, not denied, not subscribed
|
||||||
|
3. Call `pushNotificationManager.subscribe()` on first interaction
|
||||||
|
4. Remove listener after first attempt (one-shot behavior)
|
||||||
|
|
||||||
|
**Code Pattern:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function setupAutoSubscribe() {
|
||||||
|
const attemptSubscribe = async () => {
|
||||||
|
const state = pushNotificationManager.getState();
|
||||||
|
if (state.supported && state.permission !== 'denied' && !state.subscribed) {
|
||||||
|
await pushNotificationManager.subscribe();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for first user interaction
|
||||||
|
document.addEventListener('click', attemptSubscribe, { once: true });
|
||||||
|
document.addEventListener('touchstart', attemptSubscribe, { once: true });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why This is "Best Practice" Automatic:**
|
||||||
|
|
||||||
|
- Requires minimal user action (any click/touch, not explicit "Enable" button)
|
||||||
|
- Non-intrusive (happens in background after natural interaction)
|
||||||
|
- Complies with W3C security requirements
|
||||||
|
- Avoids annoying permission prompts on page load
|
||||||
|
- Mobile-friendly (touchstart event)
|
||||||
|
|
||||||
|
**Alternative Approaches Considered:**
|
||||||
|
|
||||||
|
1. **Prompt on page load** — REJECTED: Violates security policy, creates poor UX
|
||||||
|
2. **Delay with setTimeout** — REJECTED: Still violates user gesture requirement
|
||||||
|
3. **IntersectionObserver trick** — REJECTED: Does not satisfy user gesture requirement
|
||||||
|
4. **Explicit "Enable Notifications" button** — VALID but less automatic than requested
|
||||||
|
|
||||||
|
**Conclusion:** First-interaction subscription is the most automatic approach allowed by browser standards while maintaining user control.
|
||||||
|
|
||||||
|
**References:**
|
||||||
|
|
||||||
|
- W3C Push API: https://www.w3.org/TR/push-api/
|
||||||
|
- MDN Notification.requestPermission: https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission
|
||||||
|
- Existing implementation: [src/lib/client/PushNotificationManager.ts](src/lib/client/PushNotificationManager.ts#L123-161)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Queue URL Deduplication Strategy - RECIPE-0009
|
||||||
|
|
||||||
|
**Research Date:** 2026-02-18
|
||||||
|
**Source:** QueueManager.ts architecture analysis, types.ts interface definitions, existing queue operations
|
||||||
|
|
||||||
|
**Current Queue Structure:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// QueueManager.ts line 44-45
|
||||||
|
private items: Map<string, QueueItem> = new Map();
|
||||||
|
```
|
||||||
|
|
||||||
|
- Storage: `Map<string, QueueItem>` with UUID keys
|
||||||
|
- No secondary index: URL lookups require linear search through values
|
||||||
|
- In-memory only: No persistence across server restarts
|
||||||
|
- Typical size: < 100 items (based on usage patterns)
|
||||||
|
|
||||||
|
**Deduplication Requirements:**
|
||||||
|
|
||||||
|
1. Check if URL already exists in queue before creating new item
|
||||||
|
2. If duplicate found: Return existing item, do NOT create new entry
|
||||||
|
3. API layer: Respond with `duplicate: true` and existing item details
|
||||||
|
4. Message level: Info (not error) - duplicate is expected behavior
|
||||||
|
|
||||||
|
**Implementation Approach:**
|
||||||
|
|
||||||
|
**Option A - Linear Search (Chosen):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
findByUrl(url: string): QueueItem | undefined {
|
||||||
|
for (const item of this.items.values()) {
|
||||||
|
if (item.url === url) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Complexity**: O(n) where n = queue size
|
||||||
|
- **Performance**: Acceptable for n < 100 (~1-2ms on modern hardware)
|
||||||
|
- **Simplicity**: No additional data structures, no risk of index desync
|
||||||
|
- **Consistency**: Single source of truth (items Map)
|
||||||
|
|
||||||
|
**Option B - Secondary URL Index (Rejected):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private items: Map<string, QueueItem> = new Map();
|
||||||
|
private urlIndex: Map<string, string> = new Map(); // url -> id
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Complexity**: O(1) lookup, but requires maintaining two structures
|
||||||
|
- **Risk**: Index desync if remove() doesn't clean both Maps
|
||||||
|
- **Overhead**: 2x memory for keys, more complex implementation
|
||||||
|
- **Benefit**: Marginal for queue size < 1000
|
||||||
|
|
||||||
|
**Design Decision:** Option A (linear search) chosen for simplicity and reliability at current scale.
|
||||||
|
|
||||||
|
**API Response Format:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Duplicate detected
|
||||||
|
{
|
||||||
|
duplicate: true,
|
||||||
|
message: "This recipe is already in the queue",
|
||||||
|
item: { id, url, status, enqueuedAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
// New item
|
||||||
|
{
|
||||||
|
duplicate: false,
|
||||||
|
item: { id, url, status, enqueuedAt }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**User Experience:**
|
||||||
|
|
||||||
|
- Frontend checks `response.duplicate === true`
|
||||||
|
- Shows info toast: "This recipe is already in queue [View]"
|
||||||
|
- No error state, no failed request
|
||||||
|
- Links to existing queue item
|
||||||
|
|
||||||
|
**Edge Cases Handled:**
|
||||||
|
|
||||||
|
1. **Multiple rapid requests**: First wins, rest return duplicate
|
||||||
|
2. **URL normalization**: URLs compared as-is (no normalization in v1)
|
||||||
|
3. **Completed items**: Duplicates found even if status is success/error
|
||||||
|
4. **Retry scenario**: Retry uses existing queue item ID, not new URL submission
|
||||||
|
|
||||||
|
**Future Considerations:**
|
||||||
|
|
||||||
|
- URL normalization (trailing slash, query params, fragments)
|
||||||
|
- Time-based deduplication window (only check items from last N hours)
|
||||||
|
- Content-based deduplication (recipe fingerprint from parsed data)
|
||||||
|
|
||||||
|
**References:**
|
||||||
|
|
||||||
|
- QueueManager implementation: [src/lib/server/queue/QueueManager.ts](src/lib/server/queue/QueueManager.ts#L44-95)
|
||||||
|
- QueueItem type definition: [src/lib/server/queue/types.ts](src/lib/server/queue/types.ts#L57-100)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Service Worker Notification Data Flow - RECIPE-0009
|
||||||
|
|
||||||
|
**Research Date:** 2026-02-18
|
||||||
|
**Source:** Code analysis of notification pipeline from QueueProcessor → PushNotificationService → Service Worker
|
||||||
|
|
||||||
|
**Notification Payload Journey:**
|
||||||
|
|
||||||
|
**Step 1: QueueProcessor sends notification (Line 418-420)**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await pushNotificationService.notifySuccess(
|
||||||
|
item.id,
|
||||||
|
item.results?.recipe?.name,
|
||||||
|
item.results?.tandoorUrl // ← tandoorUrl passed here
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: PushNotificationService creates payload (Lines 162-181)**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const payload: NotificationPayload = {
|
||||||
|
type: 'success',
|
||||||
|
itemId,
|
||||||
|
recipeName,
|
||||||
|
body: recipeName ? `Recipe "${recipeName}" has been extracted...` : ...,
|
||||||
|
tag: `recipe-success-${itemId}`,
|
||||||
|
requireInteraction: true,
|
||||||
|
analytics: { ... }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tandoorUrl) {
|
||||||
|
payload.body += ' View it in Tandoor.';
|
||||||
|
// Note: tandoorUrl NOT explicitly added to payload object
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Issue Found:** `tandoorUrl` parameter received but **not stored in payload object**!
|
||||||
|
|
||||||
|
**Step 3: Service Worker receives push event (Line 123)**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
data = event.data.json(); // ← Payload becomes data object
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Notification created with data (Lines 130-136)**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const options: NotificationOptions = {
|
||||||
|
body: data.body,
|
||||||
|
data: data, // ← Full payload stored in data field
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Click handler accesses data (Line 183-191)**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const data = event.notification.data;
|
||||||
|
const action = event.action;
|
||||||
|
|
||||||
|
if (action === 'view' && data?.itemId) {
|
||||||
|
url = `/?highlight=${data.itemId}`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current Bug:** `data.tandoorUrl` is undefined because `PushNotificationService.notifySuccess()` doesn't add it to payload.
|
||||||
|
|
||||||
|
**Fix Required in PushNotificationService.ts (Line 162-181):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const payload: NotificationPayload = {
|
||||||
|
type: 'success',
|
||||||
|
itemId,
|
||||||
|
recipeName,
|
||||||
|
tandoorUrl, // ← Add this line
|
||||||
|
body: recipeName ? `Recipe "${recipeName}" has been extracted...` : ...,
|
||||||
|
tag: `recipe-success-${itemId}`,
|
||||||
|
requireInteraction: true,
|
||||||
|
analytics: { ... }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Then Service Worker Can Use It:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (action === 'view' && data?.tandoorUrl) {
|
||||||
|
url = data.tandoorUrl; // Redirect to Tandoor
|
||||||
|
} else if (action === 'view' && data?.itemId) {
|
||||||
|
url = `/?highlight=${data.itemId}`; // Fallback to dashboard
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**NotificationPayload Interface Update Required:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 20-28 in PushNotificationService.ts
|
||||||
|
interface NotificationPayload {
|
||||||
|
title?: string;
|
||||||
|
body: string;
|
||||||
|
type: 'success' | 'error' | 'progress';
|
||||||
|
itemId: string;
|
||||||
|
recipeName?: string;
|
||||||
|
tandoorUrl?: string; // ← Add this line
|
||||||
|
tag?: string;
|
||||||
|
requireInteraction?: boolean;
|
||||||
|
analytics?: any;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
|
||||||
|
- QueueProcessor already passes `item.results?.tandoorUrl` correctly
|
||||||
|
- `item.results.tandoorUrl` is set by QueueProcessor line 329-331 when Tandoor upload succeeds
|
||||||
|
- Format: `${TANDOOR_BASE_URL}/view/recipe/${recipeId}`
|
||||||
|
- Example: `https://tandoor.example.com/view/recipe/123`
|
||||||
|
|
||||||
|
**References:**
|
||||||
|
|
||||||
|
- QueueProcessor notification call: [src/lib/server/queue/QueueProcessor.ts](src/lib/server/queue/QueueProcessor.ts#L418-420)
|
||||||
|
- PushNotificationService: [src/lib/server/notifications/PushNotificationService.ts](src/lib/server/notifications/PushNotificationService.ts#L158-183)
|
||||||
|
- Service Worker push handler: [src/service-worker.ts](src/service-worker.ts#L112-170)
|
||||||
|
- Service Worker click handler: [src/service-worker.ts](src/service-worker.ts#L176-207)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Homepage UI Component Visibility Analysis - RECIPE-0009
|
||||||
|
|
||||||
|
**Research Date:** 2026-02-18
|
||||||
|
**Source:** +page.svelte component structure analysis
|
||||||
|
|
||||||
|
**Current Behavior:**
|
||||||
|
|
||||||
|
**Add Recipe Component Locations:**
|
||||||
|
|
||||||
|
1. **Empty State** (Lines 280-302): Shows when `!loading && filteredItems.length === 0`
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{#if !loading && filteredItems.length === 0}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<!-- ... -->
|
||||||
|
<a href="/share" class="...">
|
||||||
|
Add Recipe URL
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **No Persistent Component**: When queue has items, no "Add Recipe" button visible
|
||||||
|
|
||||||
|
**User Complaint:** "Do not hide the add recipe component when there are items in the queue"
|
||||||
|
|
||||||
|
**Issue:** Add recipe link only appears in empty state conditional block.
|
||||||
|
|
||||||
|
**Solution:** Add persistent "Add Recipe" button to action bar (always visible)
|
||||||
|
|
||||||
|
**Implementation Location:** Lines 224-254 (Action Bar section)
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<div class="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<!-- Filter Tabs -->
|
||||||
|
{#each filters as filterOption}
|
||||||
|
<button>...</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Refresh Button -->
|
||||||
|
<button>...</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<div class="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<!-- Filter Dropdown -->
|
||||||
|
<select>...</select>
|
||||||
|
|
||||||
|
<!-- Refresh Button -->
|
||||||
|
<button>...</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Recipe Button (ALWAYS VISIBLE) -->
|
||||||
|
<a href="/share" class="...">
|
||||||
|
Add Recipe URL
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
|
||||||
|
- Always accessible regardless of queue state
|
||||||
|
- Consistent UI (no disappearing elements)
|
||||||
|
- Better UX for power users (add multiple recipes quickly)
|
||||||
|
- Maintains empty state link for discoverability
|
||||||
|
|
||||||
|
**Filter Consolidation Rationale:**
|
||||||
|
|
||||||
|
Current filter tabs take significant horizontal space (5 buttons). Consolidating to dropdown:
|
||||||
|
|
||||||
|
- Frees space for persistent "Add Recipe" button
|
||||||
|
- Keeps filter + refresh on same row (per requirement)
|
||||||
|
- Mobile-friendly (dropdown vs. wrapping buttons)
|
||||||
|
- Still shows item counts in dropdown options
|
||||||
|
|
||||||
|
**References:**
|
||||||
|
|
||||||
|
- Homepage component: [src/routes/+page.svelte](src/routes/+page.svelte#L215-302)
|
||||||
|
- Empty state section: [src/routes/+page.svelte](src/routes/+page.svelte#L280-302)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [Planner] Research Notes - RECIPE-0009 Iteration 1 (2026-02-18)
|
||||||
|
|
||||||
|
**Task:** UI enhancements - footer status bar, icon-only buttons, toggle Add Recipe visibility
|
||||||
|
|
||||||
|
#### Current Homepage UI Structure Analysis
|
||||||
|
|
||||||
|
**Research Date:** 2026-02-18
|
||||||
|
**Source:** Analysis of [src/routes/+page.svelte](src/routes/+page.svelte), iteration 0 implementation
|
||||||
|
|
||||||
|
**Current Implementation (Iteration 0)**:
|
||||||
|
|
||||||
|
1. **Connection Status Widget** (lines 369-383):
|
||||||
|
- Fixed position: bottom-right (`fixed bottom-4 right-4`)
|
||||||
|
- Shows connection status with colored dot + text label
|
||||||
|
- Shows last ping timestamp
|
||||||
|
- Will be REMOVED and replaced with footer bar
|
||||||
|
|
||||||
|
2. **Action Bar** (lines 263-297):
|
||||||
|
- Filter dropdown (lines 266-276)
|
||||||
|
- Refresh button with icon + text (lines 277-285)
|
||||||
|
- Add Recipe button with icon + text (lines 288-297)
|
||||||
|
- Currently: Add Recipe button ALWAYS visible (iteration 0 requirement)
|
||||||
|
|
||||||
|
3. **Empty State** (lines 310-342):
|
||||||
|
- Shows when `!loading && filteredItems.length === 0`
|
||||||
|
- Contains "Add Recipe URL" link
|
||||||
|
|
||||||
|
**Changes Required for Iteration 1**:
|
||||||
|
|
||||||
|
1. Remove floating connection status widget
|
||||||
|
2. Add footer status bar (icons only)
|
||||||
|
3. Convert refresh button to icon-only
|
||||||
|
4. Convert Add Recipe button to icon-only
|
||||||
|
5. Toggle Add Recipe button visibility (hide when empty, show when has items)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Footer Status Bar Design - RECIPE-0009 Iteration 1
|
||||||
|
|
||||||
|
**Research Date:** 2026-02-18
|
||||||
|
**Source:** Web PWA patterns, existing codebase styling patterns
|
||||||
|
|
||||||
|
**Design Requirements**:
|
||||||
|
|
||||||
|
- **Position**: Fixed at bottom (`fixed bottom-0 left-0 right-0`)
|
||||||
|
- **Layout**: Full width with max-width container matching page layout (`max-w-6xl`)
|
||||||
|
- **Content**: Two sections (notification status left, live updates right)
|
||||||
|
- **Display**: Icons only, no text labels
|
||||||
|
- **Accessibility**: title and aria-label attributes on interactive elements
|
||||||
|
- **Z-index**: `z-50` to ensure visibility above all content
|
||||||
|
- **Visual**: White background, top border, shadow for lift effect
|
||||||
|
|
||||||
|
**State Integration**:
|
||||||
|
|
||||||
|
Footer needs access to two state sources:
|
||||||
|
|
||||||
|
1. **Notification Status**: Via `pushNotificationManager.getState()`
|
||||||
|
- Need to add `notificationViewModel` state variable in +page.svelte
|
||||||
|
- Subscribe to state changes in `onMount`
|
||||||
|
- Cleanup subscription in `onDestroy`
|
||||||
|
|
||||||
|
2. **Connection Status**: Already exists as `connectionStatus` state
|
||||||
|
- Reuse existing variable
|
||||||
|
- States: 'connecting' | 'connected' | 'disconnected'
|
||||||
|
|
||||||
|
**Notification Icon Logic**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (!supported || permission === 'denied') {
|
||||||
|
// Show bell with slash (not supported/denied)
|
||||||
|
icon = 'bell-slash';
|
||||||
|
color = 'text-gray-400';
|
||||||
|
} else if (subscribed) {
|
||||||
|
// Show bell icon (enabled)
|
||||||
|
icon = 'bell';
|
||||||
|
color = 'text-green-600';
|
||||||
|
} else {
|
||||||
|
// Show bell icon (available but not enabled)
|
||||||
|
icon = 'bell';
|
||||||
|
color = 'text-gray-400';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Live Update Indicator Logic**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (connectionStatus === 'connected') {
|
||||||
|
dotColor = 'bg-green-400';
|
||||||
|
title = 'Live updates active';
|
||||||
|
} else if (connectionStatus === 'connecting') {
|
||||||
|
dotColor = 'bg-yellow-400';
|
||||||
|
title = 'Connecting to live updates...';
|
||||||
|
} else {
|
||||||
|
dotColor = 'bg-red-400';
|
||||||
|
title = 'Live updates disconnected';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Click Behavior**:
|
||||||
|
|
||||||
|
Clicking notification icon scrolls to NotificationSettings component:
|
||||||
|
```typescript
|
||||||
|
onclick={() => {
|
||||||
|
document.querySelector('[data-notification-settings]')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires adding `data-notification-settings` attribute to NotificationSettings wrapper.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Icon-Only Button Patterns - RECIPE-0009 Iteration 1
|
||||||
|
|
||||||
|
**Research Date:** 2026-02-18
|
||||||
|
**Source:** Existing codebase button styles, Tailwind CSS documentation, WCAG 2.1 guidelines
|
||||||
|
|
||||||
|
**Current Button Pattern (with text)**:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<button class="flex items-center space-x-2 px-4 py-2 ...">
|
||||||
|
<svg class="w-4 h-4" ... />
|
||||||
|
<span>Button Text</span>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Padding: `px-4 py-2` (horizontal + vertical)
|
||||||
|
- Icon size: `w-4 h-4` (16x16px)
|
||||||
|
- Spacing: `space-x-2` (gap between icon and text)
|
||||||
|
|
||||||
|
**Icon-Only Button Pattern**:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<button
|
||||||
|
title="Button description"
|
||||||
|
aria-label="Button description"
|
||||||
|
class="p-2 ..."
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" ... />
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Changes**:
|
||||||
|
- Padding: `p-2` (square/circular button)
|
||||||
|
- Icon size: `w-5 h-5` (20x20px - slightly larger for better visibility)
|
||||||
|
- Remove: `space-x-2` class (no text to space from)
|
||||||
|
- Add: `title` attribute (tooltip on hover)
|
||||||
|
- Add: `aria-label` attribute (screen reader accessibility)
|
||||||
|
|
||||||
|
**Accessibility Requirements** (WCAG 2.1):
|
||||||
|
|
||||||
|
1. **Title Attribute**: Provides tooltip text for sighted users on hover
|
||||||
|
2. **Aria-label Attribute**: Provides accessible name for screen readers
|
||||||
|
3. **Minimum Touch Target**: 24x24px recommended (20x20px icon + 8px padding = 36x36px total ✓)
|
||||||
|
4. **Color Contrast**: Must meet 3:1 ratio for non-text (icons)
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
|
||||||
|
Refresh button:
|
||||||
|
```svelte
|
||||||
|
<button
|
||||||
|
title="Refresh queue"
|
||||||
|
aria-label="Refresh queue"
|
||||||
|
class="p-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 ..."
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" ... />
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add Recipe button:
|
||||||
|
```svelte
|
||||||
|
<a
|
||||||
|
href="/share"
|
||||||
|
title="Add recipe URL"
|
||||||
|
aria-label="Add recipe URL"
|
||||||
|
class="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 ..."
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" ... />
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Add Recipe Button Visibility Logic - RECIPE-0009 Iteration 1
|
||||||
|
|
||||||
|
**Research Date:** 2026-02-18
|
||||||
|
**Source:** context_compact.yaml requirement analysis, UX patterns
|
||||||
|
|
||||||
|
**Iteration 0 Implementation**:
|
||||||
|
- Add Recipe button ALWAYS visible in controls bar
|
||||||
|
- Rationale: User complained "do not hide the add recipe component when there are items in the queue"
|
||||||
|
|
||||||
|
**Iteration 1 Requirement**:
|
||||||
|
> "Toggle "Add Recipe" button visibility in controls bar (hide when queue empty, show when items exist - opposite of placeholder rule)"
|
||||||
|
|
||||||
|
**Interpretation**:
|
||||||
|
|
||||||
|
"Opposite of placeholder rule":
|
||||||
|
- Placeholder (empty state) shows when: `items.length === 0`
|
||||||
|
- Add Recipe button in controls shows when: `items.length > 0` (opposite condition)
|
||||||
|
|
||||||
|
**Logic**:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{#if items.length > 0}
|
||||||
|
<a href="/share" title="Add recipe URL" aria-label="Add recipe URL" ...>
|
||||||
|
<svg ... />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
|
||||||
|
1. **Empty State**: When queue is empty, user sees empty state with centered "Add Recipe URL" link
|
||||||
|
2. **Non-Empty State**: When queue has items, controls bar shows Add Recipe button (icon-only)
|
||||||
|
3. **No Redundancy**: Button doesn't appear when empty state link is already visible
|
||||||
|
4. **Consistent Access**: User always has access to "Add Recipe" via either empty state link OR controls bar button
|
||||||
|
|
||||||
|
**UX Benefits**:
|
||||||
|
|
||||||
|
- Cleaner UI when queue is empty (no redundant button)
|
||||||
|
- Convenient access when queue has items (quick add more recipes)
|
||||||
|
- Fulfills opposite condition of empty state placeholder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Svelte 5 Notification State Management
|
||||||
|
|
||||||
|
**Research Date:** 2026-02-18
|
||||||
|
**Source:** Existing iteration 0 implementation, [PushNotificationManager.ts](src/lib/client/PushNotificationManager.ts)
|
||||||
|
|
||||||
|
**NotificationState Type**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface NotificationState {
|
||||||
|
supported: boolean;
|
||||||
|
permission: NotificationPermission; // 'default' | 'granted' | 'denied'
|
||||||
|
subscribed: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**State Subscription Pattern**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Import type
|
||||||
|
import type { NotificationState } from '$lib/client/PushNotificationManager';
|
||||||
|
|
||||||
|
// Declare state
|
||||||
|
let notificationViewModel = $state<NotificationState | null>(null);
|
||||||
|
|
||||||
|
// Subscribe in onMount
|
||||||
|
onMount(() => {
|
||||||
|
// ... existing code ...
|
||||||
|
|
||||||
|
const unsubscribeNotifications = pushNotificationManager.onStateChange((newState) => {
|
||||||
|
notificationViewModel = newState;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribeNotifications?.();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cleanup in onDestroy**:
|
||||||
|
|
||||||
|
Current onDestroy only cleans up `eventSource`. Need to also cleanup notification subscription:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
onDestroy(() => {
|
||||||
|
if (eventSource) {
|
||||||
|
console.log('[SSE] Closing connection on component destroy');
|
||||||
|
eventSource.close();
|
||||||
|
connectionStatus = 'disconnected';
|
||||||
|
}
|
||||||
|
// No cleanup needed - handled by onMount return callback
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Svelte 5's `onMount` return function handles cleanup automatically when component unmounts.
|
||||||
|
|
||||||
|
**State Access in Footer**:
|
||||||
|
|
||||||
|
Footer component needs null-safe access since initial state is `null`:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{#if notificationViewModel}
|
||||||
|
{#if !notificationViewModel.supported || notificationViewModel.permission === 'denied'}
|
||||||
|
<!-- Show disabled icon -->
|
||||||
|
{:else if notificationViewModel.subscribed}
|
||||||
|
<!-- Show enabled icon -->
|
||||||
|
{:else}
|
||||||
|
<!-- Show available icon -->
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<!-- Loading state - show gray icon -->
|
||||||
|
<svg class="w-5 h-5 text-gray-400" ... />
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Initial State Handling**:
|
||||||
|
|
||||||
|
`pushNotificationManager.onStateChange()` sends initial state immediately on subscription, so `notificationViewModel` will be populated almost instantly after component mount.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Version:** 3.0
|
||||||
|
**Last Updated by:** Planner Agent (RECIPE-0009 Iteration 1)
|
||||||
|
**Next Update:** Developer Agent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Session Findings: Instagram Extraction & Production Lessons
|
||||||
|
|
||||||
|
*Recorded during active development sessions (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,135 +0,0 @@
|
|||||||
{
|
|
||||||
"cookies": [
|
|
||||||
{
|
|
||||||
"name": "csrftoken",
|
|
||||||
"value": "SDRORLyWEsWWty2ZoVGdER",
|
|
||||||
"domain": ".instagram.com",
|
|
||||||
"path": "/",
|
|
||||||
"expires": 1805935216.410097,
|
|
||||||
"httpOnly": false,
|
|
||||||
"secure": true,
|
|
||||||
"sameSite": "Lax"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "datr",
|
|
||||||
"value": "isQuaeXe5-2mFvFSOdcgVq0u",
|
|
||||||
"domain": ".instagram.com",
|
|
||||||
"path": "/",
|
|
||||||
"expires": 1799232653.525143,
|
|
||||||
"httpOnly": true,
|
|
||||||
"secure": true,
|
|
||||||
"sameSite": "None"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ig_did",
|
|
||||||
"value": "5650C8B9-B8D8-4102-9B49-F0668CE34202",
|
|
||||||
"domain": ".instagram.com",
|
|
||||||
"path": "/",
|
|
||||||
"expires": 1796208680.653147,
|
|
||||||
"httpOnly": true,
|
|
||||||
"secure": true,
|
|
||||||
"sameSite": "Lax"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mid",
|
|
||||||
"value": "aS7EigALAAHxXAxrkYg18Fzi-SR7",
|
|
||||||
"domain": ".instagram.com",
|
|
||||||
"path": "/",
|
|
||||||
"expires": 1799232653.525191,
|
|
||||||
"httpOnly": true,
|
|
||||||
"secure": true,
|
|
||||||
"sameSite": "None"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ds_user_id",
|
|
||||||
"value": "59661903731",
|
|
||||||
"domain": ".instagram.com",
|
|
||||||
"path": "/",
|
|
||||||
"expires": 1779151216.410198,
|
|
||||||
"httpOnly": false,
|
|
||||||
"secure": true,
|
|
||||||
"sameSite": "None"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "sessionid",
|
|
||||||
"value": "59661903731%3AbekaIlo4nn7x2n%3A29%3AAYhv1LJUsfRtBSH-WmDLVrxiM7T9UotIOM3XY3iHKQ",
|
|
||||||
"domain": ".instagram.com",
|
|
||||||
"path": "/",
|
|
||||||
"expires": 1797910987.674116,
|
|
||||||
"httpOnly": true,
|
|
||||||
"secure": true,
|
|
||||||
"sameSite": "Lax"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "wd",
|
|
||||||
"value": "1280x720",
|
|
||||||
"domain": ".instagram.com",
|
|
||||||
"path": "/",
|
|
||||||
"expires": 1771980018,
|
|
||||||
"httpOnly": false,
|
|
||||||
"secure": true,
|
|
||||||
"sameSite": "Lax"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "rur",
|
|
||||||
"value": "\"CLN\\05459661903731\\0541802911216:01fe0df629634929a5afb2d329011423972fad13b80d44aa8d827064e1ffa5112234bd5f\"",
|
|
||||||
"domain": ".instagram.com",
|
|
||||||
"path": "/",
|
|
||||||
"expires": -1,
|
|
||||||
"httpOnly": true,
|
|
||||||
"secure": true,
|
|
||||||
"sameSite": "Lax"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"origins": [
|
|
||||||
{
|
|
||||||
"origin": "https://www.instagram.com",
|
|
||||||
"localStorage": [
|
|
||||||
{
|
|
||||||
"name": "chatd-deviceid",
|
|
||||||
"value": "47cc5bd4-431f-4054-8aaf-3e05aa303bd0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "hb_timestamp",
|
|
||||||
"value": "1771374318548"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "IGSession",
|
|
||||||
"value": "k75336:1771377018401"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mutex_polaris_banzai",
|
|
||||||
"value": "dvrlku:1771375219401"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "pixel_fire_ts",
|
|
||||||
"value": "1771121302843"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "signal_flush_timestamp",
|
|
||||||
"value": "1771375100382"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Session",
|
|
||||||
"value": "2wm58s:1771375253401"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "has_interop_upgraded",
|
|
||||||
"value": "{\"lastCheckedAt\":1766366944051,\"status\":false}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ig_boost_on_web_campaign_upsell_shown",
|
|
||||||
"value": "false"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "mutex_banzai",
|
|
||||||
"value": "dvrlku:1771375219401"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "banzai:last_storage_flush",
|
|
||||||
"value": "1771366998859.2"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,14 @@ type CaptionCandidate = {
|
|||||||
brCount: number;
|
brCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProgressEventType = 'status' | 'method' | 'retry' | 'error' | 'thumbnail' | 'complete';
|
export type ProgressEventType =
|
||||||
|
| 'status'
|
||||||
|
| 'method'
|
||||||
|
| 'retry'
|
||||||
|
| 'error'
|
||||||
|
| 'thumbnail'
|
||||||
|
| 'complete'
|
||||||
|
| 'model_loading';
|
||||||
|
|
||||||
export interface ProgressEvent {
|
export interface ProgressEvent {
|
||||||
type: ProgressEventType;
|
type: ProgressEventType;
|
||||||
@@ -1379,22 +1386,29 @@ export async function extractTextAndThumbnail(
|
|||||||
});
|
});
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
// If we intercepted a full caption, use it immediately
|
// Always use DOM extraction (HTML Section) — it clicks "… more" in
|
||||||
if (interceptedCaption) {
|
// the browser and gets the fully expanded caption. The GraphQL
|
||||||
console.log('[Extractor] Using intercepted caption from network traffic');
|
// interception is unreliable: Instagram often truncates captions
|
||||||
const thumbnail = await extractThumbnailStealth(page, onProgress);
|
// in API responses without any "…." marker, so we cannot trust
|
||||||
onProgress?.({
|
// the intercepted text to be complete.
|
||||||
type: 'complete',
|
const capturedCaption = interceptedCaption as string | null;
|
||||||
message: 'Extraction completed via GraphQL interception',
|
if (capturedCaption) {
|
||||||
method: 'graphql-intercept',
|
console.log(
|
||||||
timestamp: new Date().toISOString()
|
`[Extractor] Intercepted GraphQL caption (${capturedCaption.length} chars) — always using DOM extraction for full text`
|
||||||
});
|
);
|
||||||
return { bodyText: cleanText(interceptedCaption), thumbnail };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await extractWithStrategies(url, page, context, onProgress);
|
const result = await extractWithStrategies(url, page, context, onProgress);
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
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');
|
throw new Error(result.error || 'Extraction failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
240
src/lib/server/instagram-extractor.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* Instagram extractor — yt-dlp subprocess implementation.
|
||||||
|
*
|
||||||
|
* Replaces the Playwright-based scraper. yt-dlp is maintained against
|
||||||
|
* Instagram's frontend churn, has no in-process state, and works on public
|
||||||
|
* reels without authentication. Login-walled reels can be supported by
|
||||||
|
* dropping a Netscape-format cookies file at the path under SECRETS_DIR.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { logError } from './utils/logger';
|
||||||
|
import type { ExtractedContent, ProgressCallback } from './extraction';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const YTDLP_TIMEOUT_MS = 60_000;
|
||||||
|
const IMAGE_FETCH_TIMEOUT_MS = 10_000;
|
||||||
|
const USER_AGENT =
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||||
|
|
||||||
|
const AUTH_PATHS = ['/app/secrets/auth.json', './secrets/auth.json'];
|
||||||
|
const COOKIE_PATHS = ['/app/secrets/cookies.txt', './secrets/cookies.txt'];
|
||||||
|
|
||||||
|
interface PlaywrightCookie {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
domain: string;
|
||||||
|
path: string;
|
||||||
|
expires: number;
|
||||||
|
httpOnly?: boolean;
|
||||||
|
secure?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert Playwright auth.json → Netscape cookies.txt next to it. */
|
||||||
|
function maybeConvertAuthJson(): void {
|
||||||
|
for (let i = 0; i < AUTH_PATHS.length; i++) {
|
||||||
|
const authPath = AUTH_PATHS[i];
|
||||||
|
const cookiePath = COOKIE_PATHS[i];
|
||||||
|
if (!existsSync(authPath)) continue;
|
||||||
|
|
||||||
|
// Always regenerate from auth.json so yt-dlp cannot overwrite our session cookies
|
||||||
|
try {
|
||||||
|
const auth = JSON.parse(readFileSync(authPath, 'utf8')) as {
|
||||||
|
cookies?: PlaywrightCookie[];
|
||||||
|
};
|
||||||
|
const cookies: PlaywrightCookie[] = auth.cookies ?? [];
|
||||||
|
const lines = [
|
||||||
|
'# Netscape HTTP Cookie File',
|
||||||
|
'# Auto-generated from auth.json by InstaChef.',
|
||||||
|
''
|
||||||
|
];
|
||||||
|
for (const c of cookies) {
|
||||||
|
const domain = c.domain.startsWith('.') ? c.domain : `.${c.domain}`;
|
||||||
|
const includeSubdomains = 'TRUE';
|
||||||
|
const secure = c.secure ? 'TRUE' : 'FALSE';
|
||||||
|
const expiry = Math.floor(c.expires > 0 ? c.expires : 0);
|
||||||
|
lines.push(
|
||||||
|
[domain, includeSubdomains, c.path ?? '/', secure, expiry, c.name, c.value].join('\t')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
writeFileSync(cookiePath, lines.join('\n') + '\n', 'utf8');
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: yt-dlp will just run without cookies
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCookiePath(): string | null {
|
||||||
|
maybeConvertAuthJson();
|
||||||
|
for (const p of COOKIE_PATHS) {
|
||||||
|
if (existsSync(p)) return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YtDlpJson {
|
||||||
|
description?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
thumbnail?: string | null;
|
||||||
|
thumbnails?: Array<{ url?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickThumbnailUrl(data: YtDlpJson): string | null {
|
||||||
|
if (data.thumbnail) return data.thumbnail;
|
||||||
|
const first = (data.thumbnails ?? []).find((t) => t?.url);
|
||||||
|
return first?.url ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchImageAsBase64(imageUrl: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(imageUrl, {
|
||||||
|
signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS)
|
||||||
|
});
|
||||||
|
if (response.status !== 200) return null;
|
||||||
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
|
if (!contentType.startsWith('image/')) return null;
|
||||||
|
const buf = Buffer.from(await response.arrayBuffer());
|
||||||
|
return `data:${contentType};base64,${buf.toString('base64')}`;
|
||||||
|
} catch (e) {
|
||||||
|
logError('[ytdlp] Thumbnail fetch failed', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyYtDlpError(stderr: string): { recoverable: boolean; reason: string } {
|
||||||
|
const lower = stderr.toLowerCase();
|
||||||
|
if (
|
||||||
|
lower.includes('login required') ||
|
||||||
|
lower.includes('login_required') ||
|
||||||
|
lower.includes('private') ||
|
||||||
|
lower.includes('rate-limit') ||
|
||||||
|
lower.includes('rate limit')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
recoverable: false,
|
||||||
|
reason:
|
||||||
|
'Instagram requires authentication for this reel. Drop a Netscape cookies.txt at secrets/cookies.txt and retry.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (lower.includes('unsupported url')) {
|
||||||
|
return { recoverable: false, reason: 'URL not recognised by yt-dlp.' };
|
||||||
|
}
|
||||||
|
if (lower.includes('http error 404') || lower.includes('does not exist')) {
|
||||||
|
return { recoverable: false, reason: 'Reel not found (404).' };
|
||||||
|
}
|
||||||
|
return { recoverable: true, reason: stderr.split('\n').filter(Boolean).slice(-2).join(' ') };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract caption text + thumbnail data-URL from an Instagram reel.
|
||||||
|
*
|
||||||
|
* Mirrors the signature of the legacy Playwright extractor so QueueProcessor
|
||||||
|
* needs no contract change. ProgressCallback events use existing types
|
||||||
|
* (`status`, `method`, `error`) so the SSE consumers do not need updates.
|
||||||
|
*/
|
||||||
|
export async function extractTextAndThumbnail(
|
||||||
|
url: string,
|
||||||
|
progressCallback?: ProgressCallback
|
||||||
|
): Promise<ExtractedContent> {
|
||||||
|
progressCallback?.({
|
||||||
|
type: 'status',
|
||||||
|
message: 'Invoking yt-dlp...',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const cookies = resolveCookiePath();
|
||||||
|
if (cookies) {
|
||||||
|
progressCallback?.({
|
||||||
|
type: 'status',
|
||||||
|
message: `Using cookies from ${cookies}`,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'--dump-single-json',
|
||||||
|
'--skip-download',
|
||||||
|
'--no-warnings',
|
||||||
|
'--no-call-home',
|
||||||
|
'--socket-timeout',
|
||||||
|
'20',
|
||||||
|
'--user-agent',
|
||||||
|
USER_AGENT,
|
||||||
|
...(cookies ? ['--cookies', cookies] : []),
|
||||||
|
url
|
||||||
|
];
|
||||||
|
|
||||||
|
let stdout: string;
|
||||||
|
try {
|
||||||
|
const result = await execFileAsync('yt-dlp', args, {
|
||||||
|
timeout: YTDLP_TIMEOUT_MS,
|
||||||
|
maxBuffer: 10 * 1024 * 1024
|
||||||
|
});
|
||||||
|
stdout = result.stdout;
|
||||||
|
} catch (e: any) {
|
||||||
|
const stderr = String(e?.stderr ?? e?.message ?? '');
|
||||||
|
const code = e?.code;
|
||||||
|
if (code === 'ENOENT') {
|
||||||
|
throw new Error(
|
||||||
|
'yt-dlp is not installed in this container. Add it to the Dockerfile.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { recoverable, reason } = classifyYtDlpError(stderr);
|
||||||
|
progressCallback?.({
|
||||||
|
type: 'error',
|
||||||
|
message: `yt-dlp failed: ${reason}`,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
const err = new Error(`yt-dlp extraction failed: ${reason}`);
|
||||||
|
// QueueProcessor.isRecoverableError() classifies on message; surface keywords.
|
||||||
|
if (!recoverable) (err as any).nonRecoverable = true;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: YtDlpJson;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(stdout);
|
||||||
|
} catch (e) {
|
||||||
|
logError('[ytdlp] Failed to parse yt-dlp JSON output', e);
|
||||||
|
throw new Error('yt-dlp returned invalid JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyText = (data.description ?? data.title ?? '').trim();
|
||||||
|
if (!bodyText) {
|
||||||
|
throw new Error('yt-dlp returned no description for this reel');
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCallback?.({
|
||||||
|
type: 'status',
|
||||||
|
message: `Caption extracted (${bodyText.length} chars)`,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
let thumbnail: string | null = null;
|
||||||
|
const thumbUrl = pickThumbnailUrl(data);
|
||||||
|
if (thumbUrl) {
|
||||||
|
progressCallback?.({
|
||||||
|
type: 'thumbnail',
|
||||||
|
message: 'Fetching thumbnail...',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
thumbnail = await fetchImageAsBase64(thumbUrl);
|
||||||
|
progressCallback?.({
|
||||||
|
type: 'status',
|
||||||
|
message: thumbnail ? 'Thumbnail fetched' : 'Thumbnail fetch failed (continuing without)',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCallback?.({
|
||||||
|
type: 'complete',
|
||||||
|
message: 'Extraction complete',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
return { bodyText, thumbnail };
|
||||||
|
}
|
||||||
@@ -2,15 +2,24 @@ import OpenAI from 'openai';
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { logError } from './utils/logger';
|
import { logError } from './utils/logger';
|
||||||
|
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
const parseTimeoutMs = (raw: string | undefined): number => {
|
||||||
|
if (!raw) return DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
|
};
|
||||||
|
|
||||||
export const createLLM = () => {
|
export const createLLM = () => {
|
||||||
// Detect if we are using Ollama or OpenAI based on URL
|
|
||||||
const baseURL = env.OPENAI_BASE_URL;
|
const baseURL = env.OPENAI_BASE_URL;
|
||||||
const apiKey = env.OPENAI_API_KEY;
|
const apiKey = env.OPENAI_API_KEY;
|
||||||
const model = env.LLM_MODEL || 'gpt-4o';
|
const model = env.LLM_MODEL || 'gpt-4o';
|
||||||
|
const timeout = parseTimeoutMs(env.LLM_REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
console.log('[LLM] Initializing client...');
|
console.log('[LLM] Initializing client...');
|
||||||
console.log('[LLM] Base URL:', baseURL);
|
console.log('[LLM] Base URL:', baseURL);
|
||||||
console.log('[LLM] Model:', model);
|
console.log('[LLM] Model:', model);
|
||||||
|
console.log('[LLM] Request timeout (ms):', timeout);
|
||||||
|
|
||||||
if (!baseURL) {
|
if (!baseURL) {
|
||||||
throw new Error('OPENAI_BASE_URL environment variable is not set');
|
throw new Error('OPENAI_BASE_URL environment variable is not set');
|
||||||
@@ -22,7 +31,9 @@ export const createLLM = () => {
|
|||||||
|
|
||||||
const client = new OpenAI({
|
const client = new OpenAI({
|
||||||
apiKey,
|
apiKey,
|
||||||
baseURL
|
baseURL,
|
||||||
|
timeout,
|
||||||
|
maxRetries: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
return { client, model };
|
return { client, model };
|
||||||
@@ -43,6 +54,47 @@ export async function checkLLMHealth(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip a trailing /v1 (or /v1/) from a base URL to get the llama-swap root.
|
||||||
|
* llama-swap exposes both /v1/* (OpenAI-compatible) and /running, /upstream, etc.
|
||||||
|
* at the bare root.
|
||||||
|
*/
|
||||||
|
function llamaSwapRoot(baseURL: string): string {
|
||||||
|
return baseURL.replace(/\/v1\/?$/, '').replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunningModelEntry {
|
||||||
|
model: string;
|
||||||
|
state?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query llama-swap's /running endpoint and report whether `model` is currently
|
||||||
|
* loaded and ready to serve. Returns false on any error (treat as cold).
|
||||||
|
*
|
||||||
|
* Why we don't fold this into checkModelAvailability(): /v1/models lists every
|
||||||
|
* model llama-swap is configured to swap to (not just loaded ones), while
|
||||||
|
* /running returns only the in-VRAM instance. Both signals are useful.
|
||||||
|
*/
|
||||||
|
export async function isModelLoaded(model: string): Promise<boolean> {
|
||||||
|
const baseURL = env.OPENAI_BASE_URL;
|
||||||
|
if (!baseURL) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${llamaSwapRoot(baseURL)}/running`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: AbortSignal.timeout(5_000)
|
||||||
|
});
|
||||||
|
if (!response.ok) return false;
|
||||||
|
const data = (await response.json()) as { running?: RunningModelEntry[] };
|
||||||
|
const running = data.running ?? [];
|
||||||
|
return running.some((m) => m.model === model && (m.state ?? 'ready') === 'ready');
|
||||||
|
} catch (e) {
|
||||||
|
logError('[LLM] isModelLoaded check failed', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a specific model is available in the OpenAI-compatible API
|
* Check if a specific model is available in the OpenAI-compatible API
|
||||||
* @param model - The model ID to check for availability
|
* @param model - The model ID to check for availability
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createLLM, checkModelAvailability } from './llm';
|
import { createLLM, checkModelAvailability, isModelLoaded } from './llm';
|
||||||
import { zodResponseFormat } from 'openai/helpers/zod';
|
import { zodResponseFormat } from 'openai/helpers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { RECIPE_DETECTION_PROMPT, RECIPE_EXTRACTION_PROMPT } from './prompts/recipe-extraction';
|
import { RECIPE_DETECTION_PROMPT, RECIPE_EXTRACTION_PROMPT } from './prompts/recipe-extraction';
|
||||||
import { logError } from './utils/logger';
|
import { logError } from './utils/logger';
|
||||||
|
import type { ProgressCallback } from './extraction';
|
||||||
|
|
||||||
const RecipeSchema = z.object({
|
const RecipeSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
@@ -48,11 +49,17 @@ export async function detectRecipe(text: string): Promise<boolean> {
|
|||||||
content: `Does this text contain a recipe?\n\n${text}`
|
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
|
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);
|
console.log('[LLM] Detection response:', detectionResult);
|
||||||
|
|
||||||
return detectionResult.includes('yes');
|
return detectionResult.includes('yes');
|
||||||
@@ -144,11 +151,33 @@ export async function parseRecipe(text: string): Promise<Recipe> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete workflow: detect recipe and parse if found
|
* Complete workflow: detect recipe and parse if found.
|
||||||
|
*
|
||||||
|
* Emits a `model_loading` progress event (if a callback is supplied) when the
|
||||||
|
* configured llama-swap model is not yet warm — the first request after idle
|
||||||
|
* blocks for several seconds while llama-swap loads the model into VRAM.
|
||||||
|
*
|
||||||
* @param text - The text to analyze
|
* @param text - The text to analyze
|
||||||
|
* @param progressCallback - Optional callback for surfacing cold-load state
|
||||||
* @returns Parsed recipe object if detected, null otherwise
|
* @returns Parsed recipe object if detected, null otherwise
|
||||||
*/
|
*/
|
||||||
export async function extractRecipe(text: string): Promise<Recipe | null> {
|
export async function extractRecipe(
|
||||||
|
text: string,
|
||||||
|
progressCallback?: ProgressCallback
|
||||||
|
): Promise<Recipe | null> {
|
||||||
|
if (progressCallback) {
|
||||||
|
const { model } = createLLM();
|
||||||
|
const warm = await isModelLoaded(model);
|
||||||
|
if (!warm) {
|
||||||
|
progressCallback({
|
||||||
|
type: 'model_loading',
|
||||||
|
message: `Inference server cold — loading ${model} into VRAM (5–30s)...`,
|
||||||
|
data: { model },
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isRecipe = await detectRecipe(text);
|
const isRecipe = await detectRecipe(text);
|
||||||
|
|
||||||
if (!isRecipe) {
|
if (!isRecipe) {
|
||||||
|
|||||||
@@ -9,32 +9,33 @@
|
|||||||
|
|
||||||
export const RECIPE_DETECTION_PROMPT = `You are a recipe detector for social media posts.
|
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":
|
SAY "yes" if the text has:
|
||||||
1. Recipe name/title is present
|
1. A dish name or title
|
||||||
2. At least 3 ingredients with quantities (even if approximate)
|
2. At least 2 ingredients (quantities optional — many posts omit them)
|
||||||
3. At least 2 cooking steps
|
|
||||||
|
|
||||||
IGNORE:
|
Steps are NOT required — many Instagram posts list only ingredients and link to the full recipe elsewhere.
|
||||||
- Hashtags (#recipe, #food, etc.)
|
|
||||||
- Mentions (@username)
|
SAY "no" only if the text has NO ingredients at all (e.g. pure food appreciation posts, restaurant reviews, memes).
|
||||||
- Emojis
|
|
||||||
- Like counts, comments, social metadata
|
IGNORE: hashtags, @mentions, emojis, "save this", "follow me", "link in bio" phrases.
|
||||||
- Promotional text
|
|
||||||
|
|
||||||
OUTPUT: Answer with ONLY 'yes' or 'no' - nothing else.
|
OUTPUT: Answer with ONLY 'yes' or 'no' - nothing else.
|
||||||
|
|
||||||
EXAMPLES:
|
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
|
Answer: yes
|
||||||
|
|
||||||
Text: "Amazing dinner tonight! 😍 So delicious! 🔥 #foodporn"
|
Text: "Amazing dinner tonight! 😍 So delicious! 🔥 #foodporn"
|
||||||
Answer: no
|
Answer: no
|
||||||
|
|
||||||
Text: "You need pasta, tomatoes, and garlic for this recipe"
|
Text: "Best restaurant in Milano! You have to try it 🙌"
|
||||||
Answer: no (missing steps)
|
Answer: no
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const RECIPE_EXTRACTION_PROMPT = `You are an EXPERT RECIPE EXTRACTOR specialized in parsing recipes from social media posts.
|
export const RECIPE_EXTRACTION_PROMPT = `You are an EXPERT RECIPE EXTRACTOR specialized in parsing recipes from social media posts.
|
||||||
|
|||||||
@@ -47,6 +47,21 @@ export class QueueManager {
|
|||||||
/** Set of subscriber callbacks */
|
/** Set of subscriber callbacks */
|
||||||
private subscribers: Set<QueueUpdateCallback> = new Set();
|
private subscribers: Set<QueueUpdateCallback> = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find queue item by URL
|
||||||
|
*
|
||||||
|
* @param url - Instagram URL to search for
|
||||||
|
* @returns Existing queue item or undefined
|
||||||
|
*/
|
||||||
|
findByUrl(url: string): QueueItem | undefined {
|
||||||
|
for (const item of this.items.values()) {
|
||||||
|
if (item.url === url) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add URL to processing queue
|
* Add URL to processing queue
|
||||||
*
|
*
|
||||||
@@ -60,6 +75,13 @@ export class QueueManager {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
enqueue(url: string): QueueItem {
|
enqueue(url: string): QueueItem {
|
||||||
|
// Check for duplicate URL
|
||||||
|
const existingItem = this.findByUrl(url);
|
||||||
|
if (existingItem) {
|
||||||
|
console.log(`[QueueManager] Duplicate URL detected: ${url}, returning existing item ${existingItem.id}`);
|
||||||
|
return existingItem;
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const item: QueueItem = {
|
const item: QueueItem = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
|
|||||||
@@ -12,15 +12,43 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { queueManager } from './QueueManager';
|
import { queueManager } from './QueueManager';
|
||||||
import { extractTextAndThumbnail } from '$lib/server/extraction';
|
import { extractTextAndThumbnail as extractWithPlaywright } from '$lib/server/extraction';
|
||||||
|
import { extractTextAndThumbnail as extractWithYtDlp } from '$lib/server/instagram-extractor';
|
||||||
import { extractRecipe } from '$lib/server/parser';
|
import { extractRecipe } from '$lib/server/parser';
|
||||||
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
|
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
|
||||||
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService';
|
import { pushNotificationService } from '$lib/server/notifications/PushNotificationService';
|
||||||
import { queueConfig } from './config';
|
import { queueConfig } from './config';
|
||||||
import { logError } from '../utils/logger';
|
import { logError } from '../utils/logger';
|
||||||
import type { ProgressEvent } from '$lib/server/extraction';
|
import type { ProgressEvent, ExtractedContent, ProgressCallback } from '$lib/server/extraction';
|
||||||
import type { QueueItem } from './types';
|
import type { QueueItem } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract caption via Playwright (full, untruncated) and thumbnail via yt-dlp
|
||||||
|
* (fast, reliable CDN URL). Both run in parallel; yt-dlp failure is non-fatal.
|
||||||
|
*/
|
||||||
|
async function extractTextAndThumbnail(
|
||||||
|
url: string,
|
||||||
|
cb?: ProgressCallback
|
||||||
|
): Promise<ExtractedContent> {
|
||||||
|
// Run Playwright (caption) and yt-dlp (thumbnail) concurrently
|
||||||
|
const [ytdlpResult, playwrightResult] = await Promise.allSettled([
|
||||||
|
extractWithYtDlp(url),
|
||||||
|
extractWithPlaywright(url, cb)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (playwrightResult.status === 'rejected') {
|
||||||
|
throw playwrightResult.reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer yt-dlp thumbnail; fall back to whatever Playwright captured
|
||||||
|
const thumbnail =
|
||||||
|
ytdlpResult.status === 'fulfilled' && ytdlpResult.value.thumbnail
|
||||||
|
? ytdlpResult.value.thumbnail
|
||||||
|
: playwrightResult.value.thumbnail;
|
||||||
|
|
||||||
|
return { bodyText: playwrightResult.value.bodyText, thumbnail };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue processor with configurable concurrency
|
* Queue processor with configurable concurrency
|
||||||
*
|
*
|
||||||
@@ -250,7 +278,9 @@ export class QueueProcessor {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[QueueProcessor] Parsing recipe: ${item.id}`);
|
console.log(`[QueueProcessor] Parsing recipe: ${item.id}`);
|
||||||
const recipe = await extractRecipe(item.extractedText);
|
const recipe = await extractRecipe(item.extractedText, (event) => {
|
||||||
|
queueManager.addProgressEvent(item.id, event);
|
||||||
|
});
|
||||||
|
|
||||||
if (!recipe) {
|
if (!recipe) {
|
||||||
throw new Error('Failed to parse recipe from extracted text');
|
throw new Error('Failed to parse recipe from extracted text');
|
||||||
|
|||||||
@@ -234,10 +234,13 @@ function parseAmount(amountStr: string): number | null {
|
|||||||
* Includes ingredients partitioned across steps
|
* Includes ingredients partitioned across steps
|
||||||
*/
|
*/
|
||||||
function buildTandoorRecipeDTO(recipe: ExtractedRecipe): TandoorRecipeDTO {
|
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 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.
|
// Map ingredients, converting unparseable amounts to 1 q.b.
|
||||||
const mappedIngredients = (ingredientPartitions[index] || []).map((ing) => {
|
const mappedIngredients = (ingredientPartitions[index] || []).map((ing) => {
|
||||||
const amount = parseAmount(ing.amount);
|
const amount = parseAmount(ing.amount);
|
||||||
|
|||||||
@@ -1,16 +1,41 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
|
||||||
import InstallPrompt from './components/InstallPrompt.svelte';
|
import InstallPrompt from './components/InstallPrompt.svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
|
|
||||||
let { children } = $props();
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<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>
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="ic-root" data-theme="light" id="ic-root">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- PWA Install Prompt -->
|
<!-- PWA Install Prompt -->
|
||||||
<InstallPrompt />
|
<InstallPrompt />
|
||||||
|
|
||||||
|
|||||||
@@ -3,341 +3,467 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import type { QueueItem, QueueStatusUpdate } from '$lib/server/queue/types';
|
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 { replaceState } from '$app/navigation';
|
||||||
|
import { pushNotificationManager } from '$lib/client/PushNotificationManager';
|
||||||
|
|
||||||
|
import TopBar from './components/TopBar.svelte';
|
||||||
|
import CookingHero from './components/CookingHero.svelte';
|
||||||
|
import TimelineRow from './components/TimelineRow.svelte';
|
||||||
|
import EmptyState from './components/EmptyState.svelte';
|
||||||
|
import RecipeSheet from './components/RecipeSheet.svelte';
|
||||||
|
import SectionHead from './components/SectionHead.svelte';
|
||||||
|
import AddUrlScreen from './components/AddUrlScreen.svelte';
|
||||||
|
import NotificationsScreen from './components/NotificationsScreen.svelte';
|
||||||
|
|
||||||
|
// ── State ──────────────────────────────────────────────────
|
||||||
let items = $state<QueueItem[]>([]);
|
let items = $state<QueueItem[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let loadError = $state<string | null>(null);
|
||||||
let filter = $state<string>('all');
|
let filter = $state<'all' | 'in_progress' | 'success' | 'error'>('all');
|
||||||
let eventSource = $state<EventSource | null>(null);
|
let eventSource = $state<EventSource | null>(null);
|
||||||
let connectionStatus = $state<'connecting' | 'connected' | 'disconnected'>('disconnected');
|
let connectionStatus = $state<'connecting' | 'connected' | 'disconnected'>('disconnected');
|
||||||
let lastPing = $state<string | null>(null);
|
let lastPing = $state<string | null>(null);
|
||||||
|
let hasAttemptedAutoSubscribe = $state(false);
|
||||||
|
|
||||||
|
// Screen router
|
||||||
|
let screen = $state<'home' | 'addurl' | 'notifications'>('home');
|
||||||
|
// Recipe detail sheet
|
||||||
|
let selectedItem = $state<QueueItem | null>(null);
|
||||||
|
// "How it works" dismissible card
|
||||||
|
let showHowTo = $state(true);
|
||||||
|
|
||||||
// Get highlighted item ID from URL params (when redirected from Share page)
|
// 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
|
// ── Derived ────────────────────────────────────────────────
|
||||||
let filters = $derived([
|
const filteredItems = $derived.by(() => {
|
||||||
{ 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(() => {
|
|
||||||
if (filter === 'all') return items;
|
if (filter === 'all') return items;
|
||||||
if (filter === 'error') return items.filter(item => item.status === 'error' || item.status === 'unhealthy');
|
if (filter === 'error') return items.filter((i) => i.status === 'error' || i.status === 'unhealthy');
|
||||||
return items.filter(item => item.status === filter);
|
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 () => {
|
onMount(async () => {
|
||||||
await loadQueueItems();
|
await loadQueueItems();
|
||||||
if (browser) {
|
if (browser) {
|
||||||
startSSEConnection();
|
startSSEConnection();
|
||||||
|
setupAutoSubscribe();
|
||||||
|
unsubscribeNotifications = pushNotificationManager.onStateChange(() => {});
|
||||||
|
|
||||||
|
// Open RecipeSheet for highlighted item
|
||||||
|
if (highlightId) {
|
||||||
|
const found = items.find((i) => i.id === highlightId);
|
||||||
|
if (found) { selectedItem = found; clearHighlight(); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (eventSource) {
|
eventSource?.close();
|
||||||
console.log('[SSE] Closing connection on component destroy');
|
|
||||||
eventSource.close();
|
|
||||||
connectionStatus = 'disconnected';
|
connectionStatus = 'disconnected';
|
||||||
}
|
unsubscribeNotifications?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Data fetching ──────────────────────────────────────────
|
||||||
async function loadQueueItems() {
|
async function loadQueueItems() {
|
||||||
try {
|
try {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
loadError = null;
|
||||||
|
|
||||||
const response = await fetch('/api/queue');
|
const response = await fetch('/api/queue');
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error('Failed to load queue items');
|
||||||
throw new Error('Failed to load queue items');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
items = data.items || [];
|
items = data.items || [];
|
||||||
} catch (e) {
|
} 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);
|
console.error('Failed to load queue items:', e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startSSEConnection() {
|
async function submitUrl(url: string) {
|
||||||
if (!browser) {
|
try {
|
||||||
console.error('Cannot start SSE connection on server side');
|
const response = await fetch('/api/queue', {
|
||||||
return; // Guard: EventSource is browser-only API
|
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';
|
connectionStatus = 'connecting';
|
||||||
console.log('[SSE] Connecting to queue stream...');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
eventSource = new EventSource('/api/queue/stream');
|
eventSource = new EventSource('/api/queue/stream');
|
||||||
|
eventSource.addEventListener('open', () => { connectionStatus = 'connected'; });
|
||||||
eventSource.addEventListener('open', () => {
|
eventSource.addEventListener('connection', () => { connectionStatus = 'connected'; });
|
||||||
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('queue-update', (event) => {
|
eventSource.addEventListener('queue-update', (event) => {
|
||||||
const update: QueueStatusUpdate = JSON.parse(event.data);
|
updateQueueItem(JSON.parse(event.data) as QueueStatusUpdate);
|
||||||
updateQueueItem(update);
|
|
||||||
});
|
});
|
||||||
|
eventSource.addEventListener('error', () => {
|
||||||
eventSource.addEventListener('error', (event) => {
|
|
||||||
console.error('[SSE] Connection error:', event);
|
|
||||||
connectionStatus = 'disconnected';
|
connectionStatus = 'disconnected';
|
||||||
|
|
||||||
// Attempt to reconnect after 5 seconds
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// EventSource.CLOSED = 2 (use numeric constant for SSR safety)
|
if (eventSource?.readyState === 2) startSSEConnection();
|
||||||
if (eventSource?.readyState === 2) {
|
|
||||||
console.log('[SSE] Attempting reconnection...');
|
|
||||||
startSSEConnection();
|
|
||||||
}
|
|
||||||
}, 5000);
|
}, 5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
eventSource.addEventListener('ping', (event) => {
|
eventSource.addEventListener('ping', (event) => {
|
||||||
// Keep-alive ping, update last ping timestamp
|
lastPing = JSON.parse(event.data).timestamp;
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
lastPing = data.timestamp;
|
|
||||||
console.log('[SSE] Keep-alive ping received at:', data.timestamp);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[SSE] Failed to start SSE connection:', e);
|
console.error('[SSE] Failed to start:', e);
|
||||||
connectionStatus = 'disconnected';
|
connectionStatus = 'disconnected';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupAutoSubscribe() {
|
||||||
|
if (hasAttemptedAutoSubscribe) return;
|
||||||
|
const attempt = async () => {
|
||||||
|
if (hasAttemptedAutoSubscribe) return;
|
||||||
|
hasAttemptedAutoSubscribe = true;
|
||||||
|
const state = pushNotificationManager.getState();
|
||||||
|
if (state.supported && state.permission !== 'denied' && !state.subscribed) {
|
||||||
|
await pushNotificationManager.subscribe();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('click', attempt, { once: true });
|
||||||
|
document.addEventListener('touchstart', attempt, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
function updateQueueItem(update: QueueStatusUpdate) {
|
function updateQueueItem(update: QueueStatusUpdate) {
|
||||||
// Find and update the item in the list
|
const idx = items.findIndex((i) => i.id === update.itemId);
|
||||||
const itemIndex = items.findIndex(item => item.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) {
|
items[idx] = {
|
||||||
// Update existing item
|
...prev,
|
||||||
items[itemIndex] = {
|
|
||||||
...items[itemIndex],
|
|
||||||
status: update.status,
|
status: update.status,
|
||||||
phases: update.progress || items[itemIndex].phases,
|
// currentPhase is sent as update.phase on status_change events
|
||||||
results: update.results || items[itemIndex].results,
|
currentPhase: update.phase ?? prev.currentPhase,
|
||||||
error: update.error || items[itemIndex].error,
|
phases: update.progress || prev.phases,
|
||||||
|
progressEvents: newEvents,
|
||||||
|
results: update.results || prev.results,
|
||||||
|
error: update.error || prev.error,
|
||||||
updatedAt: update.timestamp
|
updatedAt: update.timestamp
|
||||||
};
|
};
|
||||||
|
// Keep selectedItem in sync
|
||||||
|
if (selectedItem?.id === update.itemId) selectedItem = items[idx];
|
||||||
} else {
|
} else {
|
||||||
// New item - fetch full details from API
|
|
||||||
fetchQueueItem(update.itemId);
|
fetchQueueItem(update.itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger reactivity
|
|
||||||
items = [...items];
|
items = [...items];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchQueueItem(id: string) {
|
async function fetchQueueItem(id: string) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/queue/${id}`);
|
const response = await fetch(`/api/queue/${id}`);
|
||||||
if (response.ok) {
|
if (response.ok) items = [await response.json(), ...items];
|
||||||
const item = await response.json();
|
|
||||||
items = [item, ...items]; // Add to top of list
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch queue item:', e);
|
console.error('Failed to fetch queue item:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Actions ────────────────────────────────────────────────
|
||||||
async function retryItem(id: string) {
|
async function retryItem(id: string) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/queue/${id}/retry`, {
|
const response = await fetch(`/api/queue/${id}/retry`, { method: 'POST' });
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const err = await response.json();
|
||||||
throw new Error(errorData.message || 'Failed to retry item');
|
throw new Error(err.message || 'Failed to retry');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Item will be updated via SSE
|
|
||||||
console.log('Retry initiated for item:', id);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to retry item:', e);
|
console.error('Failed to retry item:', e);
|
||||||
// Could show a toast notification here
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeItem(id: string) {
|
async function removeItem(id: string) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/queue/${id}`, {
|
await fetch(`/api/queue/${id}`, { method: 'DELETE' });
|
||||||
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);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to remove item:', e);
|
console.error('Failed to remove item:', e);
|
||||||
// Fallback: remove from local state anyway
|
} finally {
|
||||||
items = items.filter(item => item.id !== id);
|
items = items.filter((i) => i.id !== id);
|
||||||
|
if (selectedItem?.id === id) selectedItem = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearHighlight() {
|
function clearHighlight() {
|
||||||
// Remove highlight parameter from URL without navigation
|
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.delete('highlight');
|
url.searchParams.delete('highlight');
|
||||||
replaceState(url, {});
|
replaceState(url, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Queue positions for pending items
|
||||||
|
function queuePos(item: QueueItem): number {
|
||||||
|
return items.filter((i) => i.status === 'pending').indexOf(item) + 1;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>InstaRecipe Queue Dashboard</title>
|
<title>InstaChef</title>
|
||||||
<meta name="description" content="Monitor your recipe extraction queue in real-time" />
|
<meta name="description" content="Cook anything from an Instagram link." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="mx-auto p-6 max-w-6xl">
|
<div class="app-root">
|
||||||
<!-- Header -->
|
<!-- ── Home screen ────────────────────────────────────────── -->
|
||||||
<div class="mb-8">
|
{#if screen === 'home'}
|
||||||
<h1 class="text-3xl font-bold mb-2">Recipe Queue Dashboard</h1>
|
<div class="ic-scroll home-scroll">
|
||||||
<p class="text-gray-600">Monitor your Instagram recipe extractions in real-time</p>
|
<TopBar
|
||||||
</div>
|
count={items.length}
|
||||||
|
notifCount={0}
|
||||||
|
onNotifications={() => (screen = 'notifications')}
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Action Bar -->
|
{#if loading}
|
||||||
<div class="mb-6 flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
|
<div class="loading-wrap">
|
||||||
<!-- Filter Tabs -->
|
<div class="spinner"></div>
|
||||||
<div class="flex flex-wrap gap-2">
|
</div>
|
||||||
{#each filters as filterOption}
|
{:else if loadError}
|
||||||
<button
|
<div class="err-banner">Failed to load queue: {loadError}</div>
|
||||||
onclick={() => filter = filterOption.id}
|
{:else if items.length === 0}
|
||||||
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {filter === filterOption.id
|
<EmptyState
|
||||||
? 'bg-blue-600 text-white'
|
onAdd={() => (screen = 'addurl')}
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'}"
|
{showHowTo}
|
||||||
>
|
onDismissHowTo={() => (showHowTo = false)}
|
||||||
{filterOption.name}
|
/>
|
||||||
{#if filterOption.count > 0}
|
{:else}
|
||||||
<span class="ml-1 {filter === filterOption.id ? 'text-blue-100' : 'text-gray-500'}">
|
<!-- Cooking hero -->
|
||||||
({filterOption.count})
|
{#if cooking && filter !== 'success' && filter !== 'error'}
|
||||||
</span>
|
<CookingHero item={cooking} onTap={() => (selectedItem = cooking)} />
|
||||||
{/if}
|
{/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>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Refresh Button -->
|
<!-- Timeline groups -->
|
||||||
<button
|
<div class="timeline">
|
||||||
onclick={loadQueueItems}
|
{#each (['In line', 'Today', 'Yesterday', 'Earlier'] as const) as g}
|
||||||
disabled={loading}
|
{#if groups[g]?.length}
|
||||||
class="flex items-center space-x-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 disabled:opacity-50 transition-colors"
|
<SectionHead>{g}</SectionHead>
|
||||||
>
|
{#each groups[g] as it (it.id)}
|
||||||
<svg class="w-4 h-4 {loading ? 'animate-spin' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<TimelineRow
|
||||||
<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>
|
item={it}
|
||||||
</svg>
|
queuePosition={queuePos(it)}
|
||||||
<span>Refresh</span>
|
onTap={() => (selectedItem = it)}
|
||||||
</button>
|
onRetry={retryItem}
|
||||||
</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}
|
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Notification Settings - Always visible -->
|
<!-- Sticky add button -->
|
||||||
<div class="mt-8">
|
<div class="add-fab-wrap">
|
||||||
<NotificationSettings />
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Connection Status -->
|
<!-- ── Add URL screen ────────────────────────────────────── -->
|
||||||
<div class="fixed bottom-4 right-4">
|
{:else if screen === 'addurl'}
|
||||||
<div class="flex items-center space-x-2 px-3 py-2 bg-white border rounded-lg shadow-sm text-sm">
|
<div class="ic-scroll screen-scroll">
|
||||||
<div class="w-2 h-2 rounded-full {
|
<AddUrlScreen
|
||||||
connectionStatus === 'connected' ? 'bg-green-400' :
|
onBack={() => (screen = 'home')}
|
||||||
connectionStatus === 'connecting' ? 'bg-yellow-400' :
|
onSubmit={submitUrl}
|
||||||
'bg-red-400'
|
/>
|
||||||
}"></div>
|
</div>
|
||||||
<span class="text-gray-600">
|
|
||||||
{connectionStatus === 'connected' ? 'Live updates' :
|
<!-- ── Notifications screen ──────────────────────────────── -->
|
||||||
connectionStatus === 'connecting' ? 'Connecting...' :
|
{:else if screen === 'notifications'}
|
||||||
'Disconnected'}
|
<div class="ic-scroll screen-scroll">
|
||||||
</span>
|
<NotificationsScreen
|
||||||
{#if lastPing}
|
onBack={() => (screen = 'home')}
|
||||||
<span class="text-xs text-gray-400">
|
sseConnected={connectionStatus === 'connected'}
|
||||||
({new Date(lastPing).toLocaleTimeString()})
|
{sseLastPing}
|
||||||
</span>
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── Recipe sheet overlay ─────────────────────────────── -->
|
||||||
|
{#if selectedItem}
|
||||||
|
<RecipeSheet
|
||||||
|
item={selectedItem}
|
||||||
|
onClose={() => (selectedItem = null)}
|
||||||
|
onRetry={(id) => { retryItem(id); selectedItem = null; }}
|
||||||
|
onDelete={(id) => { removeItem(id); selectedItem = null; }}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -1,34 +1,48 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { checkLLMHealth } from '$lib/server/llm';
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { checkLLMHealth, isModelLoaded } from '$lib/server/llm';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Health check endpoint for LLM service
|
* Health check endpoint for the LLM service (llama-swap on ideapad).
|
||||||
* Tests connectivity to LM Studio or OpenAI-compatible endpoint
|
*
|
||||||
|
* Three states:
|
||||||
|
* - ok → endpoint reachable AND configured model is loaded in VRAM
|
||||||
|
* - warming → endpoint reachable but configured model not yet loaded
|
||||||
|
* (next request will trigger a cold load)
|
||||||
|
* - error → endpoint unreachable
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const isHealthy = await checkLLMHealth();
|
const reachable = await checkLLMHealth();
|
||||||
|
const configuredModel = env.LLM_MODEL || 'gpt-4o';
|
||||||
|
|
||||||
if (isHealthy) {
|
if (!reachable) {
|
||||||
return json({
|
|
||||||
status: 'healthy',
|
|
||||||
message: 'LLM service is accessible'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return json(
|
return json(
|
||||||
{
|
{
|
||||||
status: 'unhealthy',
|
status: 'error',
|
||||||
message: 'LLM service is not accessible'
|
message: 'LLM service is not accessible',
|
||||||
|
configuredModel
|
||||||
},
|
},
|
||||||
{ status: 503 }
|
{ status: 503 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const warm = await isModelLoaded(configuredModel);
|
||||||
|
return json({
|
||||||
|
status: warm ? 'ok' : 'warming',
|
||||||
|
message: warm
|
||||||
|
? `Model ${configuredModel} loaded and ready`
|
||||||
|
: `Model ${configuredModel} configured; next request will trigger a cold load`,
|
||||||
|
configuredModel,
|
||||||
|
loaded: warm
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
return json(
|
return json(
|
||||||
{
|
{
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: errorMessage
|
message: errorMessage,
|
||||||
|
configuredModel: env.LLM_MODEL || 'gpt-4o'
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,15 +50,25 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
throw new ValidationError(validation.error || 'Invalid Instagram URL');
|
throw new ValidationError(validation.error || 'Invalid Instagram URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue the URL
|
// Check for duplicate before enqueueing
|
||||||
|
const existingItem = queueManager.findByUrl(url);
|
||||||
|
|
||||||
|
if (existingItem) {
|
||||||
|
// Return info response for duplicate
|
||||||
|
return json({
|
||||||
|
duplicate: true,
|
||||||
|
message: 'This recipe is already in the queue',
|
||||||
|
item: existingItem
|
||||||
|
}, { status: 200 }); // 200 OK, not an error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue new URL
|
||||||
const queueItem = queueManager.enqueue(url);
|
const queueItem = queueManager.enqueue(url);
|
||||||
|
|
||||||
// Return minimal response (full details available at GET /api/queue/{id})
|
// Return success response
|
||||||
return json({
|
return json({
|
||||||
id: queueItem.id,
|
duplicate: false,
|
||||||
url: queueItem.url,
|
item: queueItem
|
||||||
status: queueItem.status,
|
|
||||||
enqueuedAt: queueItem.enqueuedAt
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleApiError(error);
|
return handleApiError(error);
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export const GET: RequestHandler = async ({ url, request }) => {
|
|||||||
status: item.status,
|
status: item.status,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
url: item.url,
|
url: item.url,
|
||||||
|
phase: item.currentPhase,
|
||||||
progress: item.phases,
|
progress: item.phases,
|
||||||
results: item.results,
|
results: item.results,
|
||||||
error: item.error
|
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 { onMount } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { pwaInstallManager } from '$lib/client/PWAInstallManager';
|
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 showPrompt = $state(false);
|
||||||
let showFallback = $state(false);
|
let showFallback = $state(false);
|
||||||
@@ -11,36 +16,18 @@
|
|||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
onMount(() => {
|
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) => {
|
unsubscribe = pwaInstallManager.onInstallStateChange((installable) => {
|
||||||
canInstall = installable;
|
canInstall = installable;
|
||||||
|
|
||||||
// Show prompt after user engagement and delay
|
|
||||||
if (installable && userEngaged && !pwaInstallManager.isDismissed()) {
|
if (installable && userEngaged && !pwaInstallManager.isDismissed()) {
|
||||||
setTimeout(() => {
|
setTimeout(() => { showPrompt = true; }, 2000);
|
||||||
showPrompt = true;
|
|
||||||
}, 2000);
|
|
||||||
} else if (!installable && userEngaged && !pwaInstallManager.isStandalone() && !pwaInstallManager.isDismissed()) {
|
} 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; };
|
||||||
const detectEngagement = () => {
|
|
||||||
userEngaged = true;
|
|
||||||
document.removeEventListener('scroll', detectEngagement);
|
|
||||||
document.removeEventListener('click', detectEngagement);
|
|
||||||
document.removeEventListener('keydown', detectEngagement);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('scroll', detectEngagement, { once: true });
|
document.addEventListener('scroll', detectEngagement, { once: true });
|
||||||
document.addEventListener('click', detectEngagement, { once: true });
|
document.addEventListener('click', detectEngagement, { once: true });
|
||||||
document.addEventListener('keydown', detectEngagement, { once: true });
|
document.addEventListener('keydown', detectEngagement, { once: true });
|
||||||
@@ -55,18 +42,12 @@
|
|||||||
|
|
||||||
async function handleInstall() {
|
async function handleInstall() {
|
||||||
installing = true;
|
installing = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await pwaInstallManager.showInstallPrompt();
|
const result = await pwaInstallManager.showInstallPrompt();
|
||||||
|
if (result === 'accepted') { showPrompt = false; showFallback = false; }
|
||||||
if (result === 'accepted') {
|
else if (result === 'dismissed') handleDismiss();
|
||||||
showPrompt = false;
|
} catch (e) {
|
||||||
showFallback = false;
|
console.error('Install failed:', e);
|
||||||
} else if (result === 'dismissed') {
|
|
||||||
handleDismiss();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Install failed:', error);
|
|
||||||
} finally {
|
} finally {
|
||||||
installing = false;
|
installing = false;
|
||||||
}
|
}
|
||||||
@@ -79,171 +60,201 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Main Install Prompt (for browsers with beforeinstallprompt support) -->
|
<!-- InstallSheet bottom sheet -->
|
||||||
{#if showPrompt && canInstall}
|
{#if showPrompt && canInstall}
|
||||||
<div class="fixed bottom-0 left-0 right-0 z-50 transform transition-transform duration-300 ease-out animate-slide-up">
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div class="bg-gradient-to-r from-blue-600 to-purple-600 text-white shadow-2xl">
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="px-4 py-4 sm:px-6">
|
<div class="overlay ic-fade" onclick={handleDismiss}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="sheet ic-slide-up" onclick={(e) => e.stopPropagation()}>
|
||||||
<div class="flex items-center space-x-4">
|
<!-- Handle -->
|
||||||
<!-- App Icon -->
|
<div class="handle-row"><div class="handle"></div></div>
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<div class="w-12 h-12 bg-white rounded-xl flex items-center justify-center shadow-lg">
|
<div class="inner">
|
||||||
<svg class="w-8 h-8 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
<!-- App identity -->
|
||||||
<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" />
|
<div class="app-row">
|
||||||
</svg>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Feature grid -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="feature-grid">
|
||||||
<h3 class="text-lg font-semibold text-white">Install InstaRecipe</h3>
|
{#each [
|
||||||
<p class="text-blue-100 text-sm">
|
{ Icon: Share, color: 'var(--purple)', t: 'Share-sheet target', d: 'Send links from Instagram in one tap.' },
|
||||||
Get faster access and offline support. Works like a native app!
|
{ Icon: Bell, color: 'var(--pink)', t: 'Push when ready', d: 'Buzz on save, retry, or fail.' },
|
||||||
</p>
|
{ 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>
|
</div>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Install button -->
|
||||||
<div class="flex items-center space-x-2 ml-4">
|
<button class="ic-btn-reset install-btn" onclick={handleInstall} disabled={installing}>
|
||||||
<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}
|
{#if installing}
|
||||||
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
Installing…
|
||||||
<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}
|
{:else}
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<Download size={18} color="#fff" /> Install InstaChef
|
||||||
<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}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="ic-btn-reset later-btn" onclick={handleDismiss}>Maybe later</button>
|
||||||
<button
|
|
||||||
onclick={handleDismiss}
|
|
||||||
class="text-blue-100 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
|
|
||||||
title="Dismiss"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Features List -->
|
|
||||||
<div class="mt-3 flex flex-wrap gap-3 text-xs text-blue-100">
|
|
||||||
<div class="flex items-center space-x-1">
|
|
||||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Offline access</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-1">
|
|
||||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Push notifications</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-1">
|
|
||||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Faster loading</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-1">
|
|
||||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Home screen access</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Fallback Instructions (for browsers without beforeinstallprompt) -->
|
<!-- Fallback hint (iOS Safari etc.) -->
|
||||||
{#if showFallback && !canInstall && !pwaInstallManager.isStandalone()}
|
{#if showFallback && !canInstall && browser && !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="fallback ic-slide-up">
|
||||||
<div class="flex items-start space-x-3">
|
<div class="fallback-inner">
|
||||||
<div class="flex-shrink-0">
|
<div class="fallback-text">
|
||||||
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
<div class="fallback-title">Install InstaChef</div>
|
||||||
<svg class="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
<div class="fallback-sub">{pwaInstallManager.getInstallInstructions()}</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
<button class="ic-btn-reset close-fallback" onclick={handleDismiss} aria-label="Dismiss">✕</button>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@keyframes slide-up {
|
.overlay {
|
||||||
from {
|
position: fixed;
|
||||||
transform: translateY(100%);
|
inset: 0;
|
||||||
opacity: 0;
|
z-index: 70;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
background: rgba(0, 0, 0, 0.42);
|
||||||
}
|
}
|
||||||
to {
|
.sheet {
|
||||||
transform: translateY(0);
|
width: 100%;
|
||||||
opacity: 1;
|
border-radius: 32px 32px 0 0;
|
||||||
|
background: var(--bg);
|
||||||
|
box-shadow: 0 -20px 60px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
.handle-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-top: 14px;
|
||||||
}
|
}
|
||||||
|
.handle {
|
||||||
@keyframes fade-in {
|
width: 44px;
|
||||||
from {
|
height: 5px;
|
||||||
opacity: 0;
|
border-radius: 99px;
|
||||||
transform: translateY(10px) scale(0.95);
|
background: var(--border-strong);
|
||||||
}
|
}
|
||||||
to {
|
.inner {
|
||||||
opacity: 1;
|
padding: 0 22px 30px;
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
}
|
}
|
||||||
|
.app-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
.app-icon {
|
||||||
.animate-slide-up {
|
border-radius: 16px;
|
||||||
animation: slide-up 0.3s ease-out;
|
box-shadow: 0 8px 22px rgba(225, 48, 108, 0.35);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.app-name {
|
||||||
.animate-fade-in {
|
font-size: 22px;
|
||||||
animation: fade-in 0.3s ease-out;
|
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>
|
</style>
|
||||||
@@ -12,10 +12,6 @@
|
|||||||
|
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
// Test notification state
|
|
||||||
let testLoading = $state<boolean>(false);
|
|
||||||
let testMessage = $state<string | null>(null);
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Subscribe to state changes
|
// Subscribe to state changes
|
||||||
unsubscribe = pushNotificationManager.onStateChange((newState) => {
|
unsubscribe = pushNotificationManager.onStateChange((newState) => {
|
||||||
@@ -54,35 +50,6 @@
|
|||||||
function canToggle(): boolean {
|
function canToggle(): boolean {
|
||||||
return viewModel.supported && viewModel.permission !== 'denied' && !viewModel.loading;
|
return viewModel.supported && viewModel.permission !== 'denied' && !viewModel.loading;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendTestNotification(type: 'success' | 'error' | 'progress') {
|
|
||||||
testLoading = true;
|
|
||||||
testMessage = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/notifications/test', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ type })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to send test notification');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
testMessage = `✓ Test ${type} notification sent to ${result.subscriberCount} subscriber(s)`;
|
|
||||||
} catch (error) {
|
|
||||||
testMessage = `✗ Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
|
||||||
} finally {
|
|
||||||
testLoading = false;
|
|
||||||
|
|
||||||
// Auto-dismiss message after 3 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
testMessage = null;
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bg-white border rounded-lg shadow-sm p-6">
|
<div class="bg-white border rounded-lg shadow-sm p-6">
|
||||||
@@ -212,54 +179,4 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Test Notification Buttons (only shown when subscribed) -->
|
|
||||||
{#if viewModel.subscribed}
|
|
||||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
|
||||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Test Notifications</h4>
|
|
||||||
<p class="text-sm text-gray-600 mb-4">
|
|
||||||
Send a test notification to verify your subscription is working correctly.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
onclick={() => sendTestNotification('success')}
|
|
||||||
disabled={testLoading || viewModel.loading}
|
|
||||||
class="px-3 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
{testLoading ? 'Sending...' : 'Test Success'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onclick={() => sendTestNotification('error')}
|
|
||||||
disabled={testLoading || viewModel.loading}
|
|
||||||
class="px-3 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
{testLoading ? 'Sending...' : 'Test Error'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onclick={() => sendTestNotification('progress')}
|
|
||||||
disabled={testLoading || viewModel.loading}
|
|
||||||
class="px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
{testLoading ? 'Sending...' : 'Test Progress'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Test Message -->
|
|
||||||
{#if testMessage}
|
|
||||||
<div class="mt-4 p-3 rounded-lg {testMessage.startsWith('✓') ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}">
|
|
||||||
<div class="flex items-start space-x-2">
|
|
||||||
<svg class="w-4 h-4 flex-shrink-0 mt-0.5 {testMessage.startsWith('✓') ? 'text-green-400' : 'text-red-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={testMessage.startsWith('✓') ? "M5 13l4 4L19 7" : "M6 18L18 6M6 6l12 12"}></path>
|
|
||||||
</svg>
|
|
||||||
<div class="text-sm {testMessage.startsWith('✓') ? 'text-green-800' : 'text-red-800'}">
|
|
||||||
{testMessage}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
import { page } from 'vitest/browser';
|
|
||||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render } from 'vitest-browser-svelte';
|
|
||||||
import NotificationSettings from './NotificationSettings.svelte';
|
|
||||||
import { pushNotificationManager } from '$lib/client/PushNotificationManager';
|
|
||||||
|
|
||||||
// Mock the pushNotificationManager
|
|
||||||
vi.mock('$lib/client/PushNotificationManager', () => ({
|
|
||||||
pushNotificationManager: {
|
|
||||||
onStateChange: vi.fn(),
|
|
||||||
toggleSubscription: vi.fn()
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('NotificationSettings test buttons', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
// Mock fetch using vi.stubGlobal for browser environment
|
|
||||||
vi.stubGlobal('fetch', vi.fn());
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should not show test buttons when not subscribed', async () => {
|
|
||||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
|
||||||
callback({
|
|
||||||
supported: true,
|
|
||||||
permission: 'granted',
|
|
||||||
subscribed: false,
|
|
||||||
loading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
});
|
|
||||||
|
|
||||||
render(NotificationSettings);
|
|
||||||
|
|
||||||
// Test Notifications section should not be visible
|
|
||||||
const testSection = page.getByText('Test Notifications');
|
|
||||||
await expect.element(testSection).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show test buttons when subscribed', async () => {
|
|
||||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
|
||||||
callback({
|
|
||||||
supported: true,
|
|
||||||
permission: 'granted',
|
|
||||||
subscribed: true,
|
|
||||||
loading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
});
|
|
||||||
|
|
||||||
render(NotificationSettings);
|
|
||||||
|
|
||||||
await expect.element(page.getByText('Test Success')).toBeInTheDocument();
|
|
||||||
await expect.element(page.getByText('Test Error')).toBeInTheDocument();
|
|
||||||
await expect.element(page.getByText('Test Progress')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should send test success notification on button click', async () => {
|
|
||||||
vi.mocked(fetch).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ success: true, subscriberCount: 1 })
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
|
||||||
callback({
|
|
||||||
supported: true,
|
|
||||||
permission: 'granted',
|
|
||||||
subscribed: true,
|
|
||||||
loading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
});
|
|
||||||
|
|
||||||
render(NotificationSettings);
|
|
||||||
|
|
||||||
const button = page.getByText('Test Success');
|
|
||||||
await button.click();
|
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith('/api/notifications/test', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ type: 'success' })
|
|
||||||
});
|
|
||||||
|
|
||||||
const successMessage = page.getByText(/✓ Test success notification sent to 1 subscriber/i);
|
|
||||||
await expect.element(successMessage).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should send test error notification on button click', async () => {
|
|
||||||
vi.mocked(fetch).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ success: true, subscriberCount: 2 })
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
|
||||||
callback({
|
|
||||||
supported: true,
|
|
||||||
permission: 'granted',
|
|
||||||
subscribed: true,
|
|
||||||
loading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
});
|
|
||||||
|
|
||||||
render(NotificationSettings);
|
|
||||||
|
|
||||||
const button = page.getByText('Test Error');
|
|
||||||
await button.click();
|
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith('/api/notifications/test', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ type: 'error' })
|
|
||||||
});
|
|
||||||
|
|
||||||
const successMessage = page.getByText(/✓ Test error notification sent/i);
|
|
||||||
await expect.element(successMessage).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should send test progress notification on button click', async () => {
|
|
||||||
vi.mocked(fetch).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ success: true, subscriberCount: 1 })
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
|
||||||
callback({
|
|
||||||
supported: true,
|
|
||||||
permission: 'granted',
|
|
||||||
subscribed: true,
|
|
||||||
loading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
});
|
|
||||||
|
|
||||||
render(NotificationSettings);
|
|
||||||
|
|
||||||
const button = page.getByText('Test Progress');
|
|
||||||
await button.click();
|
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith('/api/notifications/test', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ type: 'progress' })
|
|
||||||
});
|
|
||||||
|
|
||||||
const successMessage = page.getByText(/✓ Test progress notification sent/i);
|
|
||||||
await expect.element(successMessage).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should display error message on failed request', async () => {
|
|
||||||
vi.mocked(fetch).mockResolvedValue({
|
|
||||||
ok: false,
|
|
||||||
status: 500
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
|
||||||
callback({
|
|
||||||
supported: true,
|
|
||||||
permission: 'granted',
|
|
||||||
subscribed: true,
|
|
||||||
loading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
});
|
|
||||||
|
|
||||||
render(NotificationSettings);
|
|
||||||
|
|
||||||
const button = page.getByText('Test Success');
|
|
||||||
await button.click();
|
|
||||||
|
|
||||||
const errorMessage = page.getByText(/✗ Error:/i);
|
|
||||||
await expect.element(errorMessage).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should auto-dismiss message after 3 seconds', async () => {
|
|
||||||
vi.mocked(fetch).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ success: true, subscriberCount: 1 })
|
|
||||||
} as Response);
|
|
||||||
|
|
||||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
|
||||||
callback({
|
|
||||||
supported: true,
|
|
||||||
permission: 'granted',
|
|
||||||
subscribed: true,
|
|
||||||
loading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
});
|
|
||||||
|
|
||||||
render(NotificationSettings);
|
|
||||||
|
|
||||||
const button = page.getByText('Test Success');
|
|
||||||
await button.click();
|
|
||||||
|
|
||||||
// Message should appear
|
|
||||||
const successMessage = page.getByText(/✓ Test success notification sent to 1 subscriber/i);
|
|
||||||
await expect.element(successMessage).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should disable buttons during loading', async () => {
|
|
||||||
// Create a promise that we can control
|
|
||||||
let resolvePromise: ((value: any) => void) | undefined;
|
|
||||||
const fetchPromise = new Promise((resolve) => {
|
|
||||||
resolvePromise = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mocked(fetch).mockReturnValue(fetchPromise as any);
|
|
||||||
|
|
||||||
vi.mocked(pushNotificationManager.onStateChange).mockImplementation((callback) => {
|
|
||||||
callback({
|
|
||||||
supported: true,
|
|
||||||
permission: 'granted',
|
|
||||||
subscribed: true,
|
|
||||||
loading: false,
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
return () => {};
|
|
||||||
});
|
|
||||||
|
|
||||||
render(NotificationSettings);
|
|
||||||
|
|
||||||
const successButton = page.getByRole('button', { name: 'Test Success' });
|
|
||||||
|
|
||||||
// Click a button to start loading
|
|
||||||
await successButton.click();
|
|
||||||
|
|
||||||
// Button should show "Sending..." text
|
|
||||||
const sendingButton = page.getByRole('button', { name: 'Sending...' }).first();
|
|
||||||
await expect.element(sendingButton).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Cleanup - resolve the promise
|
|
||||||
resolvePromise?.({
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ success: true, subscriberCount: 1 })
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
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';
|
import Page from './+page.svelte';
|
||||||
|
|
||||||
describe('/+page.svelte', () => {
|
describe('/+page.svelte', () => {
|
||||||
it('should render h1', async () => {
|
it('should render the InstaChef app shell', async () => {
|
||||||
render(Page);
|
render(Page);
|
||||||
|
|
||||||
const heading = page.getByRole('heading', { level: 1 });
|
// The app logo text is the primary visible brand element in the TopBar
|
||||||
await expect.element(heading).toBeInTheDocument();
|
const logo = page.getByText('InstaChef');
|
||||||
|
await expect.element(logo).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,13 +2,11 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import UrlInputSection from './components/UrlInputSection.svelte';
|
import AddUrlScreen from '../components/AddUrlScreen.svelte';
|
||||||
|
|
||||||
let status = $state('idle');
|
let status = $state<'idle' | 'enqueuing' | 'success' | 'error'>('idle');
|
||||||
let logs = $state<string[]>([]);
|
|
||||||
|
|
||||||
// URL param parsing for Share Target
|
// 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 sharedText = $derived($page.url.searchParams.get('text') || '');
|
||||||
let sharedUrl = $derived($page.url.searchParams.get('url') || '');
|
let sharedUrl = $derived($page.url.searchParams.get('url') || '');
|
||||||
|
|
||||||
@@ -17,32 +15,27 @@
|
|||||||
return match ? match[0] : null;
|
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
|
// Track if we've already auto-processed to prevent duplicate processing
|
||||||
let hasAutoProcessed = $state(false);
|
let hasAutoProcessed = $state(false);
|
||||||
|
|
||||||
// Auto-process URL if provided via share target
|
// Auto-process URL if provided via share target
|
||||||
// Use onMount instead of $effect for side effects (SvelteKit best practice)
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (targetUrl && status === 'idle' && !hasAutoProcessed) {
|
if (targetUrl && status === 'idle' && !hasAutoProcessed) {
|
||||||
hasAutoProcessed = true;
|
hasAutoProcessed = true;
|
||||||
process();
|
process(targetUrl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function process(url?: string) {
|
async function process(url: string) {
|
||||||
const urlToProcess = url || targetUrl;
|
if (!url) return;
|
||||||
if (!urlToProcess) return;
|
|
||||||
|
|
||||||
status = 'enqueuing';
|
status = 'enqueuing';
|
||||||
logs = [...logs, '🚀 Enqueuing extraction from: ' + urlToProcess];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Enqueue URL for background processing
|
|
||||||
const response = await fetch('/api/queue', {
|
const response = await fetch('/api/queue', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ url: urlToProcess }),
|
body: JSON.stringify({ url }),
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,88 +45,100 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const queueItem = await response.json();
|
const queueItem = await response.json();
|
||||||
logs = [...logs, `✅ URL enqueued successfully with ID: ${queueItem.id}`];
|
status = 'success';
|
||||||
logs = [...logs, '🔄 Redirecting to queue dashboard...'];
|
|
||||||
|
|
||||||
// Small delay to show the success message
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Redirect to homepage (queue dashboard) with the queue item ID highlighted
|
|
||||||
goto(`/?highlight=${queueItem.id}`);
|
goto(`/?highlight=${queueItem.id}`);
|
||||||
}, 1500);
|
}, 800);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status = 'error';
|
status = 'error';
|
||||||
const errorMessage = e instanceof Error ? e.message : 'Unknown error';
|
console.error('Failed to enqueue:', e);
|
||||||
logs = [...logs, `❌ Error: ${errorMessage}`];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function retry() {
|
|
||||||
status = 'idle';
|
|
||||||
logs = [...logs, 'Retrying...'];
|
|
||||||
process();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Share to InstaRecipe</title>
|
<title>Add Recipe — InstaChef</title>
|
||||||
<meta name="description" content="Share Instagram recipes for extraction" />
|
<meta name="description" content="Share Instagram recipes to InstaChef" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="mx-auto p-6 max-w-4xl">
|
{#if status === 'enqueuing' || status === 'success'}
|
||||||
<div class="mb-8">
|
<!-- Transitional feedback while enqueuing -->
|
||||||
<h1 class="text-3xl font-bold mb-2 text-center">Share to InstaRecipe</h1>
|
<div class="enqueue-screen">
|
||||||
<p class="text-gray-600 text-center">
|
<div class="enqueue-card">
|
||||||
{#if targetUrl}
|
|
||||||
Processing your shared recipe...
|
|
||||||
{:else}
|
|
||||||
Paste an Instagram recipe URL to extract it
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if !targetUrl}
|
|
||||||
<UrlInputSection onProcess={process} />
|
|
||||||
{:else}
|
|
||||||
<!-- Status indicator for shared URLs -->
|
|
||||||
<div class="max-w-2xl mx-auto mb-8">
|
|
||||||
<div class="bg-white p-6 rounded-lg shadow-md border">
|
|
||||||
<h3 class="font-semibold mb-2">Processing URL:</h3>
|
|
||||||
<p class="text-sm text-gray-600 mb-4 break-all">{targetUrl}</p>
|
|
||||||
|
|
||||||
{#if status === 'enqueuing'}
|
{#if status === 'enqueuing'}
|
||||||
<div class="flex items-center space-x-2">
|
<div class="spinner"></div>
|
||||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
<div class="enqueue-title">Adding to queue…</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}
|
{:else}
|
||||||
<div class="text-green-600">✅ Ready to process</div>
|
<div class="enqueue-check">✓</div>
|
||||||
|
<div class="enqueue-title">Added! Redirecting…</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="enqueue-url">{targetUrl}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<AddUrlScreen
|
||||||
|
initialUrl={targetUrl}
|
||||||
|
onBack={() => goto('/')}
|
||||||
|
onSubmit={process}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Log viewer for feedback -->
|
<style>
|
||||||
{#if logs.length > 0}
|
.enqueue-screen {
|
||||||
<div class="max-w-2xl mx-auto mt-8">
|
min-height: 100vh;
|
||||||
<div class="bg-gray-50 p-4 rounded-lg border">
|
min-height: 100dvh;
|
||||||
<h3 class="font-semibold mb-2">Process Log:</h3>
|
background: var(--bg);
|
||||||
<div class="space-y-1 text-sm">
|
display: flex;
|
||||||
{#each logs as log}
|
align-items: center;
|
||||||
<div class="text-gray-700">{log}</div>
|
justify-content: center;
|
||||||
{/each}
|
padding: 24px;
|
||||||
</div>
|
}
|
||||||
</div>
|
.enqueue-card {
|
||||||
</div>
|
background: var(--surface);
|
||||||
{/if}
|
border: 1px solid var(--border);
|
||||||
</div>
|
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>
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
type HealthStatus = 'checking' | 'ok' | 'warming' | 'error';
|
||||||
|
|
||||||
interface HealthState {
|
interface HealthState {
|
||||||
status: 'checking' | 'healthy' | 'unhealthy' | 'error';
|
status: HealthStatus;
|
||||||
message: string;
|
message: string;
|
||||||
|
configuredModel: string;
|
||||||
lastChecked: Date | null;
|
lastChecked: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,6 +17,7 @@
|
|||||||
let health = $state<HealthState>({
|
let health = $state<HealthState>({
|
||||||
status: 'checking',
|
status: 'checking',
|
||||||
message: '',
|
message: '',
|
||||||
|
configuredModel: '',
|
||||||
lastChecked: null
|
lastChecked: null
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -21,24 +25,26 @@
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/llm-health');
|
const res = await fetch('/api/llm-health');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
const status: HealthStatus =
|
||||||
|
data.status === 'ok' ? 'ok' : data.status === 'warming' ? 'warming' : 'error';
|
||||||
health = {
|
health = {
|
||||||
status: data.status === 'healthy' ? 'healthy' : 'unhealthy',
|
status,
|
||||||
message: data.message,
|
message: data.message ?? '',
|
||||||
|
configuredModel: data.configuredModel ?? '',
|
||||||
lastChecked: new Date()
|
lastChecked: new Date()
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
health = {
|
health = {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: e instanceof Error ? e.message : 'Network error',
|
message: e instanceof Error ? e.message : 'Network error',
|
||||||
|
configuredModel: '',
|
||||||
lastChecked: new Date()
|
lastChecked: new Date()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use onMount instead of $effect for timer-based side effects
|
|
||||||
// onMount only runs in browser, no SSR guard needed
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
checkHealth(); // Initial check
|
checkHealth();
|
||||||
const interval = setInterval(checkHealth, pollInterval);
|
const interval = setInterval(checkHealth, pollInterval);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
});
|
});
|
||||||
@@ -48,12 +54,12 @@
|
|||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
{#if health.status === 'checking'}
|
{#if health.status === 'checking'}
|
||||||
🟡 <span>Checking LLM...</span>
|
🟡 <span>Checking LLM...</span>
|
||||||
{:else if health.status === 'healthy'}
|
{:else if health.status === 'ok'}
|
||||||
🟢 <span class="text-green-600">LLM Ready</span>
|
🟢 <span class="text-green-600">LLM Ready</span>
|
||||||
{:else if health.status === 'unhealthy'}
|
{:else if health.status === 'warming'}
|
||||||
🔴 <span class="text-red-600">LLM Unavailable</span>
|
🟡 <span class="text-yellow-600">LLM Cold ({health.configuredModel})</span>
|
||||||
{:else}
|
{:else}
|
||||||
🔴 <span class="text-red-600">LLM Error</span>
|
🔴 <span class="text-red-600">LLM Unavailable</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500" title={health.message}>
|
<div class="text-xs text-gray-500" title={health.message}>
|
||||||
|
|||||||
131
src/routes/test/+page.svelte
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { pushNotificationManager, type NotificationState } from '$lib/client/PushNotificationManager';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let viewModel = $state<NotificationState>({
|
||||||
|
supported: false,
|
||||||
|
permission: 'default',
|
||||||
|
subscribed: false,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
let testLoading = $state<boolean>(false);
|
||||||
|
let testMessage = $state<string | null>(null);
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
unsubscribe = pushNotificationManager.onStateChange((newState) => {
|
||||||
|
viewModel = newState;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe?.();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function sendTestNotification(type: 'success' | 'error' | 'progress') {
|
||||||
|
testLoading = true;
|
||||||
|
testMessage = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/notifications/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to send test notification');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
testMessage = `✓ Test ${type} notification sent to ${result.subscriberCount} subscriber(s)`;
|
||||||
|
} catch (error) {
|
||||||
|
testMessage = `✗ Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
|
} finally {
|
||||||
|
testLoading = false;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
testMessage = null;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>InstaRecipe - Notification Tests</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto p-6 max-w-4xl">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold mb-2">Notification Testing</h1>
|
||||||
|
<p class="text-gray-600">Debug endpoint for testing push notifications</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !viewModel.subscribed}
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||||
|
<div class="flex items-start space-x-2">
|
||||||
|
<svg class="w-5 h-5 text-yellow-400 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 15.5c-.77.833.192 2.5 1.732 2.5z"></path>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-yellow-800">Not Subscribed</div>
|
||||||
|
<div class="text-sm text-yellow-700">
|
||||||
|
You must enable push notifications on the <a href="/" class="underline">homepage</a> before testing.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="bg-white border rounded-lg shadow-sm p-6">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900 mb-4">Test Notifications</h2>
|
||||||
|
<p class="text-sm text-gray-600 mb-6">
|
||||||
|
Send test notifications to verify your subscription is working correctly.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3 mb-6">
|
||||||
|
<button
|
||||||
|
onclick={() => sendTestNotification('success')}
|
||||||
|
disabled={testLoading || !viewModel.subscribed}
|
||||||
|
class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{testLoading ? 'Sending...' : 'Test Success'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={() => sendTestNotification('error')}
|
||||||
|
disabled={testLoading || !viewModel.subscribed}
|
||||||
|
class="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{testLoading ? 'Sending...' : 'Test Error'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={() => sendTestNotification('progress')}
|
||||||
|
disabled={testLoading || !viewModel.subscribed}
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{testLoading ? 'Sending...' : 'Test Progress'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if testMessage}
|
||||||
|
<div class="p-3 rounded-lg {testMessage.startsWith('✓') ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}">
|
||||||
|
<div class="flex items-start space-x-2">
|
||||||
|
<svg class="w-4 h-4 flex-shrink-0 mt-0.5 {testMessage.startsWith('✓') ? 'text-green-400' : 'text-red-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={testMessage.startsWith('✓') ? "M5 13l4 4L19 7" : "M6 18L18 6M6 6l12 12"}></path>
|
||||||
|
</svg>
|
||||||
|
<div class="text-sm {testMessage.startsWith('✓') ? 'text-green-800' : 'text-red-800'}">
|
||||||
|
{testMessage}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<a href="/" class="text-sm text-blue-600 hover:text-blue-700">← Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -183,22 +183,36 @@ self.addEventListener('notificationclick', (event) => {
|
|||||||
|
|
||||||
let url = '/';
|
let url = '/';
|
||||||
|
|
||||||
if (action === 'view' && data?.itemId) {
|
// Handle 'view' action - redirect to Tandoor if available
|
||||||
|
if (action === 'view') {
|
||||||
|
if (data?.tandoorUrl) {
|
||||||
|
// Success notification with Tandoor URL - redirect to recipe
|
||||||
|
url = data.tandoorUrl;
|
||||||
|
} else if (data?.itemId) {
|
||||||
|
// Fallback to dashboard highlight
|
||||||
url = `/?highlight=${data.itemId}`;
|
url = `/?highlight=${data.itemId}`;
|
||||||
|
}
|
||||||
} else if (action === 'retry' && data?.itemId) {
|
} else if (action === 'retry' && data?.itemId) {
|
||||||
// Navigate to dashboard and trigger retry via postMessage
|
// Navigate to dashboard and trigger retry
|
||||||
url = `/?highlight=${data.itemId}&action=retry`;
|
url = `/?highlight=${data.itemId}&action=retry`;
|
||||||
} else if (data?.itemId) {
|
} else if (data?.itemId) {
|
||||||
|
// Default: highlight item in dashboard
|
||||||
url = `/?highlight=${data.itemId}`;
|
url = `/?highlight=${data.itemId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientsList) => {
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientsList) => {
|
||||||
// Check if there's already a window/tab open
|
// For external URLs (Tandoor), always open new window
|
||||||
|
if (url.startsWith('http') && !url.includes(self.location.origin)) {
|
||||||
|
if (clients.openWindow) {
|
||||||
|
return clients.openWindow(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For internal URLs, check for existing window
|
||||||
for (const client of clientsList) {
|
for (const client of clientsList) {
|
||||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||||
return client.focus().then(() => {
|
return client.focus().then(() => {
|
||||||
// Send message to the client about the action
|
|
||||||
return client.postMessage({
|
return client.postMessage({
|
||||||
type: 'notification-action',
|
type: 'notification-action',
|
||||||
action: action,
|
action: action,
|
||||||
@@ -208,7 +222,7 @@ self.addEventListener('notificationclick', (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no window is open, open a new one
|
// No window open, open new one
|
||||||
if (clients.openWindow) {
|
if (clients.openWindow) {
|
||||||
return clients.openWindow(url);
|
return clients.openWindow(url);
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/tests/instagram-extractor.integration.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* E2E integration test for the yt-dlp Instagram extractor.
|
||||||
|
*
|
||||||
|
* Makes real network calls (yt-dlp + Instagram CDN). Requires:
|
||||||
|
* - yt-dlp installed on PATH
|
||||||
|
* - Network access to instagram.com
|
||||||
|
* - EXTRACTOR_E2E=1 env var (safety guard to avoid running in normal test runs)
|
||||||
|
*
|
||||||
|
* Run with:
|
||||||
|
* EXTRACTOR_E2E=1 npm test -- src/tests/instagram-extractor.e2e.spec.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { extractTextAndThumbnail } from '$lib/server/instagram-extractor';
|
||||||
|
|
||||||
|
const E2E = !!process.env.EXTRACTOR_E2E;
|
||||||
|
|
||||||
|
describe.skipIf(!E2E)('instagram-extractor E2E (requires yt-dlp + network)', () => {
|
||||||
|
// Public reels that have previously been in the app queue
|
||||||
|
const TEST_REELS = [
|
||||||
|
{
|
||||||
|
url: 'https://www.instagram.com/reel/DX4XEDZt3qT/',
|
||||||
|
expectKeyword: 'pizza'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://www.instagram.com/reel/DUtHm2EiD26/',
|
||||||
|
expectKeyword: 'noodles'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { url, expectKeyword } of TEST_REELS) {
|
||||||
|
it(`extracts caption from ${url}`, async () => {
|
||||||
|
const events: { type: string; message: string }[] = [];
|
||||||
|
const result = await extractTextAndThumbnail(url, (e) =>
|
||||||
|
events.push(e as { type: string; message: string })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.bodyText.length).toBeGreaterThan(20);
|
||||||
|
expect(result.bodyText.toLowerCase()).toContain(expectKeyword);
|
||||||
|
|
||||||
|
if (result.thumbnail !== null) {
|
||||||
|
expect(result.thumbnail).toMatch(/^data:image\//);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(events.some((e) => e.type === 'complete')).toBe(true);
|
||||||
|
expect(events.some((e) => e.type === 'status' && e.message.includes('yt-dlp'))).toBe(true);
|
||||||
|
}, 90_000);
|
||||||
|
}
|
||||||
|
});
|
||||||
171
src/tests/instagram-extractor.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock node:child_process before importing the SUT. The SUT uses
|
||||||
|
// promisify(execFile); without the Node-internal special handling, promisify
|
||||||
|
// would only forward the first callback arg. We sidestep that by returning a
|
||||||
|
// pre-promisified function tagged with util.promisify.custom that resolves
|
||||||
|
// to {stdout, stderr}.
|
||||||
|
import * as util from 'node:util';
|
||||||
|
const execFileMock = vi.fn();
|
||||||
|
vi.mock('node:child_process', () => {
|
||||||
|
const execFile: any = () => {
|
||||||
|
throw new Error('callback form not used in tests');
|
||||||
|
};
|
||||||
|
execFile[util.promisify.custom] = (cmd: string, args: string[], opts: any) =>
|
||||||
|
execFileMock(cmd, args, opts);
|
||||||
|
return { execFile };
|
||||||
|
});
|
||||||
|
|
||||||
|
const existsSyncMock = vi.fn();
|
||||||
|
vi.mock('node:fs', () => ({
|
||||||
|
existsSync: (p: string) => existsSyncMock(p)
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { extractTextAndThumbnail } from '../lib/server/instagram-extractor';
|
||||||
|
|
||||||
|
describe('instagram-extractor (yt-dlp backend)', () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
execFileMock.mockReset();
|
||||||
|
existsSyncMock.mockReset();
|
||||||
|
existsSyncMock.mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses yt-dlp JSON and returns bodyText + thumbnail data URI', async () => {
|
||||||
|
execFileMock.mockResolvedValue({
|
||||||
|
stdout: JSON.stringify({
|
||||||
|
description: 'Pasta carbonara: 200g spaghetti, 100g pancetta, 2 eggs.',
|
||||||
|
thumbnail: 'https://example.com/thumb.jpg'
|
||||||
|
}),
|
||||||
|
stderr: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
headers: { get: () => 'image/jpeg' },
|
||||||
|
arrayBuffer: () => Promise.resolve(new Uint8Array([1, 2, 3]).buffer)
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const result = await extractTextAndThumbnail('https://www.instagram.com/reel/abc123/');
|
||||||
|
|
||||||
|
expect(result.bodyText).toContain('carbonara');
|
||||||
|
expect(result.thumbnail).toMatch(/^data:image\/jpeg;base64,/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to first thumbnails entry when top-level thumbnail is absent', async () => {
|
||||||
|
execFileMock.mockResolvedValue({
|
||||||
|
stdout: JSON.stringify({
|
||||||
|
description: 'Recipe text',
|
||||||
|
thumbnails: [{ url: 'https://example.com/alt-thumb.jpg' }]
|
||||||
|
}),
|
||||||
|
stderr: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
headers: { get: () => 'image/png' },
|
||||||
|
arrayBuffer: () => Promise.resolve(new Uint8Array([4, 5, 6]).buffer)
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const result = await extractTextAndThumbnail('https://www.instagram.com/reel/abc/');
|
||||||
|
expect(result.thumbnail).toMatch(/^data:image\/png;base64,/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null thumbnail when fetch fails', async () => {
|
||||||
|
execFileMock.mockResolvedValue({
|
||||||
|
stdout: JSON.stringify({
|
||||||
|
description: 'Recipe text',
|
||||||
|
thumbnail: 'https://example.com/missing.jpg'
|
||||||
|
}),
|
||||||
|
stderr: ''
|
||||||
|
});
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
status: 404,
|
||||||
|
headers: { get: () => 'text/html' },
|
||||||
|
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0))
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const result = await extractTextAndThumbnail('https://www.instagram.com/reel/abc/');
|
||||||
|
expect(result.bodyText).toBe('Recipe text');
|
||||||
|
expect(result.thumbnail).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes --cookies flag when secrets/cookies.txt exists', async () => {
|
||||||
|
existsSyncMock.mockImplementation((p: string) => p.endsWith('cookies.txt'));
|
||||||
|
execFileMock.mockResolvedValue({
|
||||||
|
stdout: JSON.stringify({ description: 'x', thumbnail: null }),
|
||||||
|
stderr: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
await extractTextAndThumbnail('https://www.instagram.com/reel/abc/');
|
||||||
|
|
||||||
|
const [, args] = execFileMock.mock.calls[0];
|
||||||
|
expect(args).toContain('--cookies');
|
||||||
|
const idx = (args as string[]).indexOf('--cookies');
|
||||||
|
expect((args as string[])[idx + 1]).toMatch(/cookies\.txt$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits --cookies flag when no cookie file is present', async () => {
|
||||||
|
existsSyncMock.mockReturnValue(false);
|
||||||
|
execFileMock.mockResolvedValue({
|
||||||
|
stdout: JSON.stringify({ description: 'x', thumbnail: null }),
|
||||||
|
stderr: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
await extractTextAndThumbnail('https://www.instagram.com/reel/abc/');
|
||||||
|
|
||||||
|
const [, args] = execFileMock.mock.calls[0];
|
||||||
|
expect(args).not.toContain('--cookies');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws non-recoverable error on "Login required" stderr', async () => {
|
||||||
|
const err: any = new Error('yt-dlp failed');
|
||||||
|
err.stderr = 'ERROR: [Instagram] xyz: Login required to access this post.';
|
||||||
|
execFileMock.mockRejectedValue(err);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
extractTextAndThumbnail('https://www.instagram.com/reel/private/')
|
||||||
|
).rejects.toThrow(/authentication/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws clear error when yt-dlp binary is missing (ENOENT)', async () => {
|
||||||
|
const err: any = new Error('not found');
|
||||||
|
err.code = 'ENOENT';
|
||||||
|
execFileMock.mockRejectedValue(err);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
extractTextAndThumbnail('https://www.instagram.com/reel/abc/')
|
||||||
|
).rejects.toThrow(/yt-dlp is not installed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when description is empty', async () => {
|
||||||
|
execFileMock.mockResolvedValue({
|
||||||
|
stdout: JSON.stringify({ description: '', thumbnail: null }),
|
||||||
|
stderr: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
extractTextAndThumbnail('https://www.instagram.com/reel/empty/')
|
||||||
|
).rejects.toThrow(/no description/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits progress events through the callback', async () => {
|
||||||
|
execFileMock.mockResolvedValue({
|
||||||
|
stdout: JSON.stringify({ description: 'x', thumbnail: null }),
|
||||||
|
stderr: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const events: any[] = [];
|
||||||
|
await extractTextAndThumbnail('https://www.instagram.com/reel/abc/', (e) =>
|
||||||
|
events.push(e)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(events.some((e) => e.type === 'status' && e.message.includes('yt-dlp'))).toBe(true);
|
||||||
|
expect(events.some((e) => e.type === 'complete')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 AFTER mocking
|
||||||
import { checkLLMHealth, checkModelAvailability } from '$lib/server/llm';
|
import { checkLLMHealth, checkModelAvailability } from '$lib/server/llm';
|
||||||
|
|
||||||
|
|||||||
120
src/tests/notification-auto-subscribe.svelte.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import HomePage from '../routes/+page.svelte';
|
||||||
|
import { pushNotificationManager } from '$lib/client/PushNotificationManager';
|
||||||
|
|
||||||
|
// Mock $app/environment
|
||||||
|
vi.mock('$app/environment', () => ({
|
||||||
|
browser: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock fetch for queue API calls
|
||||||
|
globalThis.fetch = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ items: [], stats: { total: 0, pending: 0, in_progress: 0, completed: 0, error: 0 } })
|
||||||
|
} as Response)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock EventSource for SSE
|
||||||
|
globalThis.EventSource = vi.fn(() => ({
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
onerror: null,
|
||||||
|
onmessage: null,
|
||||||
|
onopen: null,
|
||||||
|
readyState: 0,
|
||||||
|
url: '',
|
||||||
|
withCredentials: false,
|
||||||
|
CONNECTING: 0,
|
||||||
|
OPEN: 1,
|
||||||
|
CLOSED: 2,
|
||||||
|
dispatchEvent: vi.fn()
|
||||||
|
})) as any;
|
||||||
|
|
||||||
|
vi.mock('$lib/client/PushNotificationManager', () => ({
|
||||||
|
pushNotificationManager: {
|
||||||
|
getState: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
onStateChange: vi.fn(() => () => {})
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Automatic Notification Subscription', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(pushNotificationManager.getState).mockReturnValue({
|
||||||
|
supported: true,
|
||||||
|
permission: 'default',
|
||||||
|
subscribed: false,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-subscribe on first user click when supported and not subscribed', async () => {
|
||||||
|
render(HomePage);
|
||||||
|
|
||||||
|
// Wait for component to mount and set up event listeners
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Simulate user click anywhere on page
|
||||||
|
document.body.click();
|
||||||
|
|
||||||
|
// Wait for event handlers to process
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(pushNotificationManager.subscribe).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not auto-subscribe if permission denied', async () => {
|
||||||
|
vi.mocked(pushNotificationManager.getState).mockReturnValue({
|
||||||
|
supported: true,
|
||||||
|
permission: 'denied',
|
||||||
|
subscribed: false,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
render(HomePage);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
document.body.click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(pushNotificationManager.subscribe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not auto-subscribe if already subscribed', async () => {
|
||||||
|
vi.mocked(pushNotificationManager.getState).mockReturnValue({
|
||||||
|
supported: true,
|
||||||
|
permission: 'granted',
|
||||||
|
subscribed: true,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
render(HomePage);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
document.body.click();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(pushNotificationManager.subscribe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only attempt subscription once', async () => {
|
||||||
|
render(HomePage);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
document.body.click();
|
||||||
|
document.body.click();
|
||||||
|
document.body.click();
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(pushNotificationManager.subscribe).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,13 +37,14 @@ describe('Queue API Endpoints', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
expect(data.id).toBeTruthy();
|
expect(data.duplicate).toBe(false);
|
||||||
expect(data.url).toBe('https://instagram.com/p/ABC123');
|
expect(data.item.id).toBeTruthy();
|
||||||
expect(data.status).toBe('pending');
|
expect(data.item.url).toBe('https://instagram.com/p/ABC123');
|
||||||
expect(data.enqueuedAt).toBeTruthy();
|
expect(data.item.status).toBe('pending');
|
||||||
|
expect(data.item.enqueuedAt).toBeTruthy();
|
||||||
|
|
||||||
// Verify item exists in queue
|
// Verify item exists in queue
|
||||||
const item = queueManager.get(data.id);
|
const item = queueManager.get(data.item.id);
|
||||||
expect(item).toBeTruthy();
|
expect(item).toBeTruthy();
|
||||||
expect(item?.url).toBe('https://instagram.com/p/ABC123');
|
expect(item?.url).toBe('https://instagram.com/p/ABC123');
|
||||||
});
|
});
|
||||||
@@ -63,10 +64,11 @@ describe('Queue API Endpoints', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
expect(data.url).toBe('https://www.instagram.com/p/XYZ789');
|
expect(data.duplicate).toBe(false);
|
||||||
|
expect(data.item.url).toBe('https://www.instagram.com/p/XYZ789');
|
||||||
|
|
||||||
// Verify item exists in queue
|
// Verify item exists in queue
|
||||||
const item = queueManager.get(data.id);
|
const item = queueManager.get(data.item.id);
|
||||||
expect(item).toBeTruthy();
|
expect(item).toBeTruthy();
|
||||||
expect(item?.url).toBe('https://www.instagram.com/p/XYZ789');
|
expect(item?.url).toBe('https://www.instagram.com/p/XYZ789');
|
||||||
});
|
});
|
||||||
@@ -83,7 +85,8 @@ describe('Queue API Endpoints', () => {
|
|||||||
const response = await queuePOST({ request } as any);
|
const response = await queuePOST({ request } as any);
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
expect(data.url).toBe('https://instagram.com/reel/ABC123');
|
expect(data.duplicate).toBe(false);
|
||||||
|
expect(data.item.url).toBe('https://instagram.com/reel/ABC123');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept Instagram URLs with query parameters', async () => {
|
it('should accept Instagram URLs with query parameters', async () => {
|
||||||
@@ -98,7 +101,8 @@ describe('Queue API Endpoints', () => {
|
|||||||
const response = await queuePOST({ request } as any);
|
const response = await queuePOST({ request } as any);
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
expect(data.url).toBe(
|
expect(data.duplicate).toBe(false);
|
||||||
|
expect(data.item.url).toBe(
|
||||||
'https://www.instagram.com/reel/DSevV5CDcNm/?utm_source=ig_web_copy_link'
|
'https://www.instagram.com/reel/DSevV5CDcNm/?utm_source=ig_web_copy_link'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -234,6 +238,37 @@ describe('Queue API Endpoints', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /api/queue deduplication', () => {
|
||||||
|
it('should return duplicate flag when URL already exists', async () => {
|
||||||
|
const url = 'https://instagram.com/p/DUP123';
|
||||||
|
|
||||||
|
// First request
|
||||||
|
const request1 = new Request('http://localhost/api/queue', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url })
|
||||||
|
});
|
||||||
|
const response1 = await queuePOST({ request: request1 } as any);
|
||||||
|
const data1 = await response1.json();
|
||||||
|
|
||||||
|
expect(data1.duplicate).toBe(false);
|
||||||
|
|
||||||
|
// Second request (duplicate)
|
||||||
|
const request2 = new Request('http://localhost/api/queue', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url })
|
||||||
|
});
|
||||||
|
const response2 = await queuePOST({ request: request2 } as any);
|
||||||
|
const data2 = await response2.json();
|
||||||
|
|
||||||
|
expect(response2.status).toBe(200);
|
||||||
|
expect(data2.duplicate).toBe(true);
|
||||||
|
expect(data2.message).toContain('already in the queue');
|
||||||
|
expect(data2.item.id).toBe(data1.item.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('GET /api/queue', () => {
|
describe('GET /api/queue', () => {
|
||||||
it('should return empty list when no items', async () => {
|
it('should return empty list when no items', async () => {
|
||||||
const url = new URL('http://localhost/api/queue');
|
const url = new URL('http://localhost/api/queue');
|
||||||
|
|||||||
@@ -353,4 +353,28 @@ describe('QueueManager', () => {
|
|||||||
expect(callback3).toHaveBeenCalled();
|
expect(callback3).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('deduplication', () => {
|
||||||
|
it('should return existing item when enqueueing duplicate URL', () => {
|
||||||
|
const url = 'https://instagram.com/p/ABC123';
|
||||||
|
const firstItem = queueManager.enqueue(url);
|
||||||
|
const secondItem = queueManager.enqueue(url);
|
||||||
|
|
||||||
|
expect(secondItem.id).toBe(firstItem.id);
|
||||||
|
expect(queueManager.getAll()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find item by URL', () => {
|
||||||
|
const url = 'https://instagram.com/p/TEST123';
|
||||||
|
const item = queueManager.enqueue(url);
|
||||||
|
|
||||||
|
const found = queueManager.findByUrl(url);
|
||||||
|
expect(found?.id).toBe(item.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when URL not found', () => {
|
||||||
|
const found = queueManager.findByUrl('https://instagram.com/p/NOTFOUND');
|
||||||
|
expect(found).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ vi.mock('$lib/server/tandoor', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { queueManager } from '$lib/server/queue/QueueManager';
|
import { queueManager } from '$lib/server/queue/QueueManager';
|
||||||
import * as extraction from '$lib/server/extraction';
|
import * as instagramExtractor from '$lib/server/instagram-extractor';
|
||||||
import { queueProcessor } from '$lib/server/queue/QueueProcessor';
|
import { queueProcessor } from '$lib/server/queue/QueueProcessor';
|
||||||
|
|
||||||
describe('QueueProcessor logging', () => {
|
describe('QueueProcessor logging', () => {
|
||||||
@@ -50,8 +50,8 @@ describe('QueueProcessor logging', () => {
|
|||||||
(complexError as any).code = 'ERR_TEST';
|
(complexError as any).code = 'ERR_TEST';
|
||||||
(complexError as any).details = { phase: 'extraction', retries: 3 };
|
(complexError as any).details = { phase: 'extraction', retries: 3 };
|
||||||
|
|
||||||
// Mock extraction to fail BEFORE starting processor
|
// Mock extraction to fail BEFORE starting processor (default backend = ytdlp)
|
||||||
const extractSpy = vi.spyOn(extraction, 'extractTextAndThumbnail');
|
const extractSpy = vi.spyOn(instagramExtractor, 'extractTextAndThumbnail');
|
||||||
extractSpy.mockRejectedValueOnce(complexError);
|
extractSpy.mockRejectedValueOnce(complexError);
|
||||||
|
|
||||||
const item = queueManager.enqueue('https://instagram.com/p/TEST');
|
const item = queueManager.enqueue('https://instagram.com/p/TEST');
|
||||||
|
|||||||
@@ -35,13 +35,21 @@ vi.mock('$lib/server/queue/config', () => ({
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock external dependencies BEFORE importing QueueProcessor
|
// Mock external dependencies BEFORE importing QueueProcessor.
|
||||||
|
// QueueProcessor.extractionPhase picks between two extractor modules based on
|
||||||
|
// EXTRACTOR_BACKEND; mock both so behavior is identical regardless of default.
|
||||||
vi.mock('$lib/server/extraction', () => ({
|
vi.mock('$lib/server/extraction', () => ({
|
||||||
extractTextAndThumbnail: vi.fn().mockResolvedValue({
|
extractTextAndThumbnail: vi.fn().mockResolvedValue({
|
||||||
bodyText: 'Default recipe text',
|
bodyText: 'Default recipe text',
|
||||||
thumbnail: null
|
thumbnail: null
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
vi.mock('$lib/server/instagram-extractor', () => ({
|
||||||
|
extractTextAndThumbnail: vi.fn().mockResolvedValue({
|
||||||
|
bodyText: 'Default recipe text',
|
||||||
|
thumbnail: null
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/server/parser', () => ({
|
vi.mock('$lib/server/parser', () => ({
|
||||||
extractRecipe: vi.fn().mockResolvedValue({
|
extractRecipe: vi.fn().mockResolvedValue({
|
||||||
@@ -62,11 +70,16 @@ vi.mock('$lib/server/tandoor', () => ({
|
|||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { extractTextAndThumbnail } from '$lib/server/extraction';
|
import { extractTextAndThumbnail as extractFromExtraction } from '$lib/server/extraction';
|
||||||
|
import { extractTextAndThumbnail as extractFromYtDlp } from '$lib/server/instagram-extractor';
|
||||||
import { extractRecipe } from '$lib/server/parser';
|
import { extractRecipe } from '$lib/server/parser';
|
||||||
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
|
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
|
||||||
import * as configModule from '$lib/server/queue/config';
|
import * as configModule from '$lib/server/queue/config';
|
||||||
|
|
||||||
|
// Alias used by existing assertions; default backend is ytdlp so the new
|
||||||
|
// instagram-extractor mock is what the processor actually invokes.
|
||||||
|
const extractTextAndThumbnail = extractFromYtDlp;
|
||||||
|
|
||||||
// Import processor AFTER mocks - it will auto-start (imported for side effects)
|
// Import processor AFTER mocks - it will auto-start (imported for side effects)
|
||||||
import '$lib/server/queue/QueueProcessor';
|
import '$lib/server/queue/QueueProcessor';
|
||||||
|
|
||||||
@@ -78,8 +91,13 @@ describe('QueueProcessor Integration Tests', () => {
|
|||||||
// Reset mocks and their implementations
|
// Reset mocks and their implementations
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
|
||||||
// Set default mock implementations
|
// Set default mock implementations on BOTH backend modules so the test
|
||||||
vi.mocked(extractTextAndThumbnail).mockResolvedValue({
|
// behavior is invariant to EXTRACTOR_BACKEND.
|
||||||
|
vi.mocked(extractFromExtraction).mockResolvedValue({
|
||||||
|
bodyText: 'Default recipe text',
|
||||||
|
thumbnail: null
|
||||||
|
});
|
||||||
|
vi.mocked(extractFromYtDlp).mockResolvedValue({
|
||||||
bodyText: 'Default recipe text',
|
bodyText: 'Default recipe text',
|
||||||
thumbnail: null
|
thumbnail: null
|
||||||
});
|
});
|
||||||
|
|||||||
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": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"theme_color": "#ffffff",
|
"theme_color": "#FFF8F5",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#FFF8F5",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/favicon.png",
|
"src": "/favicon.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icon-512.png",
|
"src": "/icon-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any maskable"
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512-maskable.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"share_target": {
|
"share_target": {
|
||||||
|
|||||||