Compare commits

...

21 commits

Author SHA1 Message Date
83cc273e52
chore: update deps 2025-11-29 08:54:12 +01:00
4ab8d98e2c
refactor: remove unused SentryErrorButton component and adjust related files
- Deleted the SentryErrorButton component as it was not needed.
- Updated the cookbook page to remove the SentryErrorButton reference.
- Adjusted the search component to fix a self-closing tag issue.
- Ensured the toggleLike function call is correctly formatted in the view component.
- Added sendDefaultPii option to Sentry configuration for improved error tracking.
2025-05-26 15:53:09 +02:00
bfd83a367b
feat: add Sentry error tracking integration
- Introduced SentryErrorButton component to demonstrate error tracking.
- Updated nuxt.config.ts to include Sentry module and configuration.
- Added Sentry client and server configuration files for error reporting.
- Updated package.json to include @sentry/nuxt and updated dependencies.
- Integrated SentryErrorButton component into the cookbook page.
2025-05-26 15:38:15 +02:00
3e34609fcf
add cookbook 2025-04-13 01:08:22 +02:00
35af888724
add cookbook page 2025-04-13 00:06:19 +02:00
fc022e024d
fix meal validation 2025-04-09 09:14:54 +02:00
29db641392
update dependencies 2025-01-02 19:01:34 +01:00
b7aca38912
13 sharing recipes (#49)
* fix: update description image

* feat: share using navigator api
2024-12-19 19:18:22 +01:00
3a66b00c74
refactor: consolidate the routers 2024-12-19 13:43:09 +01:00
32be4bb0df
fix landing page 2024-12-17 21:31:05 +01:00
0d93ff37e4
improve footer responsiveness 2024-12-17 21:28:52 +01:00
7decbe7cbe
autofocus search field on search page 2024-12-17 21:26:39 +01:00
5bd9f2c382
add search bar to search page and improve searhc on input hcange 2024-12-17 21:12:46 +01:00
b19cb02763
use icon instead of svg 2024-12-16 22:10:42 +01:00
a1bc45941d
ref: make the navbar responsive on small screens 2024-12-16 21:42:38 +01:00
534a98b36f
feat: Add mobile search icon and responsive search bar in navbar 2024-12-16 21:38:08 +01:00
55d857e658
ref: use slots to prevent repeating menu 2024-12-16 21:33:55 +01:00
7c4f6419cd
add explicit image dimensions 2024-12-16 21:26:36 +01:00
34a1f62813
improve accessibility 2024-12-16 21:26:36 +01:00
4329d61c43
image optimization 2024-12-15 15:38:14 +01:00
c756a129a2
fix: recipe links 2024-12-15 14:44:29 +01:00
29 changed files with 3798 additions and 9253 deletions

View file

@ -1 +1,2 @@
NUXT_API_URL=https://www.themealdb.com/api/json/v1/1/ NUXT_API_URL=https://www.themealdb.com/api/json/v1/1/
NUXT_PUBLIC_SENTRY_DSN=

2
.gitignore vendored
View file

@ -23,3 +23,5 @@ logs
.env.* .env.*
!.env.example !.env.example
.aider* .aider*
# Sentry Config File
.env.sentry-build-plugin

View file

@ -1,6 +1,6 @@
# Chef's Meal Planner # Chef's Meal Planner
![header image](https://socialify.git.ci/rjnemo/meal_planner/image?description=1&font=Raleway&language=1&logo=https%3A%2F%2Fchefs-meal-planner.onrender.com%2Flogo192.png&owner=1&pattern=Diagonal%20Stripes&stargazers=1&theme=Dark) ![header image](https://socialify.git.ci/rjnemo/meal_planner/image?description=1&font=Raleway&language=1&logo=https%3A%2F%2Fmood2food.netlify.app%2Flogo192.png&owner=1&pattern=Diagonal%20Stripes&stargazers=1&theme=Dark)
![license](https://img.shields.io/github/license/rjNemo/meal_planner?style=for-the-badge) ![license](https://img.shields.io/github/license/rjNemo/meal_planner?style=for-the-badge)
![release tag](https://img.shields.io/github/v/release/rjNemo/meal_planner?style=for-the-badge) ![release tag](https://img.shields.io/github/v/release/rjNemo/meal_planner?style=for-the-badge)
@ -95,8 +95,11 @@ address: [link](https://mood2food.netlify.app/).
## Built With ## Built With
- [Nuxt](https://nuxt.com/) - The Intuitive Vue Framework - [Nuxt](https://nuxt.com/) - The Intuitive Vue Framework
- [Tailwindcss](https://tailwindcss.com) -Rapidly build modern websites without - [tRPC](https://trpc.io/) - End-to-end typesafe APIs made easy
- [Tailwindcss](https://tailwindcss.com) - Rapidly build modern websites without
ever leaving your HTML. ever leaving your HTML.
- [daisyUI](https://daisyui.com/) - The most popular component library for
Tailwind CSS
- [TheMealDb](https://www.themealdb.com/api.php) - An open, crowd-sourced database - [TheMealDb](https://www.themealdb.com/api.php) - An open, crowd-sourced database
of Recipes from around the world of Recipes from around the world

View file

@ -1,6 +1,11 @@
<template> <template>
<div data-theme="cupcake" class="flex flex-col h-screen"> <div data-theme="cupcake" class="flex flex-col h-screen">
<app-navbar /> <app-navbar>
<template #menu>
<li><nuxt-link to="/categories">Categories</nuxt-link></li>
<li><nuxt-link to="/cookbook">Cookbook</nuxt-link></li>
</template>
</app-navbar>
<main class="flex-grow"> <main class="flex-grow">
<nuxt-page /> <nuxt-page />
</main> </main>

3450
bun.lock Normal file

File diff suppressed because it is too large Load diff

BIN
bun.lockb

Binary file not shown.

View file

@ -1,28 +0,0 @@
<script setup lang="ts">
import type { Category } from "~/types/category";
defineProps<{
category: Category;
}>();
</script>
<template>
<nuxt-link
:to="`/category/${category.name}`"
class="block max-w-sm rounded-lg border border-gray-200 bg-white p-6 shadow hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700"
>
<img
:src="category.picture"
:alt="category.name"
class="mb-4 h-48 w-full object-cover rounded"
/>
<h5
class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white"
>
{{ category.name }}
</h5>
<p class="font-normal text-gray-700 dark:text-gray-400">
{{ category.description }}
</p>
</nuxt-link>
</template>

View file

@ -1,42 +1,31 @@
<template> <template>
<footer class="footer bg-base-300 text-base-content items-center p-4"> <footer
<aside class="grid-flow-col items-center"> class="footer bg-base-300 text-base-content items-center p-4 flex justify-between"
<p>Copyright &copy; {{ new Date().getFullYear() }} Made with </p> >
<aside class="items-center">
<p>
<span class="hidden sm:inline"
>Copyright &copy; {{ new Date().getFullYear() }}
</span>
Made with
</p>
</aside> </aside>
<nav class="grid-flow-col gap-4 md:place-self-center md:justify-self-end"> <nav class="grid-flow-col gap-4">
<nuxt-link <nuxt-link
to="https://github.com/rjNemo/meal_planner" to="https://github.com/rjNemo/meal_planner"
:external="true" :external="true"
target="_blank" target="_blank"
aria-label="navigate to the source code on GitHub"
> >
<svg <icon name="cib:github" class="w-6 h-6" />
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
class="fill-current"
>
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
</svg>
</nuxt-link> </nuxt-link>
<nuxt-link <nuxt-link
to="https://ruidy.nemausat.com" to="https://ruidy.nemausat.com"
:external="true" :external="true"
target="_blank" target="_blank"
aria-label="navigate to my website"
> >
<svg <icon name="uil:globe" class="w-6 h-6" />
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
class="fill-current"
>
<path
d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm1 16.057v-3.057h2.994c-.059 1.143-.212 2.24-.456 3.279-.823-.12-1.674-.188-2.538-.222zm1.957 2.162c-.499 1.33-1.159 2.497-1.957 3.456v-3.62c.666.028 1.319.081 1.957.164zm-1.957-7.219v-3.015c.868-.034 1.721-.103 2.548-.224.238 1.027.389 2.111.446 3.239h-2.994zm0-5.014v-3.661c.806.969 1.471 2.15 1.971 3.496-.642.084-1.3.137-1.971.165zm2.703-3.267c1.237.496 2.354 1.228 3.29 2.146-.642.234-1.311.442-2.019.607-.344-.992-.775-1.91-1.271-2.753zm-7.241 13.56c-.244-1.039-.398-2.136-.456-3.279h2.994v3.057c-.865.034-1.714.102-2.538.222zm2.538 1.776v3.62c-.798-.959-1.458-2.126-1.957-3.456.638-.083 1.291-.136 1.957-.164zm-2.994-7.055c.057-1.128.207-2.212.446-3.239.827.121 1.68.19 2.548.224v3.015h-2.994zm1.024-5.179c.5-1.346 1.165-2.527 1.97-3.496v3.661c-.671-.028-1.329-.081-1.97-.165zm-2.005-.35c-.708-.165-1.377-.373-2.018-.607.937-.918 2.053-1.65 3.29-2.146-.496.844-.927 1.762-1.272 2.753zm-.549 1.918c-.264 1.151-.434 2.36-.492 3.611h-3.933c.165-1.658.739-3.197 1.617-4.518.88.361 1.816.67 2.808.907zm.009 9.262c-.988.236-1.92.542-2.797.9-.89-1.328-1.471-2.879-1.637-4.551h3.934c.058 1.265.231 2.488.5 3.651zm.553 1.917c.342.976.768 1.881 1.257 2.712-1.223-.49-2.326-1.211-3.256-2.115.636-.229 1.299-.435 1.999-.597zm9.924 0c.7.163 1.362.367 1.999.597-.931.903-2.034 1.625-3.257 2.116.489-.832.915-1.737 1.258-2.713zm.553-1.917c.27-1.163.442-2.386.501-3.651h3.934c-.167 1.672-.748 3.223-1.638 4.551-.877-.358-1.81-.664-2.797-.9zm.501-5.651c-.058-1.251-.229-2.46-.492-3.611.992-.237 1.929-.546 2.809-.907.877 1.321 1.451 2.86 1.616 4.518h-3.933z"
/>
</svg>
</nuxt-link> </nuxt-link>
</nav> </nav>
</footer> </footer>

View file

@ -11,72 +11,49 @@ const handleRandomClick = async () => {
} }
await execute(); await execute();
}; };
const debouncedSearch = useDebounceFn(async (query: string) => {
if (searchQuery.value.trim()) {
router.push({
path: "/search",
query: { q: query.trim() },
});
}
}, 500);
const handleSubmit = () => {
if (searchQuery.value.trim()) {
router.push({
path: "/search",
query: { q: searchQuery.value.trim() },
});
}
};
if (route.path === "/search") {
// Watch for changes in searchQuery
watch(searchQuery, (newQuery) => {
debouncedSearch(newQuery);
});
}
</script> </script>
<template> <template>
<nav class="navbar bg-base-300"> <nav class="navbar bg-base-300">
<div class="navbar-start"> <div class="navbar-start">
<div class="dropdown"> <div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden"> <div
<svg tabindex="0"
xmlns="http://www.w3.org/2000/svg" role="button"
class="h-5 w-5" class="btn btn-ghost lg:hidden"
fill="none" arial-label="Menu button"
viewBox="0 0 24 24" >
stroke="currentColor" <icon name="uil:bars" class="w-6 h-6" />
> <!-- TODO: add transition into cross on click -->
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h8m-8 6h16"
/>
</svg>
</div> </div>
<ul <ul
tabindex="0" tabindex="0"
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-200 rounded-box w-52" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-200 rounded-box w-52"
> >
<li><nuxt-link to="/categories">Categories</nuxt-link></li> <slot name="menu" />
</ul> </ul>
</div> </div>
<nuxt-link to="/" class="btn btn-ghost text-xl"> <nuxt-link to="/" class="btn btn-ghost text-xl">
<NuxtImg src="/logo192.png" width="50" /> <nuxt-img src="/logo192.png" width="50" height="50" alt="logo" />
<span style="font-family: cursive"> Chefs </span> <span style="font-family: cursive"> Chefs </span>
</nuxt-link> </nuxt-link>
</div> </div>
<div class="navbar-center hidden lg:flex"> <div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1"> <ul class="menu menu-horizontal px-1">
<li><nuxt-link to="/categories">Categories</nuxt-link></li> <slot name="menu" />
</ul> </ul>
</div> </div>
<div class="navbar-end gap-2"> <div class="navbar-end gap-2">
<recipe-search v-model="searchQuery" @search="handleSubmit" /> <!-- Search icon for mobile -->
<nuxt-link
to="/search"
class="btn btn-ghost sm:hidden"
aria-label="Search"
>
<icon name="uil:search" class="w-6 h-6" />
</nuxt-link>
<!-- Search bar for larger screens -->
<recipe-search v-model="searchQuery" class="hidden sm:flex" />
<button class="btn btn-primary" @click="handleRandomClick">Random</button> <button class="btn btn-primary" @click="handleRandomClick">Random</button>
</div> </div>
</nav> </nav>

View file

@ -12,10 +12,21 @@ defineProps<{
<div class="card-body items-center text-center bg-base-200"> <div class="card-body items-center text-center bg-base-200">
<h2 class="card-title">{{ title }}</h2> <h2 class="card-title">{{ title }}</h2>
<figure class="px-10 py-5"> <figure class="px-10 py-5">
<nuxt-img :src="pictureUrl" alt="Recipe picture" /> <nuxt-img
:src="pictureUrl"
alt="`${title} picture`"
:placeholder="[300]"
width="300"
height="300"
format="webp"
/>
</figure> </figure>
<div class="card-actions space-between"> <div class="card-actions space-between">
<nuxt-link :to="videoUrl" target="_blank"> <nuxt-link
:to="videoUrl"
target="_blank"
aria-label="watch the recipe in video"
>
<icon name="cib:youtube" color="red" /> <icon name="cib:youtube" color="red" />
</nuxt-link> </nuxt-link>
<div class="badge badge-secondary"> <div class="badge badge-secondary">

View file

@ -7,21 +7,39 @@
type="text" type="text"
class="grow" class="grow"
placeholder="Search recipes..." placeholder="Search recipes..."
:autofocus="autofocus"
@focus="isFocused = true" @focus="isFocused = true"
@blur="isFocused = false" @blur="isFocused = false"
@keydown.enter="$emit('search')"
> >
<kbd class="kbd kbd-sm" :class="{ 'opacity-50': !isFocused }"></kbd> <kbd
<kbd class="kbd kbd-sm" :class="{ 'opacity-50': !isFocused }">K</kbd> class="hidden md:inline-block kbd kbd-sm"
:class="{ 'opacity-50': !isFocused }"
></kbd
>
<kbd
class="hidden md:inline-block kbd kbd-sm"
:class="{ 'opacity-50': !isFocused }"
>K</kbd
>
</label> </label>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineEmits(["search"]);
const model = defineModel<string>(); const model = defineModel<string>();
defineProps<{ autofocus?: boolean }>();
const isFocused = ref(false); const isFocused = ref(false);
// Debounced navigation
const debouncedSearch = useDebounceFn((query: string) => {
navigateTo(`/search?q=${encodeURIComponent(query || "")}`);
}, 200);
// Watch for changes in model
watch(model, (newQuery) => {
debouncedSearch(newQuery || "");
});
onMounted(() => { onMounted(() => {
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") { if ((e.metaKey || e.ctrlKey) && e.key === "k") {

View file

@ -1,7 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Recipe } from "~/types/recipe"; import type { Recipe } from "~/types/recipe";
defineProps<{ recipe: Recipe }>(); import { useLocalStorage } from "@vueuse/core";
const { recipe } = defineProps<{ recipe: Recipe }>();
const cookbook = useLocalStorage<Recipe[]>("cookbook", []);
const likedRecipes = ref(new Set<string>());
onMounted(() => {
likedRecipes.value = new Set(cookbook.value.map((recipe) => recipe.id));
console.log("cook", likedRecipes.value);
});
const toggleLike = (recipeId: string) => {
if (likedRecipes.value.has(recipeId)) {
likedRecipes.value.delete(recipeId);
cookbook.value = cookbook.value.filter((recipe) => recipe.id !== recipeId);
} else {
likedRecipes.value.add(recipeId);
const recipeToAdd = recipe;
if (recipeToAdd) {
cookbook.value = [...cookbook.value, recipeToAdd];
}
}
};
const shareRecipe = async (recipe: Recipe) => {
const url =
useRequestURL().href.split("/").slice(0, -1).join("/") + "/" + recipe.id;
if (navigator.share) {
try {
await navigator.share({
title: recipe.title,
text: `Check out this recipe: ${recipe.title}`,
url,
});
} catch (error) {
console.error(error);
alert("Failed to share the recipe.");
}
} else {
alert("Sharing not supported on this device.");
}
};
</script> </script>
<template> <template>
@ -29,6 +71,23 @@ defineProps<{ recipe: Recipe }>();
<p class="prose prose-lg max-w-none w-full"> <p class="prose prose-lg max-w-none w-full">
{{ recipe.instructions }} {{ recipe.instructions }}
</p> </p>
<div class="flex gap-4 mt-4">
<button class="btn btn-accent" @click="shareRecipe(recipe)">
<icon name="uil:share-alt" class="mr-2 w-6 h-6" />
Share Recipe
</button>
<button
class="btn btn-ghost"
:class="{ 'text-red-500': likedRecipes.has(recipe.id) }"
@click="toggleLike(recipe.id)"
>
<icon
:name="likedRecipes.has(recipe.id) ? 'uil:heart' : 'uil:heart-alt'"
class="w-6 h-6"
/>
Like
</button>
</div>
</div> </div>
</div> </div>
</template> </template>

View file

@ -5,20 +5,7 @@
<div class="card-body"> <div class="card-body">
<!-- Error Icon --> <!-- Error Icon -->
<div class="text-error text-6xl mb-4"> <div class="text-error text-6xl mb-4">
<svg <icon name="uil:exclamation-triangle" class="w-16 h-16" />
xmlns="http://www.w3.org/2000/svg"
class="mx-auto h-24 w-24"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div> </div>
<!-- Error Details --> <!-- Error Details -->

View file

@ -5,15 +5,17 @@ export default defineNuxtConfig({
modules: [ modules: [
"@nuxt/eslint", "@nuxt/eslint",
"@nuxt/image", "@nuxt/image",
"nuxt-icon",
"nuxt-delay-hydration",
"@nuxtjs/robots", "@nuxtjs/robots",
"@sentry/nuxt/module",
"@vueuse/nuxt", "@vueuse/nuxt",
"nuxt-delay-hydration",
"nuxt-icon",
], ],
app: { app: {
head: { head: {
title: "Meal Planner", title: "Mood2Food",
htmlAttrs: { lang: "en" },
meta: [ meta: [
{ charset: "utf-8" }, { charset: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" }, { name: "viewport", content: "width=device-width, initial-scale=1" },
@ -23,7 +25,7 @@ export default defineNuxtConfig({
content: "Meal Planner", content: "Meal Planner",
}, },
], ],
link: [{ rel: "icon", type: "image/png", href: "/favicon.png" }], link: [{ rel: "icon", type: "image/x-icon", href: "/favicon.ico" }],
}, },
pageTransition: { name: "page", mode: "out-in" }, pageTransition: { name: "page", mode: "out-in" },
layoutTransition: { name: "slide", mode: "out-in" }, layoutTransition: { name: "slide", mode: "out-in" },
@ -56,8 +58,26 @@ export default defineNuxtConfig({
// The private keys which are only available server-side // The private keys which are only available server-side
apiUrl: "", apiUrl: "",
// Keys within public are also exposed client-side // Keys within public are also exposed client-side
public: {
sentry: {
dsn: "",
},
},
}, },
ssr: true, ssr: true,
compatibilityDate: "2024-12-13", compatibilityDate: "2024-12-13",
});
sentry: {
sourceMapsUploadOptions: {
org: "ruidy",
project: "meal-planner",
},
autoInjectServerSentry: "top-level-import",
},
sourcemap: {
client: "hidden",
},
});

View file

@ -13,27 +13,31 @@
"lint:fix": "eslint . --fix" "lint:fix": "eslint . --fix"
}, },
"dependencies": { "dependencies": {
"@nuxt/eslint": "^0.3.10", "@nuxt/eslint": "^0.7.6",
"@nuxt/image": "^1.6.0", "@nuxt/image": "^1.11.0",
"@nuxtjs/robots": "5.0.1", "@nuxtjs/robots": "5.0.1",
"@sentry/nuxt": "^9.47.1",
"@trpc/client": "^10.45.2", "@trpc/client": "^10.45.2",
"@trpc/server": "^10.45.2", "@trpc/server": "^10.45.2",
"@vueuse/nuxt": "12.0.0", "@vueuse/nuxt": "12.0.0",
"nuxt": "^3.14.1592", "nuxt": "^3.20.1",
"nuxt-icon": "^0.6.10", "nuxt-icon": "^0.6.10",
"trpc-nuxt": "^0.10.22", "trpc-nuxt": "^0.10.22",
"vue": "^3.4.21", "vue": "^3.5.25",
"vue-router": "^4.3.0", "vue-router": "^4.6.3",
"zod": "^3.23.8" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.22",
"daisyui": "^4.10.2", "daisyui": "^4.12.24",
"nuxt-delay-hydration": "^1.3.8", "nuxt-delay-hydration": "^1.3.8",
"postcss": "^8.4.38", "postcss": "^8.5.6",
"prettier": "3.2.5", "prettier": "3.4.2",
"prettier-plugin-tailwindcss": "^0.5.14", "prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^3.4.3" "tailwindcss": "^3.4.18"
},
"overrides": {
"@vercel/nft": "^0.27.4"
} }
} }

View file

@ -35,7 +35,7 @@ useSeoMeta({
class="hero h-[40vh] bg-cover bg-center relative" class="hero h-[40vh] bg-cover bg-center relative"
:style="`background-image: url(${category!.picture})`" :style="`background-image: url(${category!.picture})`"
> >
<div class="hero-overlay bg-opacity-60"></div> <div class="hero-overlay bg-opacity-60" />
<div class="hero-content text-center text-neutral-content"> <div class="hero-content text-center text-neutral-content">
<h1 class="text-5xl font-bold">{{ category?.name || categoryName }}</h1> <h1 class="text-5xl font-bold">{{ category?.name || categoryName }}</h1>
</div> </div>
@ -60,12 +60,19 @@ useSeoMeta({
class="card bg-base-100 shadow-xl" class="card bg-base-100 shadow-xl"
> >
<figure> <figure>
<img :src="recipe.pictureUrl" :alt="recipe.title" /> <nuxt-img
:src="recipe.pictureUrl"
:alt="recipe.title"
:placeholder="[300]"
height="300"
width="300"
format="webp"
/>
</figure> </figure>
<div class="card-body"> <div class="card-body">
<h2 class="card-title">{{ recipe.title }}</h2> <h2 class="card-title">{{ recipe.title }}</h2>
<div class="card-actions justify-end"> <div class="card-actions justify-end">
<nuxt-link :to="`/recipe/${recipe.id}`" class="btn btn-primary"> <nuxt-link :to="`/${recipe.id}`" class="btn btn-primary">
View Recipe View Recipe
</nuxt-link> </nuxt-link>
</div> </div>

View file

@ -38,7 +38,15 @@ useSeoMeta({
class="card bg-base-100 shadow-xl h-[28rem] sm:h-[32rem] md:h-[36rem] lg:h-[32rem]" class="card bg-base-100 shadow-xl h-[28rem] sm:h-[32rem] md:h-[36rem] lg:h-[32rem]"
> >
<figure> <figure>
<img :src="category.picture" :alt="category.name" /> <nuxt-img
:src="category.picture"
:alt="category.name"
:placeholder="[160, 100]"
height="100"
width="160"
format="webp"
loading="lazy"
/>
</figure> </figure>
<div class="card-body"> <div class="card-body">
<h2 class="card-title">{{ category.name }}</h2> <h2 class="card-title">{{ category.name }}</h2>
@ -57,23 +65,9 @@ useSeoMeta({
</div> </div>
</div> </div>
<div v-else class="alert alert-info my-8 flex-col items-center"> <div v-else role="alert" class="alert alert-info my-8 items-center flex">
<div class="flex items-center"> <icon name="uil:info-circle" class="w-8 h-8" />
<svg <span>No categories available</span>
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>No categories available</span>
</div>
</div> </div>
</div> </div>
</template> </template>

31
pages/cookbook.vue Normal file
View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import type { Recipe } from "~/types/recipe";
import { useStorage } from "@vueuse/core";
const cookbook = useStorage<Recipe[]>("cookbook", [], localStorage);
</script>
<template>
<main>
<div
v-if="cookbook.length === 0"
class="flex justify-center items-center min-h-screen"
>
<div class="alert alert-info">
<span>No recipes found in this category.</span>
</div>
</div>
<ul>
<li v-for="recipe in cookbook" :key="recipe.id">
<nuxt-link :to="`/${recipe.id}`">
<recipe-card
:title="recipe.title"
:picture-url="recipe.pictureUrl"
:video-url="recipe.videoUrl"
:category="recipe.category"
:origin="recipe.origin"
/>
</nuxt-link>
</li>
</ul>
</main>
</template>

View file

@ -1,17 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
useHead({
htmlAttrs: {
lang: "en",
},
link: [
{
rel: "icon",
type: "image/png",
href: "/favicon.png",
},
],
});
const url = useRequestURL(); const url = useRequestURL();
useSeoMeta({ useSeoMeta({
title: `Mood2Food`, title: `Mood2Food`,
@ -32,7 +19,12 @@ useSeoMeta({
<div class="hero-content flex-col lg:flex-row-reverse h-full"> <div class="hero-content flex-col lg:flex-row-reverse h-full">
<nuxt-img <nuxt-img
src="/chef.svg" src="/chef.svg"
class="max-w-sm h-[80vh] object-contain rounded-lg" alt="Chef holding a knife"
class="max-w-sm object-contain rounded-lg"
:placeholder="[400, 300]"
format="webp"
height="300"
width="400"
/> />
<div class="flex flex-col justify-center"> <div class="flex flex-col justify-center">
<h1 class="text-5xl font-bold prose">Eat Something New</h1> <h1 class="text-5xl font-bold prose">Eat Something New</h1>

View file

@ -4,18 +4,24 @@ import type { Recipe } from "~/types/recipe";
const route = useRoute(); const route = useRoute();
const searchQuery = computed(() => route.query.q as string); const searchQuery = computed(() => route.query.q as string);
const searchResults = ref<Recipe[]>([]); const searchResults = ref<Recipe[]>([]);
const loading = ref(false);
const { data, status, error } = await useRecipeSearch(searchQuery.value || ""); if (searchQuery.value) {
if (error.value) { loading.value = true;
throw createError({ const { data, error } = await useRecipeSearch(searchQuery.value);
statusCode: 500, if (error.value) {
message: error.value.message, throw createError({
}); statusCode: 500,
message: error.value.message,
});
}
searchResults.value = data.value!;
loading.value = false;
} }
searchResults.value = data.value;
watch(searchQuery, async (newQuery) => { watch(searchQuery, async (newQuery) => {
loading.value = true;
const { data, error } = await useRecipeSearch(newQuery.trim()); const { data, error } = await useRecipeSearch(newQuery.trim());
if (error.value) { if (error.value) {
throw createError({ throw createError({
@ -24,18 +30,24 @@ watch(searchQuery, async (newQuery) => {
}); });
} }
searchResults.value = data.value; searchResults.value = data.value!;
loading.value = false;
}); });
</script> </script>
<template> <template>
<div class="container mx-auto px-4"> <div class="container mx-auto px-4">
<div v-if="status === 'pending'" class="flex justify-center my-8"> <recipe-search
class="md:hidden mb-6"
:initial-query="searchQuery"
:autofocus="true"
/>
<div v-if="loading" class="flex justify-center my-8">
<span class="loading loading-spinner loading-lg text-primary" /> <span class="loading loading-spinner loading-lg text-primary" />
</div> </div>
<div <div
v-else-if="searchResults.length > 0" v-if="searchResults.length > 0"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 my-8" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 my-8"
> >
<div <div
@ -43,7 +55,17 @@ watch(searchQuery, async (newQuery) => {
:key="recipe.id" :key="recipe.id"
class="card bg-base-100 shadow-xl" class="card bg-base-100 shadow-xl"
> >
<figure><img :src="recipe.pictureUrl" :alt="recipe.title" /></figure> <figure>
<nuxt-img
:src="recipe.pictureUrl"
:alt="recipe.title"
format="webp"
loading="lazy"
:placeholder="[350]"
height="350"
width="350"
/>
</figure>
<div class="card-body"> <div class="card-body">
<h2 class="card-title">{{ recipe.title }}</h2> <h2 class="card-title">{{ recipe.title }}</h2>
<p>{{ recipe.category }} {{ recipe.origin }}</p> <p>{{ recipe.category }} {{ recipe.origin }}</p>
@ -58,24 +80,11 @@ watch(searchQuery, async (newQuery) => {
<div <div
v-else-if="searchQuery" v-else-if="searchQuery"
class="alert alert-info my-8 flex-col items-center" role="alert"
class="alert alert-info my-8 items-center flex"
> >
<div class="flex items-center"> <icon name="uil:info-circle" class="w-8 h-8" />
<svg <span>No recipes found for "{{ searchQuery }}"</span>
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>No recipes found for "{{ searchQuery }}"</span>
</div>
</div> </div>
</div> </div>
</template> </template>

24
sentry.client.config.ts Normal file
View file

@ -0,0 +1,24 @@
import * as Sentry from "@sentry/nuxt";
Sentry.init({
dsn: useRuntimeConfig().public.sentry.dsn,
// We recommend adjusting this value in production, or using tracesSampler for finer control
tracesSampleRate: 1.0,
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
// If the entire session is not sampled, use the below sample rate to sample
// sessions when an error occurs.
replaysOnErrorSampleRate: 1.0,
// If you don't want to use Session Replay, just remove the line below:
integrations: [
Sentry.replayIntegration(),
Sentry.consoleLoggingIntegration(),
],
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
sendDefaultPii: true,
_experiments: {
enableLogs: true,
},
});

9
sentry.server.config.ts Normal file
View file

@ -0,0 +1,9 @@
import * as Sentry from "@sentry/nuxt";
Sentry.init({
dsn: useRuntimeConfig().public.sentry.dsn,
// We recommend adjusting this value in production, or using tracesSampler for finer control
tracesSampleRate: 1.0,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View file

@ -2,7 +2,6 @@ import { createNuxtApiHandler } from "trpc-nuxt";
import { appRouter } from "~/server/trpc/routers"; import { appRouter } from "~/server/trpc/routers";
import { createContext } from "~/server/trpc/context"; import { createContext } from "~/server/trpc/context";
// export API handler
export default createNuxtApiHandler({ export default createNuxtApiHandler({
router: appRouter, router: appRouter,
createContext, createContext,

View file

@ -1,28 +0,0 @@
import { publicProcedure, router } from "../trpc";
import {} from "~/types/recipe";
import {
categoriesResponseSchema,
type CategoriesResponse,
} from "~/types/category";
const { apiUrl } = useRuntimeConfig();
export const categoryRouter = router({
listCategories: publicProcedure.query(async () => {
const response = await $fetch<CategoriesResponse>(
new URL("categories.php", apiUrl).href,
);
const result = categoriesResponseSchema.safeParse(response);
if (!result.success) {
throw createError({
statusCode: 500,
statusMessage: "Invalid API response format",
data: result.error,
});
}
return result.data.categories.sort((a, b) => a.name.localeCompare(b.name));
}),
});

View file

@ -1,9 +1,8 @@
import type { inferRouterOutputs } from "@trpc/server"; import type { inferRouterOutputs } from "@trpc/server";
import { mergeRouters } from "../trpc"; import { mergeRouters } from "../trpc";
import { recipeRouter } from "./recipes"; import { recipeRouter } from "./recipes";
import { categoryRouter } from "./categories";
export const appRouter = mergeRouters(categoryRouter, recipeRouter); export const appRouter = mergeRouters(recipeRouter);
// export type definition of API
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;
export type RouterOutput = inferRouterOutputs<AppRouter>; export type RouterOutput = inferRouterOutputs<AppRouter>;

View file

@ -1,8 +1,12 @@
import { z } from "zod"; import { z } from "zod";
import { publicProcedure, router } from "../trpc"; import {
categoriesResponseSchema,
categoryRecipesResponseSchema,
type CategoriesResponse,
} from "~/types/category";
import type { Meal } from "~/types/recipe"; import type { Meal } from "~/types/recipe";
import { parseRecipeData } from "~/utils/recipes"; import { parseRecipeData } from "~/utils/recipes";
import { categoryRecipesResponseSchema } from "~/types/category"; import { publicProcedure, router } from "../trpc";
const { apiUrl } = useRuntimeConfig(); const { apiUrl } = useRuntimeConfig();
@ -25,6 +29,7 @@ export const recipeRouter = router({
return result.data.recipes; return result.data.recipes;
}), }),
recipeGet: publicProcedure recipeGet: publicProcedure
.input( .input(
z.coerce z.coerce
@ -48,6 +53,7 @@ export const recipeRouter = router({
const recipes = parseRecipeData(data); const recipes = parseRecipeData(data);
return recipes[0]; return recipes[0];
}), }),
recipeRandom: publicProcedure.query(async () => { recipeRandom: publicProcedure.query(async () => {
const data = await $fetch<{ meals: Meal[] }>( const data = await $fetch<{ meals: Meal[] }>(
new URL("random.php", apiUrl).toString(), new URL("random.php", apiUrl).toString(),
@ -61,6 +67,7 @@ export const recipeRouter = router({
const recipes = parseRecipeData(data); const recipes = parseRecipeData(data);
return recipes[0]; return recipes[0];
}), }),
recipeSearch: publicProcedure recipeSearch: publicProcedure
.input( .input(
z.string({ z.string({
@ -77,4 +84,22 @@ export const recipeRouter = router({
const recipes = parseRecipeData(data); const recipes = parseRecipeData(data);
return recipes; return recipes;
}), }),
listCategories: publicProcedure.query(async () => {
const response = await $fetch<CategoriesResponse>(
new URL("categories.php", apiUrl).href,
);
const result = categoriesResponseSchema.safeParse(response);
if (!result.success) {
throw createError({
statusCode: 500,
statusMessage: "Invalid API response format",
data: result.error,
});
}
return result.data.categories.sort((a, b) => a.name.localeCompare(b.name));
}),
}); });

View file

@ -1,4 +1,4 @@
import { initTRPC , TRPCError } from "@trpc/server"; import { initTRPC, TRPCError } from "@trpc/server";
import type { Context } from "~/server/trpc/context"; import type { Context } from "~/server/trpc/context";
// import { authMiddleware } from "~/server/trpc/middleware"; // import { authMiddleware } from "~/server/trpc/middleware";

View file

@ -14,7 +14,6 @@ export type Recipe = {
const mealSchema = z.object({ const mealSchema = z.object({
idMeal: z.string(), idMeal: z.string(),
strMeal: z.string(), strMeal: z.string(),
strDrinkAlternate: z.string().nullable(),
strCategory: z.string(), strCategory: z.string(),
strArea: z.string(), strArea: z.string(),
strInstructions: z.string(), strInstructions: z.string(),
@ -63,8 +62,6 @@ const mealSchema = z.object({
strMeasure20: z.string().nullish(), strMeasure20: z.string().nullish(),
strSource: z.string().nullish(), strSource: z.string().nullish(),
strImageSource: z.string().nullable(), strImageSource: z.string().nullable(),
strCreativeCommonsConfirmed: z.string().nullable(),
dateModified: z.string().optional().nullable(),
}); });
export const apiResponseSchema = z.object({ export const apiResponseSchema = z.object({

9011
yarn.lock

File diff suppressed because it is too large Load diff