10 Commity 3a67d42dc6 ... d323f28c88

Autor SHA1 Wiadomość Data
  Lee Morgan d323f28c88 Fix button style 1 tydzień temu
  Lee Morgan fb5a221d03 Fix recipe page to display whether logged in or not 1 tydzień temu
  Lee Morgan a2f081bd20 Create upload button for recipes 1 tydzień temu
  Lee Morgan 503bcfbb57 Add download button for recipes 1 tydzień temu
  Lee Morgan ba372e7ef1 Create the print recipe page 1 tydzień temu
  Lee Morgan f3ee976a3b Add functionality to copy link 1 tydzień temu
  Lee Morgan 3854fdc8ce Update the style of the buttons on the recipe page 1 tydzień temu
  Lee Morgan be3bb522c4 Add notifications for turning on/off recipe mode 2 tygodni temu
  Lee Morgan 214e051968 Add recipe mode on the page 2 tygodni temu
  Lee Morgan 2b7e42842a Add funcitoning delete button 2 tygodni temu

BIN
src/lib/.wakeLock.js.swn


+ 31 - 0
src/lib/wakeLock.js

@@ -0,0 +1,31 @@
+let sentinel = null;
+
+export async function enableRecipeMode(){
+    if(typeof navigator === "undefined" || !("wakeLock" in navigator)) return false;
+
+    try{
+        sentinel = await navigator.wakeLock.request("screen");
+        sentinel.addEventListener("release", ()=>{sentinel = null});
+        return true;
+    }catch{
+        return false;
+    }
+}
+
+export async function disableRecipeMode(){
+    await sentinel?.release();
+    sentinel = null;
+}
+
+export function attachVisibilityReacquire(enabled){
+    if(typeof document === "undefined") return () => {};
+
+    async function onVis(){
+        const isEnabled = typeof enabled === "function" ? enabled() : enabled;
+        if(document.visibilityState === "visible" && isEnabled){
+            await enableRecipeMode();
+        }
+    }
+    document.addEventListener("visibilitychange", onVis);
+    return () => document.removeEventListener("visibilitychange", onVis);
+}

+ 2 - 0
src/routes/+layout.svelte

@@ -13,6 +13,7 @@
     <title>Recipes</title>
 </svelte:head>
 
+{#if !page.url.pathname.endsWith("/print")}
 <header>
     <a class="logo" href="/">
         <img src={logo} alt="Site logo">
@@ -45,6 +46,7 @@
         {/each}
     </div>
 </header>
+{/if}
 
 {@render children()}
 

+ 124 - 7
src/routes/dashboard/+page.svelte

@@ -1,11 +1,52 @@
 <script>
+    import {notify} from "$lib/notifier.svelte.js";
+    import {goto} from "$app/navigation";
+
     let {data} = $props();
+    let uploadInput = $state();
+
+    const uploadRecipe = async ()=>{
+        const file = uploadInput.files[0];
+        uploadInput.value = "";
+        if(!file) return;
+
+        try{
+            const text = await file.text();
+            const response = await fetch("/recipe/new", {
+                method: "POST",
+                headers: {"Content-Type": "application/json"},
+                credentials: "include",
+                body: text
+            });
+
+            let responseData = await response.json();
+            if(!response.ok) return notify("error", responseData.msg);
+
+            notify("success", "Recipe added");
+            goto(`/recipe/${responseData.id}`);
+        }catch(e){
+            notify("error", "Failed to parse the recipe");
+        }
+    }
 </script>
 
 <div class="container">
     <div class="topbar">
         <h1>Recipes</h1>
-        <a href="/recipe/new" class="button">New Recipe</a>
+        <div class="share">
+            <button type="button" class="shareToggle" aria-haspopup="true">New Recipe</button>
+            <div class="shareMenu" role="menu">
+                <a href="/recipe/new" role="menuitem">Create</a>
+                <button role="menuitem" onclick={()=>{uploadInput.click()}}>Upload</button>
+                <input
+                    bind:this={uploadInput}
+                    onchange={uploadRecipe}
+                    type="file"
+                    accept=".json,application/json"
+                    hidden
+                >
+            </div>
+        </div>
     </div>
 
     <div class="recipes">
@@ -46,10 +87,87 @@
         min-width: 0;
     }
 
