From ee7946316c5dfec70cf548374ff61eada92ecd0d Mon Sep 17 00:00:00 2001 From: Arnaud Date: Sat, 4 Jul 2026 10:32:23 +0200 Subject: [PATCH] feat: show recipe source link, keep screen awake while viewing a recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sourceUrl was already saved on URL import but never displayed — render it as a link (hostname only) under the description. - Extract cook-mode's wake lock into a reusable useWakeLock hook, add visibility-change re-acquisition (a wake lock releases when the tab goes background and won't come back on its own), and use it on the regular recipe view too, not just cook mode. Co-Authored-By: Claude Sonnet 5 --- apps/web/app/(app)/recipes/[id]/page.tsx | 14 ++++++ .../components/cooking-mode/cooking-mode.tsx | 8 +--- .../components/recipe/keep-screen-awake.tsx | 9 ++++ apps/web/lib/hooks/use-wake-lock.ts | 43 +++++++++++++++++++ apps/web/messages/en.json | 1 + apps/web/messages/fr.json | 1 + 6 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 apps/web/components/recipe/keep-screen-awake.tsx create mode 100644 apps/web/lib/hooks/use-wake-lock.ts diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index 9e0759e..543bbbf 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -29,6 +29,7 @@ import { CommentsSection } from "@/components/social/comments-section"; import { getPublicUrl } from "@/lib/storage"; import { cn } from "@/lib/utils"; import { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel"; +import { KeepScreenAwake } from "@/components/recipe/keep-screen-awake"; import { getMessages, formatMessage } from "@/lib/i18n/server"; type Params = { params: Promise<{ id: string }> }; @@ -86,6 +87,7 @@ export default async function RecipePage({ params }: Params) { return (
+ {/* Header */}

{recipe.title}

@@ -200,6 +202,18 @@ export default async function RecipePage({ params }: Params) {

{recipe.description}

)} + {recipe.sourceUrl && ( + + + {m.recipe.source}: {new URL(recipe.sourceUrl).hostname.replace(/^www\./, "")} + + )} +
{recipe.difficulty && ( {m.recipe.difficulty[recipe.difficulty]} diff --git a/apps/web/components/cooking-mode/cooking-mode.tsx b/apps/web/components/cooking-mode/cooking-mode.tsx index ebad953..74c5344 100644 --- a/apps/web/components/cooking-mode/cooking-mode.tsx +++ b/apps/web/components/cooking-mode/cooking-mode.tsx @@ -7,6 +7,7 @@ import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { useLocale } from "@/lib/i18n/provider"; import { hasQuantity } from "@/lib/fractions"; +import { useWakeLock } from "@/lib/hooks/use-wake-lock"; type Step = { id: string; instruction: string; timerSeconds: number | null }; type Ingredient = { rawName: string; quantity: string | null; unit: string | null }; @@ -85,7 +86,6 @@ export function CookingMode({ // Parallel timers: keyed by step index const [timers, setTimers] = useState>(() => new Map()); - const wakeLockRef = useRef(null); const recognitionRef = useRef | null>(null); const step = steps[current]!; @@ -95,11 +95,7 @@ export function CookingMode({ const currentTimer = timers.get(current); const otherRunningTimers = [...timers.entries()].filter(([idx, t]) => idx !== current && t.running); - // Wake Lock - useEffect(() => { - navigator.wakeLock?.request("screen").then((lock) => { wakeLockRef.current = lock; }).catch(() => {}); - return () => { wakeLockRef.current?.release().catch(() => {}); }; - }, []); + useWakeLock(); // Master tick: decrement all running timers once per second useEffect(() => { diff --git a/apps/web/components/recipe/keep-screen-awake.tsx b/apps/web/components/recipe/keep-screen-awake.tsx new file mode 100644 index 0000000..6284769 --- /dev/null +++ b/apps/web/components/recipe/keep-screen-awake.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { useWakeLock } from "@/lib/hooks/use-wake-lock"; + +/** Invisible — just keeps the screen awake for as long as it's mounted. */ +export function KeepScreenAwake() { + useWakeLock(); + return null; +} diff --git a/apps/web/lib/hooks/use-wake-lock.ts b/apps/web/lib/hooks/use-wake-lock.ts new file mode 100644 index 0000000..35634b8 --- /dev/null +++ b/apps/web/lib/hooks/use-wake-lock.ts @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +/** Keeps the screen awake while the component is mounted and the tab is visible. */ +export function useWakeLock(enabled = true) { + const wakeLockRef = useRef(null); + + useEffect(() => { + if (!enabled) return; + + let cancelled = false; + + async function acquire() { + try { + const lock = await navigator.wakeLock?.request("screen"); + if (cancelled) { + void lock?.release(); + return; + } + wakeLockRef.current = lock ?? null; + } catch { + // Wake Lock unsupported or denied — silently degrade. + } + } + + function handleVisibilityChange() { + if (document.visibilityState === "visible" && !wakeLockRef.current) { + void acquire(); + } + } + + void acquire(); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + cancelled = true; + document.removeEventListener("visibilitychange", handleVisibilityChange); + void wakeLockRef.current?.release().catch(() => {}); + wakeLockRef.current = null; + }; + }, [enabled]); +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index cae5c70..1d8e70c 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -29,6 +29,7 @@ "mention": "{name} mentioned you in a comment" }, "recipe": { + "source": "Source", "share": "Share", "shareLinkCopied": "Link copied to clipboard", "shareCopyFailed": "Failed to copy link", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 4d9a656..b766bff 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -29,6 +29,7 @@ "mention": "{name} vous a mentionné dans un commentaire" }, "recipe": { + "source": "Source", "share": "Partager", "shareLinkCopied": "Lien copié dans le presse-papiers", "shareCopyFailed": "Échec de la copie du lien",