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_PUBLIC_SENTRY_DSN=
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -23,3 +23,5 @@ logs
|
|||
.env.*
|
||||
!.env.example
|
||||
.aider*
|
||||
# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Chef's Meal Planner
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
|
@ -95,8 +95,11 @@ address: [link](https://mood2food.netlify.app/).
|
|||
## Built With
|
||||
|
||||
- [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.
|
||||
- [daisyUI](https://daisyui.com/) - The most popular component library for
|
||||
Tailwind CSS
|
||||
- [TheMealDb](https://www.themealdb.com/api.php) - An open, crowd-sourced database
|
||||
of Recipes from around the world
|
||||
|
||||
|
|
|
|||
7
app.vue
7
app.vue
|
|
@ -1,6 +1,11 @@
|
|||
<template>
|
||||
<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">
|
||||
<nuxt-page />
|
||||
</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>
|
||||
<footer class="footer bg-base-300 text-base-content items-center p-4">
|
||||
<aside class="grid-flow-col items-center">
|
||||
<p>Copyright © {{ new Date().getFullYear() }} – Made with ❤️</p>
|
||||
<footer
|
||||
class="footer bg-base-300 text-base-content items-center p-4 flex justify-between"
|
||||
>
|
||||
<aside class="items-center">
|
||||
<p>
|
||||
<span class="hidden sm:inline"
|
||||
>Copyright © {{ new Date().getFullYear() }} –
|
||||
</span>
|
||||
Made with ❤️
|
||||
</p>
|
||||
</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
|
||||
to="https://github.com/rjNemo/meal_planner"
|
||||
:external="true"
|
||||
target="_blank"
|
||||
aria-label="navigate to the source code on GitHub"
|
||||
>
|
||||
<svg
|
||||
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>
|
||||
<icon name="cib:github" class="w-6 h-6" />
|
||||
</nuxt-link>
|
||||
<nuxt-link
|
||||
to="https://ruidy.nemausat.com"
|
||||
:external="true"
|
||||
target="_blank"
|
||||
aria-label="navigate to my website"
|
||||
>
|
||||
<svg
|
||||
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>
|
||||
<icon name="uil:globe" class="w-6 h-6" />
|
||||
</nuxt-link>
|
||||
</nav>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -11,72 +11,49 @@ const handleRandomClick = async () => {
|
|||
}
|
||||
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>
|
||||
|
||||
<template>
|
||||
<nav class="navbar bg-base-300">
|
||||
<div class="navbar-start">
|
||||
<div class="dropdown">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h8m-8 6h16"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="btn btn-ghost lg:hidden"
|
||||
arial-label="Menu button"
|
||||
>
|
||||
<icon name="uil:bars" class="w-6 h-6" />
|
||||
<!-- TODO: add transition into cross on click -->
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
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>
|
||||
</div>
|
||||
<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>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li><nuxt-link to="/categories">Categories</nuxt-link></li>
|
||||
<slot name="menu" />
|
||||
</ul>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -12,10 +12,21 @@ defineProps<{
|
|||
<div class="card-body items-center text-center bg-base-200">
|
||||
<h2 class="card-title">{{ title }}</h2>
|
||||
<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>
|
||||
<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" />
|
||||
</nuxt-link>
|
||||
<div class="badge badge-secondary">
|
||||
|
|
|
|||
|
|
@ -7,21 +7,39 @@
|
|||
type="text"
|
||||
class="grow"
|
||||
placeholder="Search recipes..."
|
||||
:autofocus="autofocus"
|
||||
@focus="isFocused = true"
|
||||
@blur="isFocused = false"
|
||||
@keydown.enter="$emit('search')"
|
||||
>
|
||||
<kbd class="kbd kbd-sm" :class="{ 'opacity-50': !isFocused }">⌘</kbd>
|
||||
<kbd class="kbd kbd-sm" :class="{ 'opacity-50': !isFocused }">K</kbd>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineEmits(["search"]);
|
||||
const model = defineModel<string>();
|
||||
defineProps<{ autofocus?: boolean }>();
|
||||
|
||||
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(() => {
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -29,6 +71,23 @@ defineProps<{ recipe: Recipe }>();
|
|||
<p class="prose prose-lg max-w-none w-full">
|
||||
{{ recipe.instructions }}
|
||||
</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>
|
||||
</template>
|
||||
|
|
|
|||
15
error.vue
15
error.vue
|
|
@ -5,20 +5,7 @@
|
|||
<div class="card-body">
|
||||
<!-- Error Icon -->
|
||||
<div class="text-error text-6xl mb-4">
|
||||
<svg
|
||||
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>
|
||||
<icon name="uil:exclamation-triangle" class="w-16 h-16" />
|
||||
</div>
|
||||
|
||||
<!-- Error Details -->
|
||||
|
|
|
|||
|
|
@ -5,15 +5,17 @@ export default defineNuxtConfig({
|
|||
modules: [
|
||||
"@nuxt/eslint",
|
||||
"@nuxt/image",
|
||||
"nuxt-icon",
|
||||
"nuxt-delay-hydration",
|
||||
"@nuxtjs/robots",
|
||||
"@sentry/nuxt/module",
|
||||
"@vueuse/nuxt",
|
||||
"nuxt-delay-hydration",
|
||||
"nuxt-icon",
|
||||
],
|
||||
|
||||
app: {
|
||||
head: {
|
||||
title: "Meal Planner",
|
||||
title: "Mood2Food",
|
||||
htmlAttrs: { lang: "en" },
|
||||
meta: [
|
||||
{ charset: "utf-8" },
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||
|
|
@ -23,7 +25,7 @@ export default defineNuxtConfig({
|
|||
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" },
|
||||
layoutTransition: { name: "slide", mode: "out-in" },
|
||||
|
|
@ -56,8 +58,26 @@ export default defineNuxtConfig({
|
|||
// The private keys which are only available server-side
|
||||
apiUrl: "",
|
||||
// Keys within public are also exposed client-side
|
||||
public: {
|
||||
sentry: {
|
||||
dsn: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
ssr: true,
|
||||
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/eslint": "^0.3.10",
|
||||
"@nuxt/image": "^1.6.0",
|
||||
"@nuxt/eslint": "^0.7.6",
|
||||
"@nuxt/image": "^1.11.0",
|
||||
"@nuxtjs/robots": "5.0.1",
|
||||
"@sentry/nuxt": "^9.47.1",
|
||||
"@trpc/client": "^10.45.2",
|
||||
"@trpc/server": "^10.45.2",
|
||||
"@vueuse/nuxt": "12.0.0",
|
||||
"nuxt": "^3.14.1592",
|
||||
"nuxt": "^3.20.1",
|
||||
"nuxt-icon": "^0.6.10",
|
||||
"trpc-nuxt": "^0.10.22",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0",
|
||||
"zod": "^3.23.8"
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^4.6.3",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"daisyui": "^4.10.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"daisyui": "^4.12.24",
|
||||
"nuxt-delay-hydration": "^1.3.8",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"tailwindcss": "^3.4.3"
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"tailwindcss": "^3.4.18"
|
||||
},
|
||||
"overrides": {
|
||||
"@vercel/nft": "^0.27.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ useSeoMeta({
|
|||
class="hero h-[40vh] bg-cover bg-center relative"
|
||||
: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">
|
||||
<h1 class="text-5xl font-bold">{{ category?.name || categoryName }}</h1>
|
||||
</div>
|
||||
|
|
@ -60,12 +60,19 @@ useSeoMeta({
|
|||
class="card bg-base-100 shadow-xl"
|
||||
>
|
||||
<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>
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ recipe.title }}</h2>
|
||||
<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
|
||||
</nuxt-link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,15 @@ useSeoMeta({
|
|||
class="card bg-base-100 shadow-xl h-[28rem] sm:h-[32rem] md:h-[36rem] lg:h-[32rem]"
|
||||
>
|
||||
<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>
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{{ category.name }}</h2>
|
||||
|
|
@ -57,23 +65,9 @@ useSeoMeta({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="alert alert-info my-8 flex-col items-center">
|
||||
<div class="flex items-center">
|
||||
<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>
|
||||
</div>
|
||||
<div v-else role="alert" class="alert alert-info my-8 items-center flex">
|
||||
<icon name="uil:info-circle" class="w-8 h-8" />
|
||||
<span>No categories available</span>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
lang: "en",
|
||||
},
|
||||
link: [
|
||||
{
|
||||
rel: "icon",
|
||||
type: "image/png",
|
||||
href: "/favicon.png",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const url = useRequestURL();
|
||||
useSeoMeta({
|
||||
title: `Mood2Food`,
|
||||
|
|
@ -32,7 +19,12 @@ useSeoMeta({
|
|||
<div class="hero-content flex-col lg:flex-row-reverse h-full">
|
||||
<nuxt-img
|
||||
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">
|
||||
<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 searchQuery = computed(() => route.query.q as string);
|
||||
const searchResults = ref<Recipe[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const { data, status, error } = await useRecipeSearch(searchQuery.value || "");
|
||||
if (error.value) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: error.value.message,
|
||||
});
|
||||
if (searchQuery.value) {
|
||||
loading.value = true;
|
||||
const { data, error } = await useRecipeSearch(searchQuery.value);
|
||||
if (error.value) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: error.value.message,
|
||||
});
|
||||
}
|
||||
|
||||
searchResults.value = data.value!;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
searchResults.value = data.value;
|
||||
|
||||
watch(searchQuery, async (newQuery) => {
|
||||
loading.value = true;
|
||||
const { data, error } = await useRecipeSearch(newQuery.trim());
|
||||
if (error.value) {
|
||||
throw createError({
|
||||
|
|
@ -24,18 +30,24 @@ watch(searchQuery, async (newQuery) => {
|
|||
});
|
||||
}
|
||||
|
||||
searchResults.value = data.value;
|
||||
searchResults.value = data.value!;
|
||||
loading.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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" />
|
||||
</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"
|
||||
>
|
||||
<div
|
||||
|
|
@ -43,7 +55,17 @@ watch(searchQuery, async (newQuery) => {
|
|||
:key="recipe.id"
|
||||
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">
|
||||
<h2 class="card-title">{{ recipe.title }}</h2>
|
||||
<p>{{ recipe.category }} • {{ recipe.origin }}</p>
|
||||
|
|
@ -58,24 +80,11 @@ watch(searchQuery, async (newQuery) => {
|
|||
|
||||
<div
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
<icon name="uil:info-circle" class="w-8 h-8" />
|
||||
<span>No recipes found for "{{ searchQuery }}"</span>
|
||||
</div>
|
||||
</div>
|
||||
</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 { createContext } from "~/server/trpc/context";
|
||||
|
||||
// export API handler
|
||||
export default createNuxtApiHandler({
|
||||
router: appRouter,
|
||||
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 { mergeRouters } from "../trpc";
|
||||
import { recipeRouter } from "./recipes";
|
||||
import { categoryRouter } from "./categories";
|
||||
|
||||
export const appRouter = mergeRouters(categoryRouter, recipeRouter);
|
||||
// export type definition of API
|
||||
export const appRouter = mergeRouters(recipeRouter);
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
export type RouterOutput = inferRouterOutputs<AppRouter>;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { z } from "zod";
|
||||
import { publicProcedure, router } from "../trpc";
|
||||
import {
|
||||
categoriesResponseSchema,
|
||||
categoryRecipesResponseSchema,
|
||||
type CategoriesResponse,
|
||||
} from "~/types/category";
|
||||
import type { Meal } from "~/types/recipe";
|
||||
import { parseRecipeData } from "~/utils/recipes";
|
||||
import { categoryRecipesResponseSchema } from "~/types/category";
|
||||
import { publicProcedure, router } from "../trpc";
|
||||
|
||||
const { apiUrl } = useRuntimeConfig();
|
||||
|
||||
|
|
@ -25,6 +29,7 @@ export const recipeRouter = router({
|
|||
|
||||
return result.data.recipes;
|
||||
}),
|
||||
|
||||
recipeGet: publicProcedure
|
||||
.input(
|
||||
z.coerce
|
||||
|
|
@ -48,6 +53,7 @@ export const recipeRouter = router({
|
|||
const recipes = parseRecipeData(data);
|
||||
return recipes[0];
|
||||
}),
|
||||
|
||||
recipeRandom: publicProcedure.query(async () => {
|
||||
const data = await $fetch<{ meals: Meal[] }>(
|
||||
new URL("random.php", apiUrl).toString(),
|
||||
|
|
@ -61,6 +67,7 @@ export const recipeRouter = router({
|
|||
const recipes = parseRecipeData(data);
|
||||
return recipes[0];
|
||||
}),
|
||||
|
||||
recipeSearch: publicProcedure
|
||||
.input(
|
||||
z.string({
|
||||
|
|
@ -77,4 +84,22 @@ export const recipeRouter = router({
|
|||
const recipes = parseRecipeData(data);
|
||||
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 { authMiddleware } from "~/server/trpc/middleware";
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ export type Recipe = {
|
|||
const mealSchema = z.object({
|
||||
idMeal: z.string(),
|
||||
strMeal: z.string(),
|
||||
strDrinkAlternate: z.string().nullable(),
|
||||
strCategory: z.string(),
|
||||
strArea: z.string(),
|
||||
strInstructions: z.string(),
|
||||
|
|
@ -63,8 +62,6 @@ const mealSchema = z.object({
|
|||
strMeasure20: z.string().nullish(),
|
||||
strSource: z.string().nullish(),
|
||||
strImageSource: z.string().nullable(),
|
||||
strCreativeCommonsConfirmed: z.string().nullable(),
|
||||
dateModified: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export const apiResponseSchema = z.object({
|
||||
|
|
|
|||
Loading…
Reference in a new issue