diff --git a/CHANGELOG.md b/CHANGELOG.md index 4282131..b0082b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.67.0 — 2026-07-21 09:30 + +### Added +- Epicure now registers as an OS share target (Chromium/Android, once installed): share a recipe link from another app's share sheet and it opens straight into the URL-import flow, pre-filled and auto-imported. Safari has no equivalent API — Chromium/Android only, same restriction as the install-prompt banner. + ## 0.66.0 — 2026-07-21 09:00 ### Added diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md index a289ad7..33a57c9 100644 --- a/FEATURE_AUDIT.md +++ b/FEATURE_AUDIT.md @@ -130,7 +130,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real | Push (client + server) | Exists end-to-end | Real subscribe/unsubscribe with correct state sync, VAPID, multiple triggers wired | `apps/web/lib/push.ts` | | Voice control | **Exists** | Web Speech API in cooking mode (this was previously assumed missing — it isn't) | `apps/web/components/cooking-mode/cooking-mode.tsx` | | Barcode scanning | **Exists** (pantry only) | `BarcodeDetector` isn't used, but a manual-entry-triggered Open Food Facts API lookup covers the same use case | `apps/web/app/api/v1/pantry/scan/barcode` | -| OS Share Target (share a URL into Epicure from another app) | **Missing** | No `share_target` in the manifest | — | +| OS Share Target (share a URL into Epicure from another app) | Exists (Chromium/Android only) | Manifest `share_target` → `/recipes?title&text&url`; extracts the shared link from either `url` or a URL substring in `text` (senders vary), auto-opens the existing import-from-URL dialog pre-filled and auto-imports. Safari has no Share Target API at all — same Apple-restriction story as install prompts. | `apps/web/app/manifest.ts`, `apps/web/app/(app)/recipes/page.tsx`, `apps/web/components/recipe/url-import-dialog.tsx` | | Home-screen widgets, Siri/Assistant shortcuts | **Missing**, expected — N/A for a pure PWA without a native shell | — | | Native mobile app (React Native/Capacitor/Expo) | **Missing** | Confirmed PWA-only, no native project anywhere in the monorepo | — | @@ -144,7 +144,7 @@ Ranked roughly by likely value: 2. **Recipe video** — no support at all. 3. **Nutrition trend/history view** — diary is single-day only, no multi-day chart. 4. **USDA/nutrition-database lookup** — all nutrition numbers are AI-estimated; barcode scan only gets product name, not nutrition facts. -5. **OS Share Target** — can't share a recipe link into Epicure from another app's share sheet. +5. ~~OS Share Target~~ — closed 2026-07-21 (Chromium/Android only, no Safari equivalent exists). 6. ~~Push click-through handling~~ — closed 2026-07-21 (`push`/`notificationclick` listeners added to `sw.js`). 7. **Anonymous public link for meal plans** — shopping lists have this, meal plans don't. 8. **Grocery delivery/live pricing** beyond the Instacart stub (which needs a partnership agreement to go live). diff --git a/apps/web/app/(app)/recipes/page.tsx b/apps/web/app/(app)/recipes/page.tsx index ad8c549..ec69919 100644 --- a/apps/web/app/(app)/recipes/page.tsx +++ b/apps/web/app/(app)/recipes/page.tsx @@ -24,8 +24,25 @@ type SearchParams = Promise<{ page?: string; batchCook?: string; recipeType?: string; + // OS Share Target params (manifest.ts's share_target) — a browser share + // sheet navigates here with these appended. + url?: string; + text?: string; + title?: string; }>; +const URL_PATTERN = /https?:\/\/\S+/; + +/** Web Share Target's GET params vary by sender: some apps put the shared + * link in `url`, others (notably many Android apps) put it inside `text` + * alongside other text. Prefer `url`, fall back to extracting the first + * http(s) URL out of `text`. */ +function extractSharedUrl(params: { url?: string; text?: string }): string | undefined { + if (params.url && URL_PATTERN.test(params.url)) return params.url; + const match = params.text?.match(URL_PATTERN); + return match?.[0]; +} + const SORT_MAP = { updated_desc: desc(recipes.updatedAt), updated_asc: asc(recipes.updatedAt), @@ -43,7 +60,8 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear const m = getMessages((session.user as { locale?: string }).locale); const featurePrefs = await getFeaturePrefs(session.user.id); - const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook, recipeType } = await searchParams; + const { q, sort, visibility, difficulty, tag, page: pageParam, batchCook, recipeType, url, text } = await searchParams; + const sharedUrl = extractSharedUrl({ url, text }); const query = (q ?? "").trim().slice(0, 200); const sortKey: SortKey = (sort && sort in SORT_MAP ? sort : "updated_desc") as SortKey; const tagFilter = tag?.trim().slice(0, 50) || undefined; @@ -140,6 +158,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear initialTag={tagFilter ?? ""} initialBatchCook={batchCookFilter ?? ""} initialRecipeType={recipeTypeFilter ?? ""} + sharedUrl={sharedUrl} /> diff --git a/apps/web/app/manifest.ts b/apps/web/app/manifest.ts index 808d644..0aec94a 100644 --- a/apps/web/app/manifest.ts +++ b/apps/web/app/manifest.ts @@ -15,5 +15,18 @@ export default function manifest(): MetadataRoute.Manifest { { src: "/icon-512.svg", sizes: "512x512", type: "image/svg+xml", purpose: "any" }, { src: "/icon-512.svg", sizes: "512x512", type: "image/svg+xml", purpose: "maskable" }, ], + // Registers Epicure as a share target in the OS share sheet (Chromium + // on Android/desktop once installed — Safari has no equivalent API). + // Sharing a recipe link from another app opens /recipes with these + // query params, which auto-opens the URL-import dialog pre-filled. + share_target: { + action: "/recipes", + method: "GET", + params: { + title: "title", + text: "text", + url: "url", + }, + }, }; } diff --git a/apps/web/components/recipe/recipes-header.tsx b/apps/web/components/recipe/recipes-header.tsx index 3630e8a..a04fc26 100644 --- a/apps/web/components/recipe/recipes-header.tsx +++ b/apps/web/components/recipe/recipes-header.tsx @@ -87,6 +87,7 @@ export function RecipesHeader({ initialTag = "", initialBatchCook = "", initialRecipeType = "", + sharedUrl, }: { count: number; initialQuery?: string; @@ -96,13 +97,19 @@ export function RecipesHeader({ initialTag?: string; initialBatchCook?: string; initialRecipeType?: string; + /** A link shared into the installed PWA via the OS share sheet (Web Share + * Target, Chromium/Android only) — see manifest.ts's share_target and + * page.tsx's read of the `url`/`text` params it's redirected here with. + * Auto-opens the import dialog pre-filled instead of requiring the user + * to paste the link again. */ + sharedUrl?: string; }) { const router = useRouter(); const pathname = usePathname(); const t = useTranslations("recipes"); const tRecipe = useTranslations("recipe"); const [aiOpen, setAiOpen] = useState(false); - const [urlOpen, setUrlOpen] = useState(false); + const [urlOpen, setUrlOpen] = useState(!!sharedUrl); const [query, setQuery] = useState(initialQuery); const [, startTransition] = useTransition(); @@ -314,7 +321,7 @@ export function RecipesHeader({ - + ); } diff --git a/apps/web/components/recipe/url-import-dialog.tsx b/apps/web/components/recipe/url-import-dialog.tsx index fae9738..3b35ada 100644 --- a/apps/web/components/recipe/url-import-dialog.tsx +++ b/apps/web/components/recipe/url-import-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { Link2, Loader2 } from "lucide-react"; @@ -25,15 +25,23 @@ type ImportedRecipe = { export function UrlImportDialog({ open, onOpenChange, + initialUrl, + autoImport, }: { open: boolean; onOpenChange: (open: boolean) => void; + /** Prefills (and, with autoImport, triggers) the import — used by the OS + * Share Target flow (apps/web/app/(app)/recipes/page.tsx) so sharing a + * link into the installed PWA lands here already filled in. */ + initialUrl?: string; + autoImport?: boolean; }) { const t = useTranslations("recipe"); const tCommon = useTranslations("common"); const router = useRouter(); - const [url, setUrl] = useState(""); + const [url, setUrl] = useState(initialUrl ?? ""); const [importing, setImporting] = useState(false); + const autoImportFired = useRef(false); async function handleImport() { if (!url.trim()) return; @@ -77,6 +85,14 @@ export function UrlImportDialog({ } } + useEffect(() => { + if (open && autoImport && initialUrl?.trim() && !autoImportFired.current) { + autoImportFired.current = true; + void handleImport(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, autoImport, initialUrl]); + return ( diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 9e2a788..8bc3a5c 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // Mirrors CHANGELOG.md at the repo root — update both together. -export const APP_VERSION = "0.66.0"; +export const APP_VERSION = "0.67.0"; export type ChangelogEntry = { version: string; @@ -11,6 +11,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.67.0", + date: "2026-07-21 09:30", + added: [ + "Epicure now registers as an OS share target (Chromium/Android, once installed): share a recipe link from another app's share sheet and it opens straight into the URL-import flow, pre-filled and auto-imported. Safari has no equivalent API — Chromium/Android only, same restriction as the install-prompt banner.", + ], + }, { version: "0.66.0", date: "2026-07-21 09:00", diff --git a/apps/web/package.json b/apps/web/package.json index 238dd41..45ca011 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.66.0", + "version": "0.67.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 91051dd..022253e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.66.0", + "version": "0.67.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",