├── .env.example ├── .gitignore ├── .npmrc ├── README.md ├── app.vue ├── nuxt.config.ts ├── package.json ├── pnpm-lock.yaml ├── providers └── spoonacular.ts ├── public ├── favicon.ico └── robots.txt ├── renovate.json ├── server ├── api │ └── recipes.get.ts └── tsconfig.json └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | NUXT_SPOONACULAR_API_KEY= 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | 26 | # nitro cache 27 | recipes 28 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nuxt Random Recipe App 2 | 3 | This is a tiny recipe app built in [Nuxt](https://nuxt.com/) for a live-coding session at [Vue.js Nation 2024](https://vuejsnation.com/). 4 | 5 |

6 | 7 | Screenshot of the random recipe app 8 | 9 |

10 | 11 | - [✨  Live Demo](https://nuxt-spooon.vercel.app/) 12 | 13 | 14 | ## Features 15 | 16 | - Built with [Nuxt 3](https://nuxt.com/) 17 | - Server API routes using [Nitro](https://nuxt.com/docs/guide/concepts/server-engine) 18 | - Usage of [runtime config](https://nuxt.com/docs/guide/going-further/runtime-config) 19 | - Uses built-in [storage layer](https://nuxt.com/docs/guide/directory-structure/server#server-storage) 20 | - Responsive images (and custom provider) with [Nuxt Image](https://image.nuxt.com/) 21 | - Interface with [Nuxt UI](https://ui.nuxt.com/) and [TailwindCSS](https://tailwindcss.nuxtjs.org/) 22 | 23 | ## Try it out 24 | 25 | ### Setup 26 | 27 | ```bash 28 | # install dependencies 29 | pnpm install 30 | 31 | # serve in dev mode, with hot reload at localhost:3000 32 | pnpm dev 33 | 34 | # build for production (universal) 35 | pnpm build 36 | 37 | # preview in production mode 38 | pnpm preview 39 | ``` 40 | 41 | ### Deployment 42 | 43 | You should be able to deploy this repository with zero or minimal configuration (just make sure to set the ). 44 | 45 | - [Azure](https://nuxt.com/deploy/azure) 46 | - [Cloudflare Workers](https://nuxt.com/deploy/cloudflare) 47 | - [Firebase Hosting](https://nuxt.com/deploy/firebase) 48 | - [Netlify](https://nuxt.com/deploy/netlify) 49 | - [Vercel](https://nuxt.com/deploy/vercel) 50 | - ... and more 51 | 52 | ## License 53 | 54 | MIT 55 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 80 | 81 | 86 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | devtools: { enabled: true }, 4 | 5 | runtimeConfig: { 6 | spoonacular: { 7 | apiKey: '' 8 | } 9 | }, 10 | 11 | routeRules: { 12 | '/**': { 13 | isr: 60 * 60 * 24 14 | } 15 | }, 16 | 17 | $development: { 18 | nitro: { 19 | storage: { 20 | recipes: { 21 | driver: 'fs', 22 | base: 'recipes' 23 | } 24 | } 25 | } 26 | }, 27 | 28 | image: { 29 | providers: { 30 | spoonacular: { 31 | provider: '~/providers/spoonacular.ts', 32 | } 33 | } 34 | }, 35 | 36 | modules: ['@nuxt/ui', "@nuxt/image"] 37 | }) 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-app", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "nuxt build", 7 | "dev": "nuxt dev", 8 | "generate": "nuxt generate", 9 | "preview": "nuxt preview", 10 | "postinstall": "nuxt prepare" 11 | }, 12 | "devDependencies": { 13 | "@nuxt/image": "1.10.0", 14 | "@nuxt/ui": "2.22.0", 15 | "nuxt": "3.17.4", 16 | "vue": "3.5.16", 17 | "vue-router": "4.5.1" 18 | }, 19 | "dependencies": { 20 | "valibot": "^1.0.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /providers/spoonacular.ts: -------------------------------------------------------------------------------- 1 | import { joinURL } from 'ufo' 2 | import type { ProviderGetImage } from '@nuxt/image' 3 | import { createOperationsGenerator } from '#image' 4 | 5 | const operationsGenerator = createOperationsGenerator() 6 | 7 | export const getImage: ProviderGetImage = ( 8 | src, 9 | { modifiers = {}, baseURL = 'https://spoonacular.com/cdn' } = {} 10 | ) => { 11 | const sizes = [100, 250, 500] 12 | const size = modifiers.width || modifiers.height 13 | const nextBiggest = sizes.find((s) => s >= size) || sizes[sizes.length - 1] 14 | const prefix = `ingredients_${nextBiggest}x${nextBiggest}` 15 | 16 | return { 17 | url: joinURL(baseURL, prefix, src), 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielroe/spooon/90a67c631589aa5605d2f4584bc2de80b372ef0d/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielroe/spooon/90a67c631589aa5605d2f4584bc2de80b372ef0d/public/robots.txt -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>danielroe/renovate" 5 | ] 6 | } -------------------------------------------------------------------------------- /server/api/recipes.get.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'valibot' 2 | 3 | const recipeSchema = z.object({ 4 | id: z.number(), 5 | title: z.string(), 6 | image: z.optional(z.string()), 7 | imageType: z.optional(z.string()), 8 | servings: z.number(), 9 | readyInMinutes: z.number(), 10 | sourceUrl: z.string(), 11 | sourceName: z.string(), 12 | summary: z.string(), 13 | analyzedInstructions: z.array(z.object({ 14 | name: z.string(), 15 | steps: z.array(z.object({ 16 | number: z.number(), 17 | step: z.string(), 18 | ingredients: z.array(z.object({ 19 | id: z.number(), 20 | name: z.string(), 21 | localizedName: z.string(), 22 | image: z.string() 23 | })), 24 | equipment: z.array(z.object({ 25 | id: z.number(), 26 | name: z.string(), 27 | localizedName: z.string(), 28 | image: z.string() 29 | })) 30 | })) 31 | })), 32 | extendedIngredients: z.array(z.object({ 33 | id: z.number(), 34 | name: z.string(), 35 | nameClean: z.nullable(z.string()), 36 | original: z.string(), 37 | originalName: z.string(), 38 | amount: z.number(), 39 | unit: z.string(), 40 | image: z.nullable(z.string()), 41 | meta: z.array(z.string()), 42 | measures: z.object({ 43 | us: z.object({ 44 | amount: z.number(), 45 | unitShort: z.string(), 46 | unitLong: z.string() 47 | }), 48 | metric: z.object({ 49 | amount: z.number(), 50 | unitShort: z.string(), 51 | unitLong: z.string() 52 | }) 53 | }) 54 | })), 55 | diets: z.array(z.string()), 56 | dishTypes: z.array(z.string()), 57 | cuisines: z.array(z.string()), 58 | instructions: z.string() 59 | }) 60 | 61 | export default defineCachedEventHandler(async event => { 62 | console.log('making fresh recipes request') 63 | const { recipes } = await $fetch<{ recipes: unknown }>('https://api.spoonacular.com/recipes/random', { 64 | query: { 65 | limitLicense: true, 66 | number: 100, 67 | apiKey: useRuntimeConfig().spoonacular.apiKey 68 | } 69 | }) 70 | 71 | try { 72 | return z.parse(z.array(recipeSchema), recipes) 73 | } catch (e) { 74 | // @ts-expect-error untyped error 75 | console.log(e.issues.map(i => i.path)) 76 | return [] 77 | } 78 | }, { 79 | base: 'recipes', 80 | getKey: () => 'recipes', 81 | shouldBypassCache: () => false, 82 | maxAge: 1000 * 60 * 60 * 24, 83 | staleMaxAge: 1000 * 60 * 60 * 24 * 7 84 | }) 85 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | --------------------------------------------------------------------------------