mirror of
https://github.com/rjNemo/meal_planner
synced 2026-06-06 02:26:49 +00:00
Compare commits
21 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83cc273e52 | |||
| 4ab8d98e2c | |||
| bfd83a367b | |||
| 3e34609fcf | |||
| 35af888724 | |||
| fc022e024d | |||
| 29db641392 | |||
| b7aca38912 | |||
| 3a66b00c74 | |||
| 32be4bb0df | |||
| 0d93ff37e4 | |||
| 7decbe7cbe | |||
| 5bd9f2c382 | |||
| b19cb02763 | |||
| a1bc45941d | |||
| 534a98b36f | |||
| 55d857e658 | |||
| 7c4f6419cd | |||
| 34a1f62813 | |||
| 4329d61c43 | |||
| c756a129a2 |
29 changed files with 3798 additions and 9253 deletions
|
|
@ -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
2
.gitignore
vendored
|
|
@ -23,3 +23,5 @@ logs
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
.aider*
|
.aider*
|
||||||
|
# Sentry Config File
|
||||||
|
.env.sentry-build-plugin
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Chef's Meal Planner
|
# Chef's Meal Planner
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
7
app.vue
7
app.vue
|
|
@ -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>
|
||||||
|
|
|
||||||
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
|
@ -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>
|
|
||||||
|
|
@ -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 © {{ new Date().getFullYear() }} – Made with ❤️</p>
|
>
|
||||||
|
<aside class="items-center">
|
||||||
|
<p>
|
||||||
|
<span class="hidden sm:inline"
|
||||||
|
>Copyright © {{ 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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
>
|
>
|
||||||
<path
|
<icon name="uil:bars" class="w-6 h-6" />
|
||||||
stroke-linecap="round"
|
<!-- TODO: add transition into cross on click -->
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
15
error.vue
15
error.vue
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
30
package.json
30
package.json
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
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>
|
<span>No categories available</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
31
pages/cookbook.vue
Normal file
31
pages/cookbook.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
const { data, error } = await useRecipeSearch(searchQuery.value);
|
||||||
|
if (error.value) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
message: error.value.message,
|
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
|
|
||||||
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>
|
<span>No recipes found for "{{ searchQuery }}"</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
24
sentry.client.config.ts
Normal file
24
sentry.client.config.ts
Normal 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
9
sentry.server.config.ts
Normal 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,
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue