1
0

5 Коммиты 01241a90d2 ... 3a67d42dc6

Автор SHA1 Сообщение Дата
  Lee Morgan 3a67d42dc6 Update ingredients and steps so that they can be removed 2 недель назад
  Lee Morgan 42676be23c Make recipes editable 2 недель назад
  Lee Morgan 07df9be0dd Move much of the new recipe stuff to a component 2 недель назад
  Lee Morgan 2c37e47dd1 Create the page to display a recipe 2 недель назад
  Lee Morgan 26b40ce5c1 Add public/private options for recipes 2 недель назад

+ 2 - 20
src/lib/global.css

@@ -113,26 +113,8 @@ body{
     margin-bottom: 0.5rem;
 }
 
-.standardForm button{
-    border: none;
-    outline: none;
-    cursor: pointer;
-    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;
-    transition: background 0.15s ease, transform 0.05s ease;
-}
-
-.standardForm button:hover{
-    background: var(--color-primary-hover);
-}
-
-.standardForm button:active{
-    transform: translateY(1px);
+.standardForm .button{
+    margin: 0;
 }
 
 .standardForm .error{

+ 5 - 1
src/lib/server/models/Recipe.js

@@ -32,9 +32,13 @@ const recipeSchema = new mongoose.Schema({
         type: Date,
         required: true
     },
-    updateAt: {
+    updatedAt: {
         type: Date,
         required: true
+    },
+    private: {
+        type: Boolean,
+        required: true
     }
 });
 

+ 2 - 2
src/routes/dashboard/+page.server.js

@@ -2,9 +2,9 @@ import Recipe from "$lib/server/models/Recipe.js";
 import userAuth from "$lib/server/auth.js";
 
 export async function load({cookies}){
-    const userCookie = await userAuth(cookies);
+    const user = await userAuth(cookies);
     const recipes = await Recipe.aggregate([
-        {$match: {user: userCookie._id}},
+        {$match: {user: user._id}},
         {$project: {
             _id: 0,
             id: {$toString: "$_id"},

+ 4 - 2
src/routes/dashboard/+page.svelte

@@ -10,10 +10,10 @@
 
     <div class="recipes">
         {#each data.recipes as r}
-            <article class="recipe">
+            <a href="/recipe/{r.id}" class="recipe">
                 <p class="name">{r.name}</p>
                 <p class="date">{r.updatedAt ? new Date(r.updatedAt).toLocaleDateString() : ""}</p>
-            </article>
+            </a>
         {:else}
             <p class="empty">No recipes yet. Create your first one.</p>
         {/each}
@@ -70,6 +70,8 @@
         border: 1px solid var(--color-border);
         border-left: 3px solid var(--color-primary);
         border-radius: 10px;
+        text-decoration: none;
+        color: var(--color-text);
     }
 
     .name{

+ 1 - 1
src/routes/login/+page.svelte

@@ -20,7 +20,7 @@
             <input name="password" type="password" required>
         </label>
 
-        <button>Submit</button>
+        <button class="button">Submit</button>
     </form>
 </div>
 

+ 500 - 0
src/routes/recipe/EditableRecipe.svelte

@@ -0,0 +1,500 @@
+<script>
+    import {notify} from "$lib/notifier.svelte.js";
+    import {goto} from "$app/navigation";
+
+    let nameInput, quantityInput, stepsInput;
+    let {
+        id,
+        name,
+        ingredients: ingredientsProp,
+        steps: stepsProp,
+        notes,
+        visibility,
+        route
+    } = $props();
+    let ingredients = $state(ingredientsProp);
+    let steps = $state(stepsProp);
+    let quantity = $state();
+    let unit = $state("");
+    let ingredientName = $state("");
+    let stepString = $state("");
+
+    const submit = async (event)=>{
+        event.preventDefault();
+
+        if(!name){
+            notify("error", "Please enter recipe name");
+            nameInput.focus();
+            return;
+        }
+
+        const data = {
+            name, ingredients, steps, notes,
+            private: visibility === "private" ? true : false
+        };
+
+        const response = await fetch(route, {
+            method: "POST",
+            headers: {"Content-Type": "application/json"},
+            credentials: "include",
+            body: JSON.stringify(data)
+        });
+
+        const recipe = await response.json();
+        if(!response.ok) return notify("error", recipe.message);
+
+        let redirect = route === "/recipe/new" ? "/dashboard" : `/recipe/${id}`;
+        goto(redirect);
+    }
+
+    const addIngredient = ()=>{
+        if(quantity <= 0) return notify("error", "Quantity must be greater than 0");
+        if(!unit) return notify("error", "Unit is required");
+        if(!ingredientName) return notify("error", "Ingredient name is required");
+
+        ingredients.push({
+            quantity: quantity,
+            unit: unit,
+            name: ingredientName
+        });
+
+        quantity = null;
+        unit = "";
+        ingredientName = "";
+
+        quantityInput.focus();
+    }
+
+    const addStep = ()=>{
+        if(!stepString) return notify("error", "Please enter some text");
+        steps.push(stepString);
+        stepString = "";
+        stepsInput.focus();
+    }
+
+    $effect(()=>{
+        nameInput?.focus();
+    });
+</script>
+
+<form class="standardForm" onsubmit={submit}>
+    <h1>New Recipe</h1>
+
+    <label>Name
+        <input type="text" bind:this={nameInput} bind:value={name}>
+    </label>
+
+    <div class="ingredients">
+        <h2>Ingredients</h2>
+
+        <ul>
+            {#each ingredients as i, idx}
+                <li><button
+                    onclick={()=>ingredients.splice(idx, 1)}
+                    type="button"
+                >{i.name}, {i.quantity} {i.unit}</button></li>
+            {/each}
+        </ul>
+
+        <label>Quantity
+            <input type="number" min="0" bind:value={quantity} bind:this={quantityInput}>
+        </label>
+
+        <label>Unit
+            <input type="text" bind:value={unit}>
+        </label>
+
+        <label>Name
+            <input type="text" bind:value={ingredientName}>
+        </label>
+
+        <button class="button" type="button" onclick={addIngredient}>Add</button>
+    </div>
+
+    <div class="steps">
+        <h2>Steps</h2>
+
+        <ol>
+            {#each steps as s, idx}
+                <li><button
+                    onclick={()=>{steps.splice(idx, 1)}}
+                    type="button"
+                >{s}</button></li>
+            {/each}
+        </ol>
+
+        <textarea bind:value={stepString} rows="2" bind:this={stepsInput}></textarea>
+
+        <button class="button" type="button" onclick={addStep}>Add</button>
+    </div>
+
+    <textarea bind:value={notes} rows="4" placeholder="Notes"></textarea>
+
+    <fieldset class="visibility">
+        <legend>Visibility</legend>
+        <div class="visibilityOptions">
+            <label>
+                <input
+                    type="radio"
+                    name="visibility"
+                    value="private"
+                    bind:group={visibility}
+                >
+                <span>Private</span>
+            </label>
+            <label>
+                <input
+                    type="radio"
+                    name="visibility"
+                    value="public"
+                    bind:group={visibility}
+                >
+                <span>Public</span>
+            </label>
+        </div>
+    </fieldset>
+
+    <button class="button">
+        {#if route === "/recipe/new"}
+            Create Recipe
+        {:else}
+            Update Recipe
+        {/if}
+    </button>
+</form>
+
+<style>
+    .standardForm{
+        width: min(640px, 100%);
+        max-width: 100%;
+        min-width: 0;
+        padding: 1.15rem 0.9rem;
+        container-type: inline-size;
+        container-name: recipe-form;
+    }
+
+    h1{
+        font-size: clamp(1.25rem, 1rem + 2vw, 1.5rem);
+    }
+
+    h2{
+        color: var(--color-text);
+        font-size: clamp(1rem, 0.9rem + 0.5vw, 1.1rem);
+        font-weight: 700;
+        letter-spacing: 0.01em;
+    }
+
+    input,
+    textarea{
+        width: 100%;
+        min-width: 0;
+        max-width: 100%;
+        font-size: 1rem;
+    }
+
+    .ingredients,
+    .steps{
+        display: flex;
+        flex-direction: column;
+        gap: 0.75rem;
+        padding: 0.75rem;
+        min-width: 0;
+        max-width: 100%;
+        background: var(--color-surface-alt);
+        border: 1px solid var(--color-border);
+        border-radius: 8px;
+    }
+
+    ul,
+    ol{
+        margin: 0;
+        padding: 0;
+        display: flex;
+        flex-direction: column;
+        gap: 0.45rem;
+        list-style: none;
+        min-width: 0;
+        max-width: 100%;
+    }
+
+    ul:not(:has(li)),
+    ol:not(:has(li)){
+        display: none;
+    }
+
+    li{
+        padding: 0.55rem 0.7rem;
+        background: var(--color-surface);
+        border: 1px solid var(--color-border);
+        border-left: 3px solid var(--color-primary);
+        border-radius: 6px;
+        font-size: 0.95rem;
+        line-height: 1.4;
+        color: var(--color-text);
+        overflow-wrap: anywhere;
+        word-break: break-word;
+        min-width: 0;
+        max-width: 100%;
+    }
+
+    ul li{
+        padding: 0;
+    }
+
+    li button{
+        display: block;
+        width: 100%;
+        margin: 0;
+        padding: 0.55rem 0.7rem;
+        border: none;
+        outline: none;
+        background: none;
+        color: inherit;
+        font: inherit;
+        font-size: inherit;
+        line-height: inherit;
+        text-align: left;
+        cursor: pointer;
+        overflow-wrap: anywhere;
+        word-break: break-word;
+    }
+
+    ol li button{
+        padding: 0;
+    }
+
+    ul li:hover,
+    ol li:hover{
+        border-color: var(--color-primary);
+    }
+
+    ol{
+        counter-reset: step;
+    }
+
+    ol li{
+        display: grid;
+        grid-template-columns: 1.5rem minmax(0, 1fr);
+        align-items: start;
+        gap: 0.7rem;
+        counter-increment: step;
+    }
+
+    ol li::before{
+        content: counter(step);
+        flex-shrink: 0;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 1.5rem;
+        height: 1.5rem;
+        border-radius: 50%;
+        background: var(--color-primary);
+        color: var(--color-text-on-primary);
+        font-size: 0.75rem;
+        font-weight: 700;
+        line-height: 1;
+    }
+
+    .ingredients{
+        display: grid;
+        grid-template-columns: minmax(0, 1fr);
+        align-items: stretch;
+        gap: 0.75rem;
+    }
+
+    .ingredients > *{
+        min-width: 0;
+        max-width: 100%;
+    }
+
+    .button{
+        margin: 0;
+        width: 100%;
+        height: auto;
+        min-height: 2.5rem;
+        white-space: nowrap;
+    }
+
+    textarea{
+        resize: vertical;
+        min-height: 3.5rem;
+    }
+
+    .standardForm > textarea{
+        min-height: 5rem;
+    }
+
+    .visibility{
+        border: 0;
+        margin: 0;
+        padding: 0;
+        min-width: 0;
+        display: flex;
+        flex-direction: column;
+        gap: 0.4rem;
+    }
+
+    .visibility legend{
+        float: left;
+        width: 100%;
+        padding: 0;
+        margin: 0;
+        color: var(--color-text);
+        font-size: 0.9rem;
+        font-weight: 600;
+    }
+
+    .visibilityOptions{
+        position: relative;
+        display: grid;
+        grid-template-columns: 1fr 1fr;
+        gap: 0;
+        width: 100%;
+        clear: both;
+        padding: 0.2rem;
+        background: var(--color-surface-alt);
+        border: 1px solid var(--color-border);
+        border-radius: 999px;
+        isolation: isolate;
+    }
+
+    .visibilityOptions::before{
+        content: "";
+        position: absolute;
+        top: 0.2rem;
+        left: 0.2rem;
+        width: calc(50% - 0.2rem);
+        height: calc(100% - 0.4rem);
+        background: var(--color-primary);
+        border-radius: 999px;
+        transition: transform 0.2s ease;
+        pointer-events: none;
+        z-index: 0;
+    }
+
+    .visibilityOptions:has(input[value="public"]:checked)::before{
+        transform: translateX(100%);
+    }
+
+    .visibilityOptions label{
+        position: relative;
+        z-index: 1;
+        flex-direction: row;
+        align-items: stretch;
+        justify-content: stretch;
+        gap: 0;
+        margin: 0;
+        cursor: pointer;
+    }
+
+    .visibilityOptions input{
+        position: absolute;
+        inset: 0;
+        width: 100%;
+        height: 100%;
+        margin: 0;
+        padding: 0;
+        opacity: 0;
+        border: 0;
+        background: none;
+        appearance: none;
+        cursor: pointer;
+    }
+
+    .visibilityOptions span{
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 100%;
+        min-height: 2.5rem;
+        padding: 0.45rem 0.8rem;
+        background: transparent;
+        border: 0;
+        border-radius: 999px;
+        color: var(--color-text-muted);
+        font-size: 0.95rem;
+        font-weight: 600;
+        line-height: 1.2;
+        text-align: center;
+        transition: color 0.15s ease;
+    }
+
+    .visibilityOptions input:hover + span{
+        color: var(--color-text);
+    }
+
+    .visibilityOptions input:checked + span{
+        color: var(--color-text-on-primary);
+    }
+
+    .visibilityOptions input:focus-visible + span{
+        outline: 2px solid var(--color-primary);
+        outline-offset: 2px;
+    }
+
+    @container recipe-form (min-width: 260px){
+        .ingredients{
+            grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+            align-items: end;
+        }
+
+        .ingredients h2,
+        .ingredients ul,
+        .ingredients label:last-of-type,
+        .ingredients .button{
+            grid-column: 1 / -1;
+        }
+    }
+
+    @container recipe-form (min-width: 480px){
+        .ingredients,
+        .steps{
+            padding: 1rem;
+        }
+
+        .ingredients{
+            grid-template-columns: minmax(0, 5.5rem) minmax(0, 6.5rem) minmax(0, 1fr) auto;
+            align-items: end;
+        }
+
+        .ingredients h2,
+        .ingredients ul{
+            grid-column: 1 / -1;
+        }
+
+        .ingredients label:last-of-type,
+        .ingredients .button{
+            grid-column: auto;
+        }
+
+        .ingredients .button{
+            width: auto;
+            min-height: 0;
+            height: fit-content;
+        }
+    }
+
+    @media (min-width: 640px){
+        .standardForm{
+            padding: 2rem;
+        }
+
+        .standardForm > textarea{
+            min-height: 6rem;
+        }
+    }
+
+    @media (max-height: 500px){
+        .standardForm > textarea{
+            min-height: 3.5rem;
+        }
+    }
+
+    @media (pointer: coarse){
+        .button,
+        .visibilityOptions span{
+            min-height: 44px;
+        }
+    }
+</style>

+ 23 - 0
src/routes/recipe/[recipeId]/+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};
+}

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

@@ -0,0 +1,275 @@
+<script>
+    let {data} = $props();
+</script>
+
+<div class="container">
+    {#if data.recipe}
+        <article>
+            <h1>{data.recipe.name}</h1>
+
+            <ul>
+                {#each data.recipe.ingredients as i}
+                    <li>{i.quantity} {i.unit} {i.name}</li>
+                {/each}
+            </ul>
+
+            <ol>
+                {#each data.recipe.steps as s}
+                    <li>{s}</li>
+                {/each}
+            </ol>
+
+            <p>{data.recipe.notes}</p>
+        </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}
+
+    <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>
+    .container{
+        width: min(720px, 100%);
+        min-height: calc(100% - 75px);
+        min-height: calc(100dvh - 75px);
+        margin: 0 auto;
+        padding: 1rem max(0.75rem, env(safe-area-inset-right, 0px)) max(2rem, env(safe-area-inset-bottom, 0px)) max(0.75rem, env(safe-area-inset-left, 0px));
+        color: var(--color-text);
+        overflow-x: clip;
+    }
+
+    .container:has(article){
+        display: flex;
+        flex-direction: column;
+        gap: 1rem;
+    }
+
+    .container:not(:has(article)){
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        width: 100%;
+    }
+
+    .buttonBox{
+        display: flex;
+        flex-wrap: wrap;
+        justify-content: flex-end;
+        gap: 0.5rem;
+    }
+
+    .buttonBox button{
+        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;
+        transition: background 0.15s ease, transform 0.05s ease;
+    }
+
+    .buttonBox button:hover{
+        background: var(--color-primary-hover);
+    }
+
+    .buttonBox button:active{
+        transform: translateY(1px);
+    }
+
+    article{
+        display: flex;
+        flex-direction: column;
+        gap: 1.15rem;
+        min-width: 0;
+        padding: 1.15rem 0.9rem 1.35rem;
+        background: var(--color-surface);
+        border: 1px solid var(--color-border);
+        border-radius: 10px;
+    }
+
+    article h1{
+        margin: 0;
+        color: var(--color-text);
+        font-size: clamp(1.4rem, 1.1rem + 1.6vw, 1.85rem);
+        font-weight: 700;
+        line-height: 1.25;
+        text-align: center;
+        overflow-wrap: anywhere;
+    }
+
+    article ul,
+    article ol,
+    article > p{
+        display: flex;
+        flex-direction: column;
+        gap: 0.45rem;
+        min-width: 0;
+        max-width: 100%;
+        margin: 0;
+        padding: 0.75rem;
+        list-style: none;
+        background: var(--color-surface-alt);
+        border: 1px solid var(--color-border);
+        border-radius: 8px;
+    }
+
+    article ul::before,
+    article ol::before,
+    article > p::before{
+        color: var(--color-text);
+        font-size: clamp(1rem, 0.9rem + 0.5vw, 1.1rem);
+        font-weight: 700;
+        letter-spacing: 0.01em;
+        margin-bottom: 0.2rem;
+    }
+
+    article ul::before{
+        content: "Ingredients";
+    }
+
+    article ol::before{
+        content: "Steps";
+    }
+
+    article > p::before{
+        content: "Notes";
+    }
+
+    article li{
+        padding: 0.55rem 0.7rem;
+        background: var(--color-surface);
+        border: 1px solid var(--color-border);
+        border-left: 3px solid var(--color-primary);
+        border-radius: 6px;
+        font-size: 0.95rem;
+        line-height: 1.4;
+        color: var(--color-text);
+        overflow-wrap: anywhere;
+        word-break: break-word;
+        min-width: 0;
+        max-width: 100%;
+    }
+
+    article ol{
+        counter-reset: step;
+    }
+
+    article ol li{
+        display: grid;
+        grid-template-columns: 1.5rem minmax(0, 1fr);
+        align-items: start;
+        gap: 0.7rem;
+        counter-increment: step;
+    }
+
+    article ol li::before{
+        content: counter(step);
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 1.5rem;
+        height: 1.5rem;
+        border-radius: 50%;
+        background: var(--color-primary);
+        color: var(--color-text-on-primary);
+        font-size: 0.75rem;
+        font-weight: 700;
+        line-height: 1;
+    }
+
+    article > p{
+        color: var(--color-text);
+        font-size: 0.95rem;
+        line-height: 1.5;
+        white-space: pre-wrap;
+        overflow-wrap: anywhere;
+    }
+
+    article > p:empty{
+        display: none;
+    }
+
+    .missing{
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        gap: 0.75rem;
+        width: min(420px, 100%);
+        padding: 2rem 1.5rem;
+        text-align: center;
+        background: var(--color-surface);
+        border: 1px solid var(--color-border);
+        border-radius: 10px;
+    }
+
+    .missing h1{
+        margin: 0;
+        color: var(--color-text);
+        font-size: clamp(1.25rem, 1rem + 1.4vw, 1.5rem);
+        font-weight: 700;
+        line-height: 1.3;
+    }
+
+    .missing p{
+        margin: 0 0 0.35rem;
+        color: var(--color-text-muted);
+        font-size: 0.95rem;
+        line-height: 1.45;
+    }
+
+    .missing .button{
+        margin: 0;
+    }
+
+    @media (min-width: 640px){
+        .container{
+            padding: 2rem 1rem 3rem;
+        }
+
+        article{
+            padding: 2rem;
+        }
+
+        article ul,
+        article ol,
+        article > p{
+            padding: 1rem;
+        }
+    }
+
+    @media (max-width: 480px){
+        .buttonBox{
+            flex-direction: column;
+        }
+
+        .buttonBox button{
+            width: 100%;
+        }
+    }
+
+    @media (pointer: coarse){
+        .buttonBox button,
+        .missing .button{
+            min-height: 44px;
+            display: inline-flex;
+            align-items: center;
+            justify-content: center;
+        }
+    }
+</style>

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

@@ -0,0 +1,22 @@
+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,
+            user: user._id
+        }, {"ingredients._id": 0}).lean();
+        
+        recipe.id = recipe._id.toString();
+        recipe._id = undefined;
+        recipe.user = undefined;
+
+        return recipe;
+    }catch{
+        return {recipe: null};
+    }
+}

+ 18 - 0
src/routes/recipe/[recipeId]/edit/+page.svelte

@@ -0,0 +1,18 @@
+<script>
+    import EditableRecipe from "../../EditableRecipe.svelte";
+
+    let {data} = $props();
+    console.log(data)
+</script>
+
+<div class="container">
+    <EditableRecipe
+        id={data.id}
+        name={data.name}
+        ingredients={data.ingredients}
+        steps={data.steps}
+        notes={data.notes}
+        visibility={data.private ? "private" : "public"}
+        route="/recipe/{data.id}/edit"
+    />
+</div>

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

@@ -0,0 +1,30 @@
+import auth from "$lib/server/auth.js";
+import Recipe from "$lib/server/models/Recipe.js";
+import {error, json} from "@sveltejs/kit";
+
+export async function POST({request, cookies, params}){
+    const user = await auth(cookies, false);
+    const body = await request.json();
+
+    if(!body.name) error(400, "Recipe name required");
+    if(body.ingredients.length <= 0) error(400, "Ingredients required");
+    if(body.steps.length <= 0) error(400, "Preparation steps required");
+
+    for(let i = 0; i < body.ingredients.length; i++){
+        if(body.ingredients[i].quantity <= 0) error(400, "Quantity must be greater than 0");
+        if(!body.ingredients[i].name) error(400, "Ingredient name required");
+    }
+
+    const recipe = await Recipe.findOne({_id: params.recipeId});
+
+    recipe.name = body.name;
+    recipe.ingredients = body.ingredients;
+    recipe.steps = body.steps;
+    recipe.notes = body.notes;
+    recipe.private = body.private;
+    recipe.updatedAt = new Date();
+
+    await recipe.save();
+
+    return json({msg: "success"}, {status: 200});
+}

+ 9 - 305
src/routes/recipe/new/+page.svelte

@@ -1,121 +1,16 @@
 <script>
-    import {notify} from "$lib/notifier.svelte.js";
-    import {goto} from "$app/navigation";
-
-    let nameInput, quantityInput, stepsInput;
-    let name = $state("");
-    let quantity = $state();
-    let unit = $state("");
-    let ingredientName = $state("");
-    let ingredients = $state([]);
-    let stepString = $state("");
-    let steps = $state([]);
-    let notes = $state("");
-
-    const submit = async (event)=>{
-        event.preventDefault();
-
-        if(!name){
-            notify("error", "Please enter recipe name");
-            nameInput.focus();
-            return;
-        }
-
-        const data = {name, ingredients, steps, notes};
-
-        const response = await fetch("/recipe/new", {
-            method: "POST",
-            headers: {"Content-Type": "application/json"},
-            credentials: "include",
-            body: JSON.stringify(data)
-        });
-
-        const recipe = await response.json();
-        if(!response.ok) return notify("error", recipe.message);
-
-        goto("/dashboard");
-    }
-
-    const addIngredient = ()=>{
-        if(quantity <= 0) return notify("error", "Quantity must be greater than 0");
-        if(!unit) return notify("error", "Unit is required");
-        if(!ingredientName) return notify("error", "Ingredient name is required");
-
-        ingredients.push({
-            quantity: quantity,
-            unit: unit,
-            name: ingredientName
-        });
-
-        quantity = null;
-        unit = "";
-        ingredientName = "";
-
-        quantityInput.focus();
-    }
-
-    const addStep = ()=>{
-        if(!stepString) return notify("error", "Please enter some text");
-        steps.push(stepString);
-        stepString = "";
-        stepsInput.focus();
-    }
-
-    $effect(()=>{
-        nameInput?.focus();
-    });
+    import EditableRecipe from "../EditableRecipe.svelte";
 </script>
 
 <div class="container">
-    <form class="standardForm" onsubmit={submit}>
-        <h1>New Recipe</h1>
-
-        <label>Name
-            <input type="text" bind:this={nameInput} bind:value={name}>
-        </label>
-
-        <div class="ingredients">
-            <h2>Ingredients</h2>
-
-            <ul>
-                {#each ingredients as i}
-                    <li>{i.name}, {i.quantity} {i.unit}</li>
-                {/each}
-            </ul>
-
-            <label>Quantity
-                <input type="number" min="0" bind:value={quantity} bind:this={quantityInput}>
-            </label>
-
-            <label>Unit
-                <input type="text" bind:value={unit}>
-            </label>
-
-            <label>Name
-                <input type="text" bind:value={ingredientName}>
-            </label>
-
-            <button class="button" type="button" onclick={addIngredient}>Add</button>
-        </div>
-
-        <div class="steps">
-            <h2>Steps</h2>
-
-            <ol>
-                {#each steps as s}
-                    <li>{s}</li>
-                {/each}
-            </ol>
-
-            <textarea bind:value={stepString} rows="2" bind:this={stepsInput}></textarea>
-
-            <button class="button" type="button" onclick={addStep}>Add</button>
-        </div>
-
-        <textarea bind:value={notes} rows="4" placeholder="Notes"></textarea>
-
-        <button>Create Recipe</button>
-    </form>
+    <EditableRecipe
+        name=""
+        ingredients={[]}
+        steps={[]}
+        notes=""
+        visibility="private"
+        route="/recipe/new"
+    />
 </div>
 
 <style>
@@ -133,190 +28,10 @@
         box-sizing: border-box;
     }
 
-    .standardForm{
-        width: min(640px, 100%);
-        max-width: 100%;
-        min-width: 0;
-        padding: 1.15rem 0.9rem;
-        container-type: inline-size;
-        container-name: recipe-form;
-    }
-
-    h1{
-        font-size: clamp(1.25rem, 1rem + 2vw, 1.5rem);
-    }
-
-    h2{
-        color: var(--color-text);
-        font-size: clamp(1rem, 0.9rem + 0.5vw, 1.1rem);
-        font-weight: 700;
-        letter-spacing: 0.01em;
-    }
-
-    input,
-    textarea{
-        width: 100%;
-        min-width: 0;
-        max-width: 100%;
-        font-size: 1rem;
-    }
-
-    .ingredients,
-    .steps{
-        display: flex;
-        flex-direction: column;
-        gap: 0.75rem;
-        padding: 0.75rem;
-        min-width: 0;
-        max-width: 100%;
-        background: var(--color-surface-alt);
-        border: 1px solid var(--color-border);
-        border-radius: 8px;
-    }
-
-    ul,
-    ol{
-        margin: 0;
-        padding: 0;
-        display: flex;
-        flex-direction: column;
-        gap: 0.45rem;
-        list-style: none;
-        min-width: 0;
-        max-width: 100%;
-    }
-
-    ul:not(:has(li)),
-    ol:not(:has(li)){
-        display: none;
-    }
-
-    li{
-        padding: 0.55rem 0.7rem;
-        background: var(--color-surface);
-        border: 1px solid var(--color-border);
-        border-left: 3px solid var(--color-primary);
-        border-radius: 6px;
-        font-size: 0.95rem;
-        line-height: 1.4;
-        color: var(--color-text);
-        overflow-wrap: anywhere;
-        word-break: break-word;
-        min-width: 0;
-        max-width: 100%;
-    }
-
-    ol{
-        counter-reset: step;
-    }
-
-    ol li{
-        display: grid;
-        grid-template-columns: 1.5rem minmax(0, 1fr);
-        align-items: start;
-        gap: 0.7rem;
-        counter-increment: step;
-    }
-
-    ol li::before{
-        content: counter(step);
-        flex-shrink: 0;
-        display: flex;
-        align-items: center;
-        justify-content: center;
-        width: 1.5rem;
-        height: 1.5rem;
-        border-radius: 50%;
-        background: var(--color-primary);
-        color: var(--color-text-on-primary);
-        font-size: 0.75rem;
-        font-weight: 700;
-        line-height: 1;
-    }
-
-    .ingredients{
-        display: grid;
-        grid-template-columns: minmax(0, 1fr);
-        align-items: stretch;
-        gap: 0.75rem;
-    }
-
-    .ingredients > *{
-        min-width: 0;
-        max-width: 100%;
-    }
-
-    .button{
-        margin: 0;
-        width: 100%;
-        height: auto;
-        min-height: 2.5rem;
-        white-space: nowrap;
-    }
-
-    textarea{
-        resize: vertical;
-        min-height: 3.5rem;
-    }
-
-    .standardForm > textarea{
-        min-height: 5rem;
-    }
-
-    @container recipe-form (min-width: 260px){
-        .ingredients{
-            grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
-            align-items: end;
-        }
-
-        .ingredients h2,
-        .ingredients ul,
-        .ingredients label:last-of-type,
-        .ingredients .button{
-            grid-column: 1 / -1;
-        }
-    }
-
-    @container recipe-form (min-width: 480px){
-        .ingredients,
-        .steps{
-            padding: 1rem;
-        }
-
-        .ingredients{
-            grid-template-columns: minmax(0, 5.5rem) minmax(0, 6.5rem) minmax(0, 1fr) auto;
-            align-items: end;
-        }
-
-        .ingredients h2,
-        .ingredients ul{
-            grid-column: 1 / -1;
-        }
-
-        .ingredients label:last-of-type,
-        .ingredients .button{
-            grid-column: auto;
-        }
-
-        .ingredients .button{
-            width: auto;
-            min-height: 0;
-            height: fit-content;
-        }
-    }
-
     @media (min-width: 640px){
         .container{
             padding: 2rem 1rem 3rem;
         }
-
-        .standardForm{
-            padding: 2rem;
-        }
-
-        .standardForm > textarea{
-            min-height: 6rem;
-        }
     }
 
     @media (max-height: 500px){
@@ -324,16 +39,5 @@
             padding-top: 0.75rem;
             padding-bottom: 1rem;
         }
-
-        .standardForm > textarea{
-            min-height: 3.5rem;
-        }
-    }
-
-    @media (pointer: coarse){
-        .button,
-        .standardForm button{
-            min-height: 44px;
-        }
     }
 </style>

+ 2 - 1
src/routes/recipe/new/+server.js

@@ -22,7 +22,8 @@ export async function POST({request, cookies}){
         steps: body.steps,
         notes: body.notes,
         createdAt: new Date(),
-        updatedAt: new Date()
+        updatedAt: new Date(),
+        private: body.private
     });
 
     await recipe.save();

+ 1 - 1
src/routes/register/+page.svelte

@@ -35,7 +35,7 @@
             <input name="confirmPassword" type="password" required>
         </label>
 
-        <button>Submit</button>
+        <button class="button">Submit</button>
     </form>
 </div>