-    .button{
+    .shareToggle{
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        box-sizing: border-box;
+        border: none;
+        outline: none;
+        cursor: pointer;
         margin: 0;
+        background: var(--color-primary);
+        color: var(--color-text-on-primary);
+        border-radius: 6px;
+        padding: 0.6rem 1.2rem;
+        font-weight: 600;
+        font-size: 1rem;
+        line-height: 1.2;
+        white-space: nowrap;
+        text-decoration: none;
+        transition: background 0.15s ease, transform 0.05s ease;
+        font-family: inherit;
+    }
+
+    .shareToggle:hover{
+        background: var(--color-primary-hover);
+    }
+
+    .shareToggle:active{
+        transform: translateY(1px);
+    }
+
+    .share{
+        position: relative;
         flex-shrink: 0;
-        text-align: center;
+    }
+
+    .shareMenu{
+        position: absolute;
+        right: 0;
+        top: calc(100% + 0.4rem);
+        z-index: 10;
+        display: none;
+        flex-direction: column;
+        gap: 0.2rem;
+        min-width: 100%;
+        padding: 0.35rem;
+        background: var(--color-surface);
+        border: 1px solid var(--color-border);
+        border-radius: 8px;
+        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
+    }
+
+    .share:focus-within .shareMenu{
+        display: flex;
+    }
+
+    .shareMenu button,
+    .shareMenu a{
+        display: flex;
+        align-items: center;
+        box-sizing: border-box;
+        border: none;
+        outline: none;
+        cursor: pointer;
+        margin: 0;
+        width: 100%;
+        padding: 0.55rem 0.8rem;
+        background: transparent;
+        color: var(--color-text);
+        border-radius: 6px;
+        font-weight: 600;
+        font-size: 0.9rem;
+        line-height: 1.2;
+        text-align: left;
+        white-space: nowrap;
+        text-decoration: none;
+        font-family: inherit;
+    }
+
+    .shareMenu button:hover,
+    .shareMenu a:hover{
+        background: var(--color-surface-alt);
     }
 
     .recipes{
@@ -121,11 +239,10 @@
     }
 
     @media (pointer: coarse){
-        .button{
+        .shareToggle,
+        .shareMenu button,
+        .shareMenu a{
             min-height: 44px;
-            display: inline-flex;
-            align-items: center;
-            justify-content: center;
         }
     }
 </style>

+ 27 - 8
src/routes/recipe/[recipeId]/+page.server.js

@@ -1,23 +1,42 @@
 import auth from "$lib/server/auth.js";
 import Recipe from "$lib/server/models/Recipe.js";
+import User from "$lib/server/models/User.js";
+import {JWT_SECRET} from "$env/static/private";
+import jwt from "jsonwebtoken";
 
 export async function load({cookies, params}){
-    const user = await auth(cookies);
-
     let recipe;
+    let user = null;
+    let isOwner = false;
+    try{
+        const userData = jwt.verify(cookies.get("user"), JWT_SECRET);
+        user = await User.findOne({_id: userData.id, token: userData.token});
+    }catch{}
+    
+
     try{
         recipe = await Recipe.findOne({_id: params.recipeId}, {"ingredients._id": 0}).lean();
+        isOwner = user && user._id.toString() === recipe.user.toString();
 
-        if(recipe.private && user._id.toString() !== recipe.user.toString()){
+        if(recipe.private && !isOwner){
             recipe = null;
         }else{
-            recipe.id = recipe._id.toString();
-            recipe._id = undefined;
-            recipe.user = undefined;
+            recipe = {
+                id: recipe._id.toString(),
+                name: recipe.name,
+                ingredients: recipe.ingredients,
+                steps: recipe.steps,
+                notes: recipe.notes,
+                createdAt: recipe.createdAt,
+                updatedAt: recipe.updatedAt
+            };
         }
-    }catch{
+    }catch(e){
         recipe = null;
     }
 
-    return {recipe};
+    return {
+        isOwner,
+        recipe
+    };
 }

+ 155 - 25
src/routes/recipe/[recipeId]/+page.svelte

@@ -1,5 +1,67 @@
 <script>
+    import {onMount} from "svelte";
+    import {enableRecipeMode, disableRecipeMode, attachVisibilityReacquire} from "$lib/wakeLock.js";
+    import {notify} from "$lib/notifier.svelte.js";
+
     let {data} = $props();
+    let on = $state(false);
+    let shareOpen = $state(false);
+
+    async function toggle(){
+        if(on){
+            await disableRecipeMode();
+            on = false;
+            notify("success", "Recipe mode on. Screen will not turn off");
+            return;
+        }
+        on = await enableRecipeMode();
+        notify("warning", "Recipe mode off.")
+    }
+
+    const copyLink = ()=>{
+        navigator.clipboard.writeText(`https://${location.hostname}/recipe/${data.recipe.id}`);
+        notify("success", "Link copied to clipboard");
+    }
+
+    const download = ()=>{
+        const json = JSON.stringify({
+            name: data.recipe.name,
+            ingredients: data.recipe.ingredients,
+            steps: data.recipe.steps,
+            notes: data.recipe.notes,
+            private: data.recipe.private
+        });
+        const blob = new Blob([json], {type: "application/json"});
+        const url = URL.createObjectURL(blob);
+
+        const a = document.createElement("a");
+        a.href = url;
+        a.download = `${data.recipe.name.replaceAll(" ", "_")}.json`;
+        document.body.appendChild(a);
+        a.click();
+        a.remove();
+
+        URL.revokeObjectURL(url);
+    }
+
+    onMount(()=>{
+        const stop = attachVisibilityReacquire(()=>on);
+
+        function onPointerDown(event){
+            if(!shareOpen) return;
+            const target = event.target;
+            if(!(target instanceof Node) || !target.closest(".share")){
+                shareOpen = false;
+            }
+        }
+        document.addEventListener("pointerdown", onPointerDown, true);
+
+        return ()=>{
+            document.removeEventListener("pointerdown", onPointerDown, true);
+            stop();
+            disableRecipeMode();
+        };
+    });
 </script>
 
 <div class="container">
@@ -21,6 +83,29 @@
 
             <p>{data.recipe.notes}</p>
         </article>
+
+        <div class="buttonBox">
+            {#if data.isOwner}
+                <a class="button" href="/recipe/{data.recipe.id}/edit">Edit</a>
+            {/if}
+            <button onclick={toggle}>Recipe Mode</button>
+            <div class="share">
+                <button
+                    type="button"
+                    class="shareToggle"
+                    aria-expanded={shareOpen}
+                    aria-haspopup="true"
+                    onclick={()=>shareOpen = !shareOpen}
+                >Share</button>
+                {#if shareOpen}
+                    <div class="shareMenu" role="menu">
+                        <button role="menuitem" onclick={copyLink}>Copy link</button>
+                        <a href="/recipe/{data.recipe.id}/print" role="menuitem">Print</a>
+                        <button role="menuitem" onclick={download}>Download</button>
+                    </div>
+                {/if}
+            </div>
+        </div>
     {:else}
         <div class="missing">
             <h1>Recipe doesn't exist or is private</h1>
@@ -28,13 +113,6 @@
             <a href="/dashboard" class="button">Back to Dashboard</a>
         </div>
     {/if}
-
-    <div class="buttonBox">
-        <a class="button" href="/recipe/{data.recipe.id}/edit">Edit</a>
-        <button>Printable PDF</button>
-        <button>Delete</button>
-        <button>Link</button>
-    </div>
 </div>
 
 <style>
@@ -64,11 +142,17 @@
     .buttonBox{
         display: flex;
         flex-wrap: wrap;
-        justify-content: flex-end;
+        align-items: center;
         gap: 0.5rem;
     }
 
-    .buttonBox button{
+    .buttonBox > a,
+    .buttonBox > button,
+    .shareToggle{
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        box-sizing: border-box;
         border: none;
         outline: none;
         cursor: pointer;
@@ -77,21 +161,76 @@
         color: var(--color-text-on-primary);
         border-radius: 6px;
         padding: 0.6rem 1.2rem;
+        font-family: inherit;
         font-weight: 600;
         font-size: 1rem;
         line-height: 1.2;
         white-space: nowrap;
+        text-decoration: none;
         transition: background 0.15s ease, transform 0.05s ease;
     }
 
-    .buttonBox button:hover{
+    .buttonBox > a:hover,
+    .buttonBox > button:hover,
+    .shareToggle:hover{
         background: var(--color-primary-hover);
     }
 
-    .buttonBox button:active{
+    .buttonBox > a:active,
+    .buttonBox > button:active,
+    .shareToggle:active{
         transform: translateY(1px);
     }
 
+    .share{
+        position: relative;
+        margin-left: auto;
+    }
+
+    .shareMenu{
+        position: absolute;
+        right: 0;
+        bottom: calc(100% + 0.4rem);
+        z-index: 10;
+        display: flex;
+        flex-direction: column;
+        gap: 0.2rem;
+        min-width: 100%;
+        padding: 0.35rem;
+        background: var(--color-surface);
+        border: 1px solid var(--color-border);
+        border-radius: 8px;
+        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
+    }
+
+    .shareMenu button,
+    .shareMenu a{
+        display: flex;
+        align-items: center;
+        box-sizing: border-box;
+        border: none;
+        outline: none;
+        cursor: pointer;
+        margin: 0;
+        width: 100%;
+        padding: 0.55rem 0.8rem;
+        background: transparent;
+        color: var(--color-text);
+        border-radius: 6px;
+        font-weight: 600;
+        font-size: 0.9rem;
+        line-height: 1.2;
+        text-align: left;
+        white-space: nowrap;
+        text-decoration: none;
+        font-family: inherit;
+    }
+
+    .shareMenu button:hover,
+    .shareMenu a:hover{
+        background: var(--color-surface-alt);
+    }
+
     article{
         display: flex;
         flex-direction: column;
@@ -253,23 +392,14 @@
         }
     }
 
-    @media (max-width: 480px){
-        .buttonBox{
-            flex-direction: column;
-        }
-
-        .buttonBox button{
-            width: 100%;
-        }
-    }
-
     @media (pointer: coarse){
-        .buttonBox button,
+        .buttonBox > a,
+        .buttonBox > button,
+        .shareToggle,
+        .shareMenu button,
+        .shareMenu a,
         .missing .button{
             min-height: 44px;
-            display: inline-flex;
-            align-items: center;
-            justify-content: center;
         }
     }
 </style>

+ 67 - 1
src/routes/recipe/[recipeId]/edit/+page.svelte

@@ -1,8 +1,21 @@
 <script>
     import EditableRecipe from "../../EditableRecipe.svelte";
+    import {notify} from "$lib/notifier.svelte.js";
+    import {goto} from "$app/navigation";
 
     let {data} = $props();
-    console.log(data)
+
+    const deleteRecipe = async ()=>{
+        const response = await fetch(`/recipe/${data.id}/edit`, {
+            method: "DELETE",
+            headers: {"Content-Type": "application/json"},
+            credentials: "include"
+        });
+
+        const json = await response.json();
+        if(!response.ok) return notify("error", json.msg);
+        goto("/dashboard");
+    }
 </script>
 
 <div class="container">
@@ -15,4 +28,57 @@
         visibility={data.private ? "private" : "public"}
         route="/recipe/{data.id}/edit"
     />
+    <button
+        type="button"
+        class="button delete"
+        onclick={deleteRecipe}
+    >Delete</button>
 </div>
+
+<style>
+    .container{
+        display: flex;
+        flex-direction: column;
+        justify-content: flex-start;
+        align-items: center;
+        gap: 0.75rem;
+        width: 100%;
+        max-width: 100%;
+        min-height: calc(100% - 75px);
+        min-height: calc(100dvh - 75px);
+        padding: 1rem max(0.75rem, env(safe-area-inset-right, 0px)) max(1.5rem, env(safe-area-inset-bottom, 0px)) max(0.75rem, env(safe-area-inset-left, 0px));
+        color: var(--color-text);
+        overflow-x: clip;
+        box-sizing: border-box;
+    }
+
+    .delete{
+        margin: 0;
+        width: min(640px, 100%);
+        background: var(--color-error);
+        color: var(--color-text);
+    }
+
+    .delete:hover{
+        background: color-mix(in srgb, var(--color-error) 82%, white);
+    }
+
+    @media (min-width: 640px){
+        .container{
+            padding: 2rem 1rem 3rem;
+        }
+    }
+
+    @media (max-height: 500px){
+        .container{
+            padding-top: 0.75rem;
+            padding-bottom: 1rem;
+        }
+    }
+
+    @media (pointer: coarse){
+        .delete{
+            min-height: 44px;
+        }
+    }
+</style>

+ 16 - 0
src/routes/recipe/[recipeId]/edit/+server.js

@@ -28,3 +28,19 @@ export async function POST({request, cookies, params}){
 
     return json({msg: "success"}, {status: 200});
 }
+
+export async function DELETE({cookies, params}){
+    const user = await auth(cookies, false);
+
+    let recipe;
+    try{
+        recipe = await Recipe.findOne({_id: params.recipeId});
+        if(!recipe || user._id.toString() !== recipe.user.toString()) error(403, "Forbidden");
+        await Recipe.deleteOne({_id: params.recipeId});
+    }catch(e){
+        if(e.status === 403) error(403, "Forbidden");
+        error(500, "Internal Server Error");
+    }
+
+    return json({msg: "success"}, {status: 200});
+}

+ 23 - 0
src/routes/recipe/[recipeId]/print/+page.server.js

@@ -0,0 +1,23 @@
+import auth from "$lib/server/auth.js";
+import Recipe from "$lib/server/models/Recipe.js";
+
+export async function load({cookies, params}){
+    const user = await auth(cookies);
+
+    let recipe;
+    try{
+        recipe = await Recipe.findOne({_id: params.recipeId}, {"ingredients._id": 0}).lean();
+
+        if(recipe.private && user._id.toString() !== recipe.user.toString()){
+            recipe = null;
+        }else{
+            recipe.id = recipe._id.toString();
+            recipe._id = undefined;
+            recipe.user = undefined;
+        }
+    }catch{
+        recipe = null;
+    }
+
+    return {recipe};
+}

+ 376 - 0
src/routes/recipe/[recipeId]/print/+page.svelte

@@ -0,0 +1,376 @@
+<script>
+    let {data} = $props();
+    let recipe = $derived(data.recipe);
+
+    function printPage(){
+        window.print();
+    }
+</script>
+
+<svelte:head>
+    <title>{recipe ? `${recipe.name}` : "Recipe"}</title>
+</svelte:head>
+
+{#if recipe}
+    <div class="screenBar">
+        <a href="/recipe/{recipe.id}">Back to recipe</a>
+        <button type="button" onclick={printPage}>Print</button>
+    </div>
+
+    <article class="sheet">
+        <div class="masthead">
+            <h1>{recipe.name}</h1>
+        </div>
+
+        <div class="layout">
+            <section class="ingredients">
+                <h2>Ingredients</h2>
+                <ul>
+                    {#each recipe.ingredients as i}
+                        <li>
+                            <span class="qty">{i.quantity}</span>
+                            <span class="unit">{i.unit}</span>
+                            <span class="iname">{i.name}</span>
+                        </li>
+                    {/each}
+                </ul>
+            </section>
+
+            <section class="method">
+                <h2>Method</h2>
+                <ol>
+                    {#each recipe.steps as s, idx}
+                        <li>
+                            <span class="num">{idx + 1}</span>
+                            <p>{s}</p>
+                        </li>
+                    {/each}
+                </ol>
+            </section>
+
+            {#if recipe.notes}
+                <section class="notes">
+                    <h2>Notes</h2>
+                    <p>{recipe.notes}</p>
+                </section>
+            {/if}
+        </div>
+    </article>
+{:else}
+    <div class="missing">
+        <h1>Recipe doesn't exist or is private</h1>
+        <p>It may have been removed, or you don't have permission to view it.</p>
+        <a href="/dashboard" class="button">Back to Dashboard</a>
+    </div>
+{/if}
+
+<style>
+    :global(html),
+    :global(body){
+        background: var(--color-bg);
+    }
+
+    .screenBar{
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        gap: 0.75rem;
+        width: min(8.5in, calc(100% - 2rem));
+        margin: 1rem auto 0;
+    }
+
+    .screenBar a,
+    .screenBar button{
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        margin: 0;
+        border: none;
+        border-radius: 6px;
+        padding: 0.55rem 1.1rem;
+        background: var(--color-primary);
+        color: var(--color-text-on-primary);
+        font: inherit;
+        font-weight: 600;
+        font-size: 0.95rem;
+        text-decoration: none;
+        cursor: pointer;
+    }
+
+    .screenBar a:hover,
+    .screenBar button:hover{
+        background: var(--color-primary-hover);
+    }
+
+    .sheet{
+        width: min(8.5in, calc(100% - 2rem));
+        margin: 1rem auto 2.5rem;
+        padding: 0.7in 0.75in 0.65in;
+        background: #fffdf8;
+        color: #1c1917;
+        border-radius: 2px;
+        box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
+    }
+
+    .masthead{
+        margin-bottom: 1.35rem;
+        padding-bottom: 0.85rem;
+        border-bottom: 2px solid #ff7a1a;
+    }
+
+    h1{
+        margin: 0;
+        font-family: Georgia, "Iowan Old Style", "Palatino Linotype", Palatino, serif;
+        font-size: clamp(1.8rem, 1.4rem + 1.6vw, 2.45rem);
+        font-weight: 700;
+        line-height: 1.15;
+        letter-spacing: -0.02em;
+        overflow-wrap: anywhere;
+    }
+
+    .layout,
+    .ingredients,
+    .method,
+    .notes{
+        display: block;
+        width: 100%;
+        max-width: 100%;
+        float: none;
+    }
+
+    .layout{
+        display: flex;
+        flex-direction: column;
+        gap: 1.6rem;
+    }
+
+    h2{
+        margin: 0 0 0.7rem;
+        color: #cc5f0f;
+        font-size: 0.72rem;
+        font-weight: 700;
+        letter-spacing: 0.18em;
+        text-transform: uppercase;
+    }
+
+    .ingredients ul{
+        margin: 0;
+        padding: 0;
+        list-style: none;
+        display: grid;
+        grid-template-columns: 1fr 1fr;
+        gap: 0.45rem 1.5rem;
+        width: 100%;
+    }
+
+    .ingredients li{
+        display: grid;
+        grid-template-columns: 2.1rem 2.2rem minmax(0, 1fr);
+        gap: 0.35rem 0.45rem;
+        align-items: baseline;
+        min-width: 0;
+        padding: 0.2rem 0 0.2rem 0.6rem;
+        border-left: 2px solid #ff7a1a;
+        font-size: 0.95rem;
+        line-height: 1.35;
+        break-inside: avoid;
+    }
+
+    .qty{
+        font-variant-numeric: tabular-nums;
+        font-weight: 700;
+        text-align: right;
+    }
+
+    .unit{
+        color: #6b645c;
+        font-size: 0.85rem;
+    }
+
+    .iname{
+        overflow-wrap: anywhere;
+    }
+
+    .method{
+        width: 100%;
+        padding-top: 0.15rem;
+        border-top: 1px solid #e6dfd4;
+    }
+
+    .method ol{
+        margin: 0;
+        padding: 0;
+        list-style: none;
+        display: flex;
+        flex-direction: column;
+        gap: 0.95rem;
+        counter-reset: none;
+    }
+
+    .method li{
+        display: grid;
+        grid-template-columns: 1.55rem minmax(0, 1fr);
+        gap: 0.85rem;
+        align-items: start;
+        width: 100%;
+        break-inside: avoid;
+    }
+
+    .num{
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 1.55rem;
+        height: 1.55rem;
+        border: 1.5px solid #ff7a1a;
+        border-radius: 50%;
+        color: #cc5f0f;
+        font-size: 0.75rem;
+        font-weight: 700;
+        line-height: 1;
+    }
+
+    .method p{
+        margin: 0;
+        font-size: 0.98rem;
+        line-height: 1.5;
+        overflow-wrap: anywhere;
+    }
+
+    .notes{
+        padding-top: 0.35rem;
+        border-top: 1px solid #e6dfd4;
+        break-inside: avoid;
+    }
+
+    .notes p{
+        margin: 0;
+        font-size: 0.95rem;
+        line-height: 1.55;
+        white-space: pre-wrap;
+        overflow-wrap: anywhere;
+        color: #3f3a36;
+    }
+
+    @media (max-width: 560px){
+        .ingredients ul{
+            grid-template-columns: 1fr;
+        }
+    }
+
+    .missing{
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        gap: 0.75rem;
+        width: min(420px, calc(100% - 2rem));
+        margin: 15vh auto;
+        padding: 2rem 1.5rem;
+        text-align: center;
+        color: var(--color-text);
+        background: var(--color-surface);
+        border: 1px solid var(--color-border);
+        border-radius: 10px;
+    }
+
+    .missing h1{
+        font-family: inherit;
+        font-size: 1.35rem;
+        letter-spacing: 0;
+    }
+
+    .missing p{
+        margin: 0;
+        color: var(--color-text-muted);
+    }
+
+    .missing a{
+        margin: 0;
+    }
+
+    @page{
+        size: letter;
+        margin: 0.55in 0.6in 0.55in 0.6in;
+    }
+
+    @media print{
+        :global(html),
+        :global(body){
+            background: #ffffff !important;
+            color: #1c1917 !important;
+            height: auto !important;
+            overflow: visible !important;
+        }
+
+        .screenBar{
+            display: none !important;
+        }
+
+        .sheet{
+            width: auto;
+            max-width: none;
+            margin: 0;
+            padding: 0;
+            background: #ffffff;
+            box-shadow: none;
+            border-radius: 0;
+        }
+
+        .layout,
+        .ingredients,
+        .method,
+        .notes{
+            display: block !important;
+            width: 100% !important;
+            max-width: 100% !important;
+            float: none !important;
+            clear: both !important;
+            grid-column: auto !important;
+            grid-row: auto !important;
+            grid-template-columns: none !important;
+        }
+
+        .layout{
+            gap: 0 !important;
+        }
+
+        .ingredients,
+        .method,
+        .notes{
+            margin-top: 1.15rem !important;
+            padding-right: 0 !important;
+            border-right: 0 !important;
+        }
+
+        .ingredients{
+            margin-top: 0 !important;
+        }
+
+        .ingredients ul{
+            display: grid !important;
+            grid-template-columns: 1fr 1fr !important;
+            gap: 0.35rem 0.5in !important;
+            width: 100% !important;
+            float: none !important;
+        }
+
+        .ingredients li{
+            grid-template-columns: 2.1rem 2.2rem minmax(0, 1fr) !important;
+        }
+
+        h1, h2, .masthead, .method li, .ingredients li, .notes{
+            break-inside: avoid;
+            page-break-inside: avoid;
+        }
+
+        .masthead{
+            break-after: avoid;
+            page-break-after: avoid;
+        }
+
+        *{
+            print-color-adjust: exact;
+            -webkit-print-color-adjust: exact;
+        }
+    }
+</style>