Răsfoiți Sursa

Create search functionality

Lee Morgan 1 lună în urmă
părinte
comite
e71cde6ae5

+ 97 - 0
src/lib/Header.svelte

@@ -0,0 +1,97 @@
+<script>
+    import logo from "$lib/assets/favicon.svg";
+    import {page} from "$app/state";
+    import {getNotifications} from "$lib/notifier.svelte.js";
+
+    let {loggedIn} = $props();
+    let notifications = getNotifications();
+    let path = $derived(page.url.pathname);
+</script>
+
+{#if !path.endsWith("/print")}
+    <header>
+        <a class="logo" href="/">
+            <img src={logo} alt="Site logo">
+        </a>
+
+        {#if loggedIn && path !== "/dashboard"}
+            <a href="/dashboard" class="button">Dashboard</a>
+        {/if}
+
+        {#if path === "/dashboard"}
+            <a href="/search" class="button">Search</a>
+        {/if}
+
+        {#if loggedIn}
+            <a href="/logout" class="button">Sign Out</a>
+        {:else if path !== "/login"}
+            <a href="/login" class="button">Sign In</a>
+        {/if}
+
+        {#if path === "/login"}
+            <a href="/register" class="button">Sign Up</a>
+        {/if}
+
+        <div class="notifications">
+            {#each notifications as n}
+                <p class="{n.type}">{n.message}</p>
+            {/each}
+        </div>
+    </header>
+{/if}
+
+<style>
+    header{
+        display: flex;
+        align-items: center;
+        height: 75px;
+        width: 100%;
+    }
+
+    .logo{
+        height: 90%;
+        cursor: pointer;
+    }
+
+    .logo img{
+        height: 100%;
+        width: auto;
+    }
+
+    .notifications{
+        position: fixed;
+        top: max(1rem, env(safe-area-inset-top));
+        right: max(1rem, env(safe-area-inset-right));
+        z-index: 9999;
+        display: flex;
+        flex-direction: column;
+        gap: 0.5rem;
+        width: min(320px, calc(100vw - 2rem));
+        pointer-events: none;
+    }
+
+    .notifications p{
+        margin: 0;
+        padding: 0.75rem 1rem;
+        border-radius: 6px;
+        font-size: 0.9rem;
+        line-height: 1.3;
+        color: var(--color-text);
+        background: var(--color-surface-alt);
+        border: 1px solid var(--color-border);
+        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
+        pointer-events: auto;
+    }
+
+    .notifications p.success{
+        border-color: var(--color-success);
+    }
+
+    .notifications p.warning{
+        border-color: var(--color-warning);
+    }
+
+    .notifications p.error{
+        border-color: var(--color-error);
+    }
+</style>

+ 262 - 0
src/lib/IngredientSearch.svelte

@@ -0,0 +1,262 @@
+<script>
+    import {untrack} from "svelte";
+    import {goto} from "$app/navigation";
+    import {notify} from "$lib/notifier.svelte.js";
+
+    let {
+        startingIngredients,
+        autofocus = false,
+        compact = false
+    } = $props();
+
+    let nameInput;
+    let name = $state("");
+    let ingredients = $state(untrack(()=>startingIngredients ? [...startingIngredients] : []));
+
+    $effect(()=>{
+        if(!startingIngredients) return;
+        const next = [...startingIngredients];
+        untrack(()=>{
+            ingredients = next;
+        });
+    });
+
+    const addIngredient = (event)=>{
+        event.preventDefault();
+        const value = name.trim();
+        if(!value) return;
+
+        ingredients.push(value);
+        name = "";
+        nameInput?.focus();
+    };
+
+    const searchRecipes = ()=>{
+        const pending = name.trim();
+        const terms = pending ? [...ingredients, pending] : [...ingredients];
+        if(terms.length === 0){
+            notify("error", "Add at least one ingredient");
+            nameInput?.focus();
+            return;
+        }
+
+        if(pending){
+            ingredients.push(pending);
+            name = "";
+        }
+
+        const params = new URLSearchParams();
+        for(const term of terms) params.append("i", term);
+        goto(`/search?${params}`);
+    };
+
+    $effect(()=>{
+        if(autofocus) nameInput?.focus();
+    });
+</script>
+
+<form class="composer" class:compact onsubmit={addIngredient}>
+    {#if !compact}
+        <h1>Ingredients</h1>
+        <p class="hint">Add as many as you like</p>
+    {/if}
+
+    <div class="row">
+        <input
+            type="text"
+            placeholder="Type an ingredient"
+            aria-label="Ingredient name"
+            bind:this={nameInput}
+            bind:value={name}
+        >
+        <button class="add" type="submit">Add</button>
+    </div>
+
+    {#if ingredients.length}
+        <ul>
+            {#each ingredients as ingredient, idx}
+                <li>
+                    <button
+                        type="button"
+                        onclick={()=>ingredients.splice(idx, 1)}
+                        aria-label="Remove {ingredient}"
+                    >{ingredient}</button>
+                </li>
+            {/each}
+        </ul>
+    {/if}
+
+    <button class="search" type="button" onclick={searchRecipes}>
+        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
+            <path d="M17 17L21 21" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path>
+            <path d="M3 11C3 15.4183 6.58172 19 11 19C13.213 19 15.2161 18.1015 16.6644 16.6493C18.1077 15.2022 19 13.2053 19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11Z" stroke="currentColor" stroke-width="1.8"></path>
+        </svg>
+        Search recipes
+    </button>
+</form>
+
+<style>
+    .composer{
+        display: flex;
+        flex-direction: column;
+        gap: 0.75rem;
+        width: min(480px, 100%);
+        padding: 1.25rem;
+        background: var(--color-surface);
+        border: 1px solid var(--color-border);
+        border-radius: 12px;
+    }
+
+    .composer.compact{
+        width: 100%;
+        padding: 1rem;
+    }
+
+    h1{
+        margin: 0;
+        font-size: clamp(1.25rem, 1rem + 1.4vw, 1.6rem);
+        font-weight: 700;
+        line-height: 1.2;
+        text-align: center;
+        color: var(--color-text);
+    }
+
+    .hint{
+        margin: 0;
+        text-align: center;
+        color: var(--color-text-muted);
+        font-size: 0.95rem;
+        line-height: 1.35;
+    }
+
+    .row{
+        display: flex;
+        align-items: stretch;
+        gap: 0.5rem;
+        min-width: 0;
+    }
+
+    input{
+        flex: 1;
+        min-width: 0;
+        border: 1px solid var(--color-border);
+        outline: none;
+        background: var(--color-surface-alt);
+        color: var(--color-text);
+        border-radius: 8px;
+        padding: 0.7rem 0.9rem;
+        font-size: 1rem;
+        font-family: inherit;
+    }
+
+    input:focus{
+        border-color: var(--color-primary);
+    }
+
+    .add{
+        flex-shrink: 0;
+        margin: 0;
+        border: 1px solid var(--color-border);
+        outline: none;
+        cursor: pointer;
+        background: var(--color-surface-alt);
+        color: var(--color-text);
+        border-radius: 8px;
+        padding: 0.7rem 1.1rem;
+        font-weight: 600;
+        font-size: 1rem;
+        font-family: inherit;
+        line-height: 1.2;
+    }
+
+    .add:hover{
+        border-color: var(--color-primary);
+        color: var(--color-primary);
+    }
+
+    .add:active{
+        transform: translateY(1px);
+    }
+
+    .search{
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        align-self: center;
+        gap: 0.45rem;
+        margin: 0.5rem 0 0;
+        border: none;
+        outline: none;
+        cursor: pointer;
+        background: var(--color-primary);
+        color: var(--color-text-on-primary);
+        border-radius: 999px;
+        padding: 0.7rem 1.35rem;
+        font-weight: 600;
+        font-size: 1rem;
+        font-family: inherit;
+        line-height: 1;
+        white-space: nowrap;
+        transition: background 0.15s ease, transform 0.05s ease;
+    }
+
+    .search:hover{
+        background: var(--color-primary-hover);
+    }
+
+    .search:active{
+        transform: translateY(1px);
+    }
+
+    .search svg{
+        display: block;
+        flex-shrink: 0;
+    }
+
+    ul{
+        display: flex;
+        flex-wrap: wrap;
+        gap: 0.45rem;
+        margin: 0.25rem 0 0;
+        padding: 0;
+        list-style: none;
+    }
+
+    li button{
+        display: inline-flex;
+        align-items: center;
+        max-width: 100%;
+        margin: 0;
+        border: 1px solid var(--color-border);
+        outline: none;
+        cursor: pointer;
+        background: var(--color-surface-alt);
+        color: var(--color-text);
+        border-radius: 999px;
+        padding: 0.4rem 0.8rem;
+        font-size: 0.95rem;
+        font-family: inherit;
+        line-height: 1.3;
+        overflow-wrap: anywhere;
+        word-break: break-word;
+    }
+
+    li button:hover{
+        border-color: var(--color-primary);
+    }
+
+    @media (min-width: 640px){
+        .composer:not(.compact){
+            padding: 1.5rem;
+        }
+    }
+
+    @media (pointer: coarse){
+        .add,
+        .search,
+        input,
+        li button{
+            min-height: 44px;
+        }
+    }
+</style>

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

@@ -1,13 +1,10 @@
 <script>
 <script>
     import "$lib/global.css";
     import "$lib/global.css";
-	import logo from '$lib/assets/favicon.svg';
-    import {page} from "$app/state";
-    import {getNotifications} from "$lib/notifier.svelte.js";
+    import Header from "$lib/Header.svelte";
 	import { pwaInfo } from 'virtual:pwa-info';
 	import { pwaInfo } from 'virtual:pwa-info';
 	import { initPwaInstall } from '$lib/pwaInstall.svelte.js';
 	import { initPwaInstall } from '$lib/pwaInstall.svelte.js';
 
 
 	let {data, children} = $props();
 	let {data, children} = $props();
-    let notifications = getNotifications();
 	let webManifest = $derived(pwaInfo ? pwaInfo.webManifest.linkTag : '');
 	let webManifest = $derived(pwaInfo ? pwaInfo.webManifest.linkTag : '');
 
 
 	$effect(() => {
 	$effect(() => {
@@ -33,95 +30,6 @@
 	{/if}
 	{/if}
 </svelte:head>
 </svelte:head>
 
 
-{#if !page.url.pathname.endsWith("/print")}
-<header>
-    <a class="logo" href="/">
-        <img src={logo} alt="Site logo">
-    </a>
-
-    {#if data.loggedIn && page.url.pathname !== "/dashboard"}
-        <a href="/dashboard" class="button">Dashboard</a>
-    {/if}
-
-    {#if page.url.pathname === "/"}
-        {#if !data.loggedIn}
-            <a href="/login" class="button">Sign In</a>
-            <a href="/register" class="button">Sign Up</a>
-        {:else}
-            <a href="logout" class="button">Log Out</a>
-        {/if}
-    {/if}
-
-    {#if page.url.pathname === "/register"}
-        <a href="/login" class="button">Sign In</a>
-    {/if}
-
-    {#if page.url.pathname === "/login"}
-        <a href="/register" class="button">Sign Up</a>
-    {/if}
-
-    <div class="notifications">
-        {#each notifications as n}
-            <p class="{n.type}">{n.message}</p>
-        {/each}
-    </div>
-</header>
-{/if}
+<Header loggedIn={data.loggedIn} />
 
 
 {@render children()}
 {@render children()}
-
-<style>
-    header{
-        display: flex;
-        align-items: center;
-        height: 75px;
-        width: 100%;
-    }
-
-    .logo{
-        height: 90%;
-        cursor: pointer;
-    }
-
-    .logo img{
-        height: 100%;
-        width: auto;
-    }
-
-    .notifications{
-        position: fixed;
-        top: max(1rem, env(safe-area-inset-top));
-        right: max(1rem, env(safe-area-inset-right));
-        z-index: 9999;
-        display: flex;
-        flex-direction: column;
-        gap: 0.5rem;
-        width: min(320px, calc(100vw - 2rem));
-        pointer-events: none;
-    }
-
-    .notifications p{
-        margin: 0;
-        padding: 0.75rem 1rem;
-        border-radius: 6px;
-        font-size: 0.9rem;
-        line-height: 1.3;
-        color: var(--color-text);
-        background: var(--color-surface-alt);
-        border: 1px solid var(--color-border);
-        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
-        pointer-events: auto;
-    }
-
-    .notifications p.success{
-        border-color: var(--color-success);
-    }
-
-    .notifications p.warning{
-        border-color: var(--color-warning);
-    }
-
-    .notifications p.error{
-        border-color: var(--color-error);
-    }
-</style>

+ 10 - 55
src/routes/+page.svelte

@@ -1,65 +1,20 @@
 <script>
 <script>
-    let searchInput;
-
-    $effect(()=>{
-        searchInput?.focus();
-    });
+    import IngredientSearch from "$lib/IngredientSearch.svelte";
 </script>
 </script>
 
 
-<form>
-    <input type="text" placeholder="Search Recipes" bind:this={searchInput} required>
-
-    <button type="submit" aria-label="Search recipes">
-        <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" color="#000000" aria-hidden="true">
-            <path d="M17 17L21 21" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
-            <path d="M3 11C3 15.4183 6.58172 19 11 19C13.213 19 15.2161 18.1015 16.6644 16.6493C18.1077 15.2022 19 13.2053 19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11Z" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
-        </svg>
-    </button>
-</form>
+<div class="container">
+    <IngredientSearch autofocus />
+</div>
 
 
 <style>
 <style>
-    form{
+    .container{
         display: flex;
         display: flex;
-        align-items: center;
         justify-content: center;
         justify-content: center;
-        gap: 0.5rem;
-        width: min(480px, 90vw);
-        margin: 40vh auto 0;
-        padding: 0.5rem 0.5rem 0.5rem 1.25rem;
-        background: #ffffff;
-        border-radius: 999px;
-        box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
-    }
-
-    input[type="text"]{
-        flex: 1;
-        border: none;
-        outline: none;
-        background: none;
-        font-size: 1rem;
-        color: #000000;
-    }
-
-    button{
-        display: flex;
         align-items: center;
         align-items: center;
-        justify-content: center;
-        width: 2.5rem;
-        height: 2.5rem;
-        background: #000000;
-        border: none;
-        border-radius: 50%;
-        cursor: pointer;
-        flex-shrink: 0;
-        transition: background 0.15s ease, transform 0.1s ease;
-    }
-
-    button:hover{
-        background: #333333;
-    }
-
-    button:active{
-        background: #555555;
-        transform: scale(0.92);
+        width: 100%;
+        min-height: 100dvh;
+        margin-top: -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));
+        box-sizing: border-box;
     }
     }
 </style>
 </style>

+ 54 - 0
src/routes/search/+page.server.js

@@ -0,0 +1,54 @@
+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";
+
+function textQuery(ingredients){
+    return ingredients
+        .map((ingredient)=>String(ingredient).trim().replace(/["\\]/g, " ").replace(/\s+/g, " ").trim())
+        .filter(Boolean)
+        .map((ingredient)=>`"${ingredient}"`)
+        .join(" ");
+}
+
+export async function load({cookies, url}){
+    const ingredients = url.searchParams.getAll("i").map((value)=>value.trim()).filter(Boolean);
+
+    let user = null;
+    try{
+        const userData = jwt.verify(cookies.get("user"), JWT_SECRET);
+        user = await User.findOne({_id: userData.id, token: userData.token});
+    }catch{}
+
+    if(ingredients.length === 0){
+        return {ingredients, recipes: []};
+    }
+
+    const query = textQuery(ingredients);
+    if(!query){
+        return {ingredients, recipes: []};
+    }
+
+    const visibility = user
+        ? {$or: [{private: false}, {user: user._id}]}
+        : {private: false};
+
+    let recipes = [];
+    try{
+        recipes = await Recipe.aggregate([
+            {$match: {$text: {$search: query}, ...visibility}},
+            {$addFields: {score: {$meta: "textScore"}}},
+            {$sort: {score: -1}},
+            {$project: {
+                _id: 0,
+                id: {$toString: "$_id"},
+                name: 1,
+                updatedAt: 1
+            }}
+        ]);
+    }catch{
+        recipes = [];
+    }
+
+    return {ingredients, recipes};
+}

+ 126 - 0
src/routes/search/+page.svelte

@@ -0,0 +1,126 @@
+<script>
+    import IngredientSearch from "$lib/IngredientSearch.svelte";
+
+    let {data} = $props();
+</script>
+
+<div class="container">
+    <div class="topbar">
+        <h1>Search results</h1>
+    </div>
+
+    <IngredientSearch startingIngredients={data.ingredients} compact />
+
+    <div class="recipes">
+        {#each data.recipes as r}
+            <a href="/recipe/{r.id}" class="recipe">
+                <p class="name">{r.name}</p>
+                <p class="date">{r.updatedAt ? new Date(r.updatedAt).toLocaleDateString() : ""}</p>
+            </a>
+        {:else}
+            <p class="empty">
+                {#if data.ingredients.length === 0}
+                    Add at least one ingredient to search.
+                {:else}
+                    No recipes match those ingredients.
+                {/if}
+            </p>
+        {/each}
+    </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;
+    }
+
+    .topbar{
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        gap: 0.75rem 1rem;
+        margin-bottom: 1rem;
+    }
+
+    h1{
+        font-size: clamp(1.35rem, 1rem + 1.6vw, 1.75rem);
+        font-weight: 700;
+        line-height: 1.2;
+        min-width: 0;
+    }
+
+    .recipes{
+        margin-top: 1rem;
+        display: flex;
+        flex-direction: column;
+        gap: 0.5rem;
+    }
+
+    .recipe{
+        display: flex;
+        flex-direction: row;
+        align-items: baseline;
+        justify-content: space-between;
+        gap: 0.75rem 1.5rem;
+        min-width: 0;
+        padding: 0.85rem 1rem;
+        background: var(--color-surface);
+        border: 1px solid var(--color-border);
+        border-left: 3px solid var(--color-primary);
+        border-radius: 10px;
+        text-decoration: none;
+        color: var(--color-text);
+    }
+
+    .name{
+        flex: 1;
+        min-width: 0;
+        font-size: 1.05rem;
+        font-weight: 600;
+        line-height: 1.35;
+        overflow-wrap: anywhere;
+    }
+
+    .date{
+        flex-shrink: 0;
+        color: var(--color-text-muted);
+        font-size: 0.85rem;
+        line-height: 1.3;
+        white-space: nowrap;
+        text-align: right;
+    }
+
+    .date:empty{
+        display: none;
+    }
+
+    .empty{
+        margin: 0;
+        padding: 2.5rem 1.25rem;
+        text-align: center;
+        color: var(--color-text-muted);
+        background: var(--color-surface);
+        border: 1px dashed var(--color-border);
+        border-radius: 10px;
+        font-size: 0.95rem;
+        line-height: 1.45;
+    }
+
+    @media (min-width: 560px){
+        .container{
+            padding: 1.5rem 1rem 3rem;
+        }
+    }
+
+    @media (min-width: 900px){
+        .container{
+            padding-top: 2rem;
+        }
+    }
+</style>