├── .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 |
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 |
2 |
3 |
4 | {{ recipe.title }}
5 |
6 |
7 |
8 |
9 |
13 |
14 |
15 |
16 |
Ingredients
17 |
21 |
22 |
23 |
24 |
25 |
26 | {{ row.name }}
27 | ({{ [row.measures[unit].amount, row.measures[unit].unitLong].filter(Boolean).join(' ') }})
28 |
29 |
30 |
31 |
32 |
33 | Steps
34 |
35 | -
36 |
37 | {{ number }}
38 |
39 | {{ step }}
40 |
41 |
42 |
43 |
44 |
45 | View original recipe on {{ recipe.sourceName }}
46 |
47 |
48 |
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 |
--------------------------------------------------------------------------------