Monday, May 13, 2024
HomeJavaScriptConstruct a Picture Gallery app with Webiny and Nuxt

Construct a Picture Gallery app with Webiny and Nuxt


Serverless infrastructure has many advantages on the subject of constructing trendy purposes. These advantages embody decrease latency, lowered prices, faster deployments, and plenty of extra. With a Serverless CMS like Webiny, we will construct trendy purposes powered by serverless structure.

On this tutorial, we’ll see how we will construct a full-stack utility by leveraging Webiny’s’ Headless CMS options.

We’ll be constructing a picture sharing app with Webiny and Nuxt.js 3, the place customers will view photos posted by different customers and be capable of add their very own photos.

We’ll cowl how Webiny works as a Serverless CMS and we’ll stroll by way of the method of making, configuring, and deploying a Webiny venture with a GraphQL API.

Then, we join it to a Nuxt.js frontend and construct out the performance for creating and viewing pictures.

A Temporary Introduction to Serverless & Headless CMS

CMS stands for Content material Administration System which, because the title implies, helps handle content material for purposes. Conventional CMS options are those the place the again finish is coupled with the entrance finish of the applying. A headless CMS is one the place the backend is decoupled and communication between the 2 components is completed utilizing APIs which are supplied by the Headless CMS.

This enables any expertise of selection for use in constructing the consumer aspect of the applying, permitting the applying for use throughout a number of gadgets starting from laptop computer computer systems to telephones, to TVs to watches. Principally something able to speaking through APIs.

Serverless CMS is kind of an evolution of a typical Headless CMS, the place as a substitute of being hosted and deployed on a server or accessed as a SaaS (like within the case of some Headless CMS choices, Webiny is serverless because it makes use of AWS Serverless companies to run.

Additionally, deploying and sustaining a Webiny occasion is made tremendous simple by way of the Webiny command-line interface (CLI).

What Is Webiny

Webiny is an open-source content material administration system designed for enterprises. It’s constructed on prime of serverless infrastructure to allow nice scalability and website reliability even in demanding, peak site visitors circumstances.

Though Webiny describes itself as an “open-source content material administration system” it’s far more than that. It affords a wealthy set of options which incorporates:

On this tutorial, nonetheless, we’ll be wanting extensively on the Headless CMS characteristic.

What We’re Constructing

We’ll be constructing a easy photograph gallery app that shows Images photos with info comparable to Caption, Creator Creator Title and Username. Customers will be capable of view photos and likewise add photos with the caption and creator info that they select.

Conditions

Earlier than getting began with this venture, guarantee you have got the next:

  • Primary data of Vue and Nuxt
  • Node.js variations 14 or better
  • When you don’t have Node.js put in, the best method to set up it’s by downloading the official binary
  • Volar Extension (for Nuxt)
  • Yarn put in
    • Webiny works with yarn variations >=1.22.0 and >=2
  • AWS account and person credentials

Setting Up and Deploying a Webiny Venture

We’ll create a brand new Webiny venture utilizing create-webiny-project, to try this, navigate to the listing we wish to create our app in and run:

npx create-webiny-project webiny-photo-api

That is going to do a number of issues:

  • Put together a venture folder
  • Set up dependencies
  • Scaffold a Webiny utility
  • Initialize git and make an preliminary commit

After that, we simply need to comply with the prompts to proceed with the set up. We’ll be requested to

  • Select the area the place our app can be deployed
  • Select the database setup for our Webiny venture

The set up would possibly take some time. Ensure you’ve adopted the directions from the conditions and have arrange your AWS account fully.

When you’re nonetheless encountering any issues, you possibly can at all times attain out to the Webiny Neighborhood on Slack.

As soon as the set up is full, we will go forward and deploy our Webiny venture:

yarn webiny deploy

By executing this command, the entire venture purposes will first get constructed, and, together with wanted cloud infrastructure sources, deployed into our AWS account.

🚨Observe that the primary deployment can take as much as 20 minutes! So, although it’d seem like nothing is occurring within the terminal, please be affected person and let the method end. If one thing went flawed, an error can be proven.

Right here’s what we get after a profitable deploy:




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/successful-deploy.png

Keep in mind: You’ll be able to at all times run yarn webiny data --env=dev to view the entire related venture URLs, together with the URL of your GraphQL API:




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/info-command-output.png

By working that command, we get the URL with which we will entry the Admin app.




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/webiny-installer-start.png

As soon as we’ve created the admin account, we will proceed to undergo the remaining steps and set up all of the purposes.




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/webiny-installer-end.png

Click on the FINISH INSTALL button to be taken to the Webiny dashboard.




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/webiny-dashboard.png

Now now we have our Webiny utility deployed, we will get began on structuring our content material.

Making a Content material Mannequin

Very first thing we’ll have to do now could be to create the fashions for our utility. Within the Webiny dashboard, click on on Create New Content material Mannequin below the Headless CMS possibility.

A type seems the place we will create our content material mannequin.

Picture Content material Mannequin

Arrange mannequin as under:

  • Title: “Picture”
  • Content material Mannequin Group: Ungrouped
  • Description: “A photograph”




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/create-photos-content-model.png

Within the Picture content material mannequin, we create the next fields:

  • A textual content subject with the Label worth “caption”




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/photo-model-text-field-settings.png

  • A recordsdata subject with the Label worth “picture”
  • Choose pictures solely




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/photo-model-files-field-settings.png

We must always now have one thing like this:




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/photo-model-complete.png

Creator Content material Mannequin

Arrange the content material mannequin as under:

  • Title: “creator”
  • Content material Mannequin Group: Ungrouped
  • Description: “Creator of images”




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/author-model-create.png

Within the creator content material mannequin, we create the next fields:

  • A textual content subject with the Label worth “title”




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/author-model-name-field-create.png

  • One other textual content subject with the Label worth “username”




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/author-model-text-field-create.png

  • Beneath the Validators tab set the next values:
    • Required to enabled
    • Distinctive to enabled




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/author-model-username-field-validators.png

Now we have to create our first creator.

Create an Creator

To create a brand new Creator entry, go to the dashboard and below CONTENT MODELS, choose authors




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/author-entry-create.png

Enter the creator particulars and click on on SAVE & PUBLISH




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/author-entry-after-create.png

Add Creator Reference Subject to Picture

Now that we’ve created our creator content material mannequin, we will now add it as a relational subject in our Images content material mannequin.

Open the Images mannequin and add the Creator reference subject, i.e. add a  relational subject with the Label worth “creator”




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/photos-model-author-field-create.png

Our content material construction ought to seem like this:




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/photo-model-with-reference-field.png

Create Our First Picture

Creating our first creator and photograph document within the UI like this enables us to check our API within the GraphQL Playground and our app to verify now we have it arrange accurately and for simpler debugging.

Navigate to the Picture content material sort to create a brand new photograph




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/photos-content-menu-item.png

Click on on NEW ENTRY to create a brand new entry, add a picture of your selection and full the remainder of the small print as you would like.




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/photo-content-new-entry.png

Click on SAVE & PUBLISH to save lots of the entry and publish it to be accessible on the API.

Now we can fetch our images from the GraphQL API playground.

Navigate to the API Playground from aspect menu and within the Headless CMS – Learn API tab execute this question to listing all images:

{
listPhotos {
information {
id
caption
photograph
creator {
title
username
}
}
}
}

Right here’s what you must get:




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/api-playground-listphotos-query.png

Creating API Keys

As seen within the docs on utilizing the GraphQL API, the GraphQL API sits behind a safety layer that forbids unauthorized entry. So in an effort to connect with it, we have to go the worth of an API key. These might be created through the Safety Webiny utility, by opening the API Keys part:




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/api-keys-menu-navigation.png

Now, let’s give our API Key a reputation and outline. We’ll additionally arrange some permissions. For the content material, we’ll allow All Locales.




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/api-key-enable-all-locales.png

Subsequent, within the Headless CMS part, we’ll configure the Entry Degree to Customized entry.

Beneath the GRAPHQL API TYPES, we choose READ.




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/api-key-all-api-types-read.png

Additionally, below CONTENT MODELS, we choose Solely particular fashions and choose:




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/api-key-specify-models.png

Now, click on the SAVE API KEY button and replica the token.

We will now ship an exterior request from an API tester to our Headless CMS Learn API by organising the Authorization header with our token.

Let’s get an inventory of our images with this question:

{
listPhotos{
information{
id
caption
photograph
creator{
title
username
}
}
}
}

We get this response:

{
"information": {
"listPhotos": {
"information": [
{
"id": "631b9e8807a1f20009dce683#0001",
"caption": "Awesome neon robot",
"photo": "https://mywebinyinstance.cloudfront.net/files/9l7usfmto-ant-rozetsky-r4iD__fTqIs-unsplash.jpg",
"author": {
"name": "Ant Rozetsky",
"username": "rozetsky"
}
}
]
}
},
"extensions": {
"console": []
}
}

I’ve examined this with a GraphQL consumer as you possibly can see within the picture under. You are able to do the identical with like Insomnia or Postman.




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/test-api-response.png

Now that our API is up and working, let’s create the entrance finish of our utility with Nuxt.

Setting Up the Frontend

We’ll be utilizing Nuxt.js 3 to construct out the frontend for our Picture gallery app. Nuxt is an online framework constructed on Vue.js that gives server-side rendering capabilities, automated routing, and way more.

To arrange Nuxt.js 3 utilizing the Nuxt CLI (referred to as “Nuxi”), run:

npx nuxi init photo-gallery

This creates a brand new venture in a listing /photo-gallery, to put in the venture:

cd photo-gallery

npm set up

Arrange Tailwind

After a profitable set up, we will now proceed to arrange TailwindCSS for styling:

npm set up tailwindcss postcss@newest autoprefixer@newest @tailwindcss/types

npx tailwindcss init

Create a brand new file – ./property/css/most important.css and add these three strains:

@tailwind base;
@tailwind elements;
@tailwind utilities;

To maintain this tutorial straight to the purpose, I can’t be specializing in the styling of the applying and all types utilized in constructing the applying can be found within the most important.css file on GitHub.

Now we will configure postcss in ./nuxt.config.ts

import { defineNuxtConfig } from 'nuxt'

export default defineNuxtConfig({
construct: {
postcss: {
postcssOptions: {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
},
},
},
css: [
'@/assets/css/main.css',
],
})

We will additionally configure the content material in ./tailwind.config.js:

/** @sort {import('tailwindcss').Config} */
module.exports = {
content material: [
"./components/**/*.{js,vue,ts}",
"./layouts/**/*.vue",
"./pages/**/*.vue",
"./plugins/**/*.{js,ts}",
"./app.vue"
],
theme: {
lengthen: {},
},
plugins: [
require('@tailwindcss/forms'),
],
}

Nice! It is a good basis for our elements that we’re about to start constructing.

Let’s create a easy <SiteHeader/> element with navigation. In a brand new file – ./elements/siteHeader.vue, enter the next:

// ./elements/siteHeader.vue
<template>
<header class="site-header">
<div class="wrapper">
<determine class="site-logo">
<h3>Picture Gallery</h3>
</determine>
<nav class="site-nav">
<ul class="hyperlinks">
<li class="hyperlink">
<NuxtLink to="/">All images</NuxtLink>
</li>
</ul>
</nav>
</div>
</header>
</template>

Now that now we have a easy header, let’s create the house web page. Create a brand new file – ./pages/index.vue:

// ./pages/index.vue
<script setup>
useHead({
title: "All images",
});
</script>
<template>
<most important class="site-main photos-page">
<div class="wrapper">
<part class="gallery-section">
<div class="wrapper">
<header>
<h1 class="text-xl">All images</h1>
</header>
<!-- Picture gallery -->
</div>
</part>
</div>
</most important>
</template>

Lastly, in ./app.vue now we have so as to add the <SiteHeader/> and <NuxtPage/> elements:

// ./app.vue
<script setup>
// add web page meta
useHead({
titleTemplate: (title) => `${title} - Picture Gallery App`,
viewport: "width=device-width, initial-scale=1, maximum-scale=1",
charset: "utf-8",
meta: [
{
name: "description",
content: "Photo gallery app with Nuxt.js powered by Webiny",
},
],
hyperlink: [
{
rel: "icon",
type: "image/png",
href: "/favicon.png",
},
],
});
</script>
<template>
<SiteHeader />
<NuxtPage />
</template>

Now, if we run our app:

npm run dev

We must always begin to see the UI now we have created, one thing like this:




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/nuxt-ui-no-photos.png

Nice begin! Subsequent, we’ll see how we will fetch images from our GraphQL API.

Fetching Images From Webiny GraphQL API

On the core, we’ll be utilizing the Internet Fetch API to ship requests. Let’s arrange a number of issues.

1. Add API URL and Token to Nuxt.js Runtime Config

We’ll add our API URL and token to Nuxt.js Runtime Config and make the values globally accessible in our utility.

In ./nuxt.config.ts:

// ./nuxt.config.ts
import { defineNuxtConfig } from 'nuxt'
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
// ...
// https://v3.nuxtjs.org/information/options/runtime-config#exposing-runtime-config
// Expose runtime config to the remainder of the app
runtimeConfig: {
public: {
readToken: course of.env.WEBINY_VIEW_TOKEN,
readAPIURL: "https://mywebinyinstance.cloudfront.internet/cms/learn/en-US"
}
}
})

2. Create sendReq Helper Perform for Sending GraphQL Requests

Now that now we have our URL and token arrange in our runtime config, let’s create a helper perform to ship GraphQL requests. Create a brand new file: ./composables/sendReq.js

// ./composables/sendReq.js
// perform to ship requests
// go GraphQL URL and request choices
export const sendReq = async (graphqlURL, opts) => {
console.log({ graphqlURL, opts });
attempt {
let res = await fetch(graphqlURL, {
technique: "POST",
// fetch choices
...opts,
});
let consequence = await res.json();
console.log({ consequence, errors: consequence.error });
// Deal with request errors
if (consequence.error) {
// consequence.error.forEach((error) => alert(error.message));
// Throw an error to exit the attempt block
throw Error(JSON.stringify(consequence.error));
} else if (consequence.errors) {
consequence.error.forEach((error) => console.log({ error: error.message }));
// Throw an error to exit the attempt block
throw Error(JSON.stringify(consequence.errors));
}
// save consequence response to web page information state
return consequence.information;
} catch (error) {
console.log(error);
return {
errors: error,
};
}
};

With this perform within the ./composables listing, Nuxt.js robotically units up auto-imports for our sendReq perform in our Vue elements.

The subsequent factor now we have to do now, is to create a server API route that may get all images from our GraphQL API.

3. Create getAllPhotos API Server Route

In a brand new ./server/api/getAllPhotos.js file:

// ./server/api/getAllPhotos.js
import { sendReq } from "~~/composables/sendReq";
export default defineEventHandler(async (occasion) => {
const { readToken, readAPIURL } = useRuntimeConfig().public;
let photosQuery = {
question: `{
listPhotos{
information{
id
caption
photograph
creator{
title
username
}
}
}
}`,
};
const images = await sendReq(readAPIURL, {
physique: JSON.stringify(photosQuery),
headers: {
Authorization: `Bearer ${readToken}`,
"Content material-Sort": "utility/json",
},
});
return images;
});

Right here, you possibly can see that we’re importing the sendReq perform, passing in our readToken and readAPIURL to the perform, and return the consequence – images.

Now, if we ship a GET request to http://localhost:3000/api/getAllPhotos, we get our images:




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/test-api-get-request.png

Nice! Now we will show our pictures.

4. Create ImgItem Part to Show Picture

Let’s shortly create a <ImgItem /> element that takes photograph information as a prop and shows the picture and different related info.

Create a brand new file ./elements/ImgItem.vue:

// ./elements/ImgItem.vue -->
<script setup>
const { photograph, full } = defineProps(["photo", "full"]);

// encode ID in an effort to embody the # in URL
const linkID = encodeURIComponent(photograph.id);
</script>
<template>
// template for full web page view
<article class="photograph full" v-if="full">
<header class="author-details">
<h3 class="text-lg">{{ photograph.creator.title }}</h3>
<p>@{{ photograph.creator.username }}</p>
</header>
<determine>
<div class="img-cont">
<img :src="photograph.photograph" alt="picture" />
<div class="backdrop group-hover:opacity-100"></div>
</div>
<figcaption class="photo-caption">
<p>{{ photograph.caption }}</p>
</figcaption>
</determine>
</article>

// template for gallery preview
<determine class="photograph group" v-else>
<NuxtLink :to="`/photograph/${linkID}`">
<div class="img-cont">
<img :src="photograph.photograph" alt="picture" />
<div class="backdrop group-hover:opacity-100"></div>
</div>
<figcaption class="particulars">
<div class="author-details group-hover:opacity-100">
<h3 class="text-lg">{{ photograph.creator.title }}</h3>
<p>@{{ photograph.creator.username }}</p>
</div>
<p class="caption">
{{ photograph.caption }}
</p>
</figcaption>
</NuxtLink>
</determine>
</template>

Right here, you possibly can see our <ImgItem /> element does a number of issues:

  1. Encodes the id of the picture because of the #. It is because the # is ignored on the Nuxt.js server aspect. Encoding it into the URL is a technique to make sure it’s picked up by the useRoute() composable within the dynamic web page route.
  2. Passes the encoded id as a path to <NuxtLink>
  3. Conditionally renders a preview and huge view of the photograph utilizing a full prop. Right here, we render two variations of the element:
  • One for the big view when the element is used within the dynamic web page route, and
  • A smaller preview for when the element is used within the dwelling web page as gallery objects.

Now, on our dwelling web page, we will fetch the photograph information with the useFetch() composable and render an inventory of pictures with the <ImgItem /> element:

// ./pages/index.vue
<script setup>
const { information } = await useFetch("/api/getAllPhotos");
</script>
<template>
<most important class="site-main photos-page">
<div class="wrapper">
<part class="gallery-section">
<div class="wrapper">
<header>
<h1 class="text-xl">All images</h1>
</header>
<ul v-if="information && information?.listPhotos?.information" class="gallery">
<li
v-for="photograph in information.listPhotos.information"
:key="photograph.id"
class="gallery-item"
>
<ImgItem :photograph="photograph" />
</li>
</ul>
<div v-else class="gallery-error">
<p>Oops.. No images to show</p>
</div>
</div>
</part>
</div>
</most important>
</template>

We must always now have one thing like this:




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/nuxt-ui-images-recieved.png

Effectively carried out, you have got efficiently rendered pictures from Webiny’s file supervisor.

Subsequent, we’ll create a server path to fetch pictures by id in an effort to create a dynamic web page for every photograph.

Create photoById Server Route

In a brand new file **server/api/getPhotoById.js:

// server/api/getPhotoById.js
import { sendReq } from "~~/composables/sendReq";
export default defineEventHandler(async (occasion) => {
const { readToken, readAPIURL } = useRuntimeConfig().public;
const { id } = useQuery(occasion);
const photoQuery = {
question: `question($id: ID) {
getPhoto(the place: {id: $id}) {
information {
caption
photograph
creator {
title
username
}
}
}
}`,
variables: { id },
};
const photograph = await sendReq(readAPIURL, {
physique: JSON.stringify(photoQuery),
headers: {
Authorization: `Bearer ${readToken}`,
"Content material-Sort": "utility/json",
},
});
return photograph;
});

Right here, in our occasion handler, we’ve outlined our photoQuery with variable id which is accessed through the useQuery composable which returns question parameters from the occasion request URL.

We’ve additionally outlined a photoQuery object with question and variables. We are going to go the id of the photograph with variables.

Lastly, we ship the request utilizing sendReq and return the consequence.

Create a Dynamic Images Web page

Create a brand new file ./pages/photograph/[id].vue. Putting id inside a sq. bracket turns it right into a dynamic route parameter that we will entry to match with the present URL utilizing useRoute.

// ./pages/photograph/[id].vue
<script setup>
let {
params: { id },
} = useRoute();
// encode ID once more in an effort to embody # in URL
id = encodeURIComponent(id);
const { information: photograph } = await useAsyncData(id, () => {
return $fetch(`/api/getPhotoById?id=${id}`);
});
useHead({
title: photograph?.worth?.getPhoto?.information?.caption,
});
</script>
<template>
<most important class="site-main">
<div class="wrapper">
<part class="site-section">
<div v-if="photograph?.getPhoto" class="wrapper">
<ImgItem :photograph="photograph?.getPhoto?.information" :full="true" />
</div>
<div v-else class="wrapper">
<div class="gallery-error">
<p>Oops.. It appears an error occured</p>
</div>
</div>
</part>
</div>
</most important>
</template>

Right here, you possibly can see that we added :full="true" to our <ImgItem /> element in an effort to render a big model of the photograph. Now, if we click on on a photograph from the gallery, it ought to take us to the photograph web page which might look one thing like this:




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/nuxt-ui-image-single.png

Within the subsequent part, we’re going to stroll by way of how we will add photos and create images from our Nuxt.js frontend securely with the assistance of server routes.

Creating Picture Entries From the Frontend

In an effort to create a photograph entry, we want a picture. So now we have to add a picture file first.

Importing an Picture to Webiny

This can be carried out in three steps:

  • Add file to Webiny
    • Get a PreSignedPostPayload information with file information from Webiny
    • Add file to the Amazon S3 bucket with the PreSignedPostPayload information
    • Add the file to the Webiny file supervisor utilizing the createFile question
  • Get creator by username
    • If the creator with the username doesn’t exist, create and publish a brand new creator with that username
  • Create photograph entry with file URL and creator ID

You’ll find extra about these steps from this information on importing recordsdata to Webiny.

In an effort to obtain all that with our Nuxt.js frontend, we have to create an API key that may in a position to have full entry to the CMS and file supervisor.

Create a Most important API Key

We’ll name this new API key the “most important” API key. As normal, in our Webiny dashboard, open the aspect menu to navigate to SETTINGS > ACCESS MANAGEMENT > API KEYS Create a brand new entry with entry to all locales and full entry to the Headless CMS




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/api-key-main-create.png

Additionally give it full entry to the file supervisor:




./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/api-key-main-full-access.png

Save the API key and add it to the .env file of the Nuxt.js venture as WEBINY_MAIN_TOKEN.

Configure Nuxt.js Config

Now we will add the newly created API key together with the primary and handle URLs of our  GraphQL endpoints to our nuxt.config.ts file:

// ./nuxt.config.ts
import { defineNuxtConfig } from 'nuxt'
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
// ...
runtimeConfig: {
mainToken: course of.env.WEBINY_MAIN_TOKEN,
mainAPIURL: course of.env.WEBINY_MAIN_URL,
manageAPIURL: course of.env.WEBINY_MANAGE_URL,
public: {
readToken: course of.env.WEBINY_VIEW_TOKEN,
readAPIURL: course of.env.WEBINY_READ_URL
}
}
})

Right here, you’ll discover that we didn’t add the brand new mainToken, mainAPIURL and manageAPIURL to the public property in order that it could actually solely be used on the server aspect.

Now we will proceed with our steps to add a file to Webiny. First, we’ll create a bunch of server API routes:

  • /api/getPresignedPostData: Receives the file title, measurement, and sort of file and sends a request to get the pre-signed post-payload that can be used to add the file. This enables the file add to be carried out on the entrance finish.
  • /api/createFile: After the file has been uploaded, this route receives the title, key, sort, measurement of the uploaded file and sends a CreateFile mutation to Webiny so as to add the file to the file supervisor.
  • /api/getAuthorByUsername: Receives the username as a question parameter and makes a request to Webiny to see if that creator exists and responds with the person info if the creator exists.
  • /api/createAuthor: Receives the username and title of the creator and runs a createAuthor mutation to create the brand new creator after which runs a publishAuthor mutation to publish the newly created creator.
  • /api/createPhoto: Receives the caption, URL of the picture, and the authorId which is the id of the creator. Then it runs a createPhoto mutation to create the photograph entry.
    One other mutation – publishPhoto to publish the newly created photograph entry.

Create getPresignedPostData Route

Create a brand new file – ./server/api/getPresignedPostData.publish.js

// ./server/api/getPresignedPostData.publish.js

import { sendReq } from "~~/composables/sendReq";
const { mainToken, mainAPIURL } = useRuntimeConfig();

export default defineEventHandler(async (occasion) => {
const { title, sort, measurement } = await useBody(occasion);

const dataQuery = {
question: `question($information: PreSignedPostPayloadInput!) {
fileManager {
getPreSignedPostPayload(information: $information) {
information {
information
file {
title
sort
measurement
key
}
}
}
}
}
`,
variables: { information: { title, sort, measurement } },
};

const information = await sendReq(mainAPIURL, {
physique: JSON.stringify(dataQuery),
headers: {
Authorization: `Bearer ${mainToken}`,
"Content material-Sort": "utility/json",
},
});

return information ? information : (occasion.res.statusCode = 400);
});

Right here, you’ll discover the .publish in our route handler file title. It is a method to match the request HTTP technique. This allows our path to deal with POST requests. We additionally use the useBody composable within the occasion handler to get our information from the request physique which we use as variables for our getPreSignedPostPayload question. We then make this request utilizing the sendReq perform to the mainAPIURL utilizing the mainToken

Create createFile Route

Create a brand new file – ./server/api/createFile.publish.js

// ./server/api/createFile.publish.js
import { sendReq } from "~~/composables/sendReq";
const { mainAPIURL, mainToken } = useRuntimeConfig();
export default defineEventHandler(async (occasion) => {
const { title, key, sort, measurement, tags } = await useBody(occasion);

const createFileMutation = {
question: `mutation CreateFile($information: FileInput!) {
fileManager {
createFile(information: $information) {
error {
code
message
information
}
information {
id
title
key
src
measurement
sort
tags
createdOn
createdBy {
id
}
}
}
}
}`,
variables: {
information: {
sort,
title,
measurement,
key,
tags: tags ? tags : [],
},
},
};

const information = await sendReq(mainAPIURL, {
physique: JSON.stringify(createFileMutation),
headers: {
Authorization: `Bearer ${mainToken}`,
"Content material-Sort": "utility/json",
},
});

return information ? information : (occasion.res.statusCode = 400);
});

As talked about earlier, after the file has been uploaded, this route receives the title, key, sort, measurementand tags of the uploaded file and provides it to the file supervisor by making CreateFile mutation.

Create getAuthorByUsername Route

Create a brand new file – ./server/api/getAuthorByUsername.js

// ./server/api/getAuthorByUsername.js
import { sendReq } from "~~/composables/sendReq";
export default defineEventHandler(async (occasion) => {
const { readToken, readAPIURL } = useRuntimeConfig().public;
const { username } = useQuery(occasion);

const authorQuery = {
question: `question($username: String!) {
getAuthor(the place: { username: $username }) {
information {
id
title
username
}
}
}
`,
variables: { username },
};
const creator = await sendReq(readAPIURL, {
physique: JSON.stringify(authorQuery),
headers: {
Authorization: `Bearer ${readToken}`,
"Content material-Sort": "utility/json",
},
});

return creator.getAuthor;
});

This route receives the creator username as a question parameter so the useQuery composable is used to entry it. Then it sends a getAuthor question to get the creator by its username.

Create createAuthor Route

Create a brand new file – ./server/api/createAuthor.publish.js

// ./server/api/createAuthor.publish.js
import { sendReq } from "~~/composables/sendReq";
const { manageAPIURL, mainToken } = useRuntimeConfig();
// perform to create creator entry
const createAuthor = async ({ username, title, images }) => {
const createAuthorMutation = {
question: `mutation($authorInput: AuthorInput!){
createAuthor(information: $authorInput){
information{
id
}
}
}`,
variables: {
authorInput: {
username,
title,
images: images ? images : [],
},
},
};

const res = await sendReq(manageAPIURL, {
physique: JSON.stringify(createAuthorMutation),
headers: {
Authorization: `Bearer ${mainToken}`,
"Content material-Sort": "utility/json",
},
});
return res;
};

// perform to publish created creator entry
const publishAuthor = async (id) => {
const publishAuthorMutation = {
question: `mutation($id: ID!){
publishAuthor(revision: $id){
information{
id
username
title
}
}
}`,
variables: {
id,
},
};
const res = await sendReq(manageAPIURL, {
physique: JSON.stringify(publishAuthorMutation),
headers: {
Authorization: `Bearer ${mainToken}`,
"Content material-Sort": "utility/json",
},
});

return res;
};

export default defineEventHandler(async (occasion) => {
attempt {
const physique = await useBody(occasion);

// STEP 1
// Create Creator
const createAuthorRes = await createAuthor(physique);

// get id of newly created Creator
const { id } = createAuthorRes.createAuthor.information;

// STEP 2
// Publish Creator
const publishAuthorRes = await publishAuthor(id);

return publishAuthorRes.publishAuthor.information;
} catch (error) {
return { statusCode: (occasion.res.statusCode = 400), message: error };
}
});

This route receives the username, title and images of the creator and creates the creator in two steps:

  • Step 1: Create creator with the createAuthor perform
  • Step 2: Publish creator with the publishAuthor perform

Create CreatePhoto Route

Create a brand new file – ./server/api/createPhoto.publish.js

// ./server/api/createPhoto.publish.js

import { sendReq } from "~~/composables/sendReq";
const { manageAPIURL, mainToken } = useRuntimeConfig();

// perform to create photograph entry
const createPhoto = async ({ caption, url, authorId }) => {
const createPhotoMutation = {
question: `mutation($photoInput: PhotoInput!){
createPhoto(information: $photoInput){
information{
id
caption
photograph
creator{
id
}
}
}
}`,
variables: {
photoInput: {
caption,
photograph: url,
creator: { modelId: "creator", id: authorId },
},
},
};

const res = await sendReq(manageAPIURL, {
physique: JSON.stringify(createPhotoMutation),
headers: {
Authorization: `Bearer ${mainToken}`,
"Content material-Sort": "utility/json",
},
});
return res;
};
// perform to publish created photograph entry
const publishPhoto = async (id) => {
const publishPhotoMutation = {
question: `mutation($id: ID!){
publishPhoto(revision:$id){
information{
id
caption
photograph
creator{
id
}
meta{
standing
revisions{
id
}
}
}
}
}`,
variables: {
id,
},
};
const res = await sendReq(manageAPIURL, {
physique: JSON.stringify(publishPhotoMutation),
headers: {
Authorization: `Bearer ${mainToken}`,
"Content material-Sort": "utility/json",
},
});
return res;
};
export default defineEventHandler(async (occasion) => {
attempt {
const physique = await useBody(occasion);

// STEP 1
// Create Picture
const createPhotoRes = await createPhoto(physique);

// get id of newly created photograph
const { id } = createPhotoRes.createPhoto.information;

// STEP 2
// Publish Picture
const publishPhotoRes = await publishPhoto(id);

// return printed photograph
return publishPhotoRes.publishPhoto.information;
} catch (error) {

return { statusCode: (occasion.res.statusCode = 400), message: error };
}
});

Right here, we obtain caption, url and authorId. We additionally create the photograph entry in two steps:

  • Step 1: Create photograph with the createPhoto perform
  • Step 2: Publish creator with the publishPhoto perform

Now that our routes have been created, we subsequent need to create a brand new web page in Nuxt.js to sew all these collectively.

Create a PhotoForm Part

So as to have the ability to make all these requests above and add a file to create a photograph entry, now we have to create a element to deal with it. Create a brand new element ./elements/PhotoForm.vue and enter the next code for the <script> part:

// ./elements/PhotoForm.vue
<script setup>
// get photograph information from props, if any is supplied
const { photoData, mode } = defineProps(["photoData", "mode"]);

// preliminary web page state
// conditionally set default values incase photograph information is just not outlined in props
const file = ref( "",
);
const caption = ref(photoData?.caption || "Caption");
const username = ref(photoData?.creator.username || "miracleio");
const title = ref(photoData?.creator.title || "");
const userId = ref(photoData?.creator.id || "");

const authorExists = ref(false);
const isLoading = ref(false);
const information = ref({});

// init toast element state
const toastState = useToastState();
const setToastState = useSetToastState;
// header information for use in POST fetch requests
let headers = {
technique: "POST",
"Content material-Sort": "utility/json",
};
// perform to reset type state
const resetForm = (e) => {
e.goal.reset();
file.worth = null;
caption.worth = "";
username.worth = "";
title.worth = "";
userId.worth = null;
authorExists.worth = false;
isLoading.worth = false;
information.worth = {};
};
// perform to deal with file choice
const handleFileSelect = (e) => {
// get file from file enter button
file.worth = e.goal.recordsdata[0];
// create a short lived url for use as `src` of preview picture
file.worth.url = URL.createObjectURL(file.worth);
console.log({ file, url: file.worth.url });
// examine if file chosen is definitely a picture
let isImage = file.worth.sort.contains("picture");
console.log({ isImage });
// alert person and clear enter and file values
if (!isImage) {
alert("Please choose a legitimate picture file");
e.goal.worth = "";
file.worth = null;
}
};
// perform to get presigned payload information from webiny
const getPresignedPostData = async (fileData) => {
console.log({ fileData });
const res = await (
await fetch("/api/getPresignedPostData", {
...headers,
physique: JSON.stringify(fileData),
})
).json();
return res.fileManager.getPreSignedPostPayload.information;
};
// perform to add file to s3 bucket
const uploadToS3 = async ({ url, fields }, file) => {
console.log({ url, fields, file });
const formData = new FormData();
Object.keys(fields).forEach((key) => {
formData.append(key, fields[key]);
});
// Precise file needs to be appended final.
formData.append("file", file);
const res = await fetch(url, {
technique: "POST",
"Content material-Sort": "multipart/form-data",
physique: formData,
});
return res;
};
// perform to get payload information and add file to s3 bucket
const uploadFile = async (fileData) => {
// get payload information
let preSignedPostPayload = await getPresignedPostData(fileData);
// add file to s3 bucket with payload information
let add = await uploadToS3(preSignedPostPayload.information, file.worth);
return { standing: add.standing, preSignedPostPayload };
};
// perform to create file in webiny dashboard
const createFile = async ({ title, key, sort, measurement, tags }) => {
console.log({ title, key, sort, measurement, tags });
const res = await (
await fetch("api/createFile", {
...headers,
physique: JSON.stringify({ title, key, sort, measurement, tags }),
})
).json();
console.log({ res });
return res.fileManager.createFile.information;
};
// perform to create photograph entry
const createPhoto = async ({ caption, url, authorId }) => {
let photoData = { caption, url, authorId };
console.log({ photoData });
const res = await (
await fetch("/api/createPhoto", {
...headers,
physique: JSON.stringify(photoData),
})
).json();
return res;
};
// perform to examine if creator exists
const checkAuthor = async () => {
isLoading.worth = true;
setToastState({
message: `Checking for @${username.worth}`,
});
attempt {
const res = await (
await fetch(`api/getAuthorByUsername?username=${username.worth}`)
).json();
if (!res.information?.id) throw Error("No creator discovered");
userId.worth = res.information.id;
title.worth = res.information.title;
authorExists.worth = true;
isLoading.worth = false;
setToastState({
message: `✅ Discovered creator for @${username.worth}`,
code: "success",
});
return res.information;
} catch (error) {
console.log({ error });
authorExists.worth = false;
setToastState({
message: `No creator discovered for @${username.worth}.
You'll be able to proceed to add and a brand new creator can be created`,
code: "error",
});
isLoading.worth = false;
return null;
}
};
// perform to create creator
const createAuthor = async (userData) => {
let { username, title } = userData;
const res = await (
await fetch("api/createAuthor", {
...headers,
physique: JSON.stringify({ username, title }),
})
).json();
return res;
};
// perform to deal with type submit motion
const handlePhotoSubmit = async (e) => {
e.preventDefault();
// examine if creator with present username exists
let creator = await checkAuthor();
// activate loading state
isLoading.worth = true;
// affirm whether or not to proceed with chosen person title
if (authorExists.worth) {
let confirmUsername = affirm(
`The username @${username.worth} is taken. Is ${title.worth} the creator of this picture?`
);
// cancel course of if person doesn't affirm to proceed
if (!confirmUsername) return null;
} else {
// affirm new person creation
let confirmUsername = affirm(
`A brand new person for the username @${username.worth} can be created for this photograph. Is ${title.worth} the creator of this picture?`
);
// cancel course of if person doesn't affirm to proceed
if (!confirmUsername) return null;
// replace toast state
setToastState({
message: `Creating new creator for @${username.worth}`,
});
// set native creator state worth to newly created creator
creator = await createAuthor({
username: username.worth,
title: title.worth,
});
console.log({ creator });
// replace toast state
setToastState({
message: `Creator for @${username.worth} created efficiently`,
code: "success",
});
}
// get chosen file particulars
let { title: fileName, sort, measurement } = file.worth;
console.log({ fileName, sort, measurement });
attempt {
// replace toast state
setToastState({
message: `Importing file to storage...`,
});
// begin add file processes
// rename destructured values returned by `uploadFile` in an effort to aviod title conflicts
const {
standing,
preSignedPostPayload: {
file: { key, title: _name, measurement: _size, sort: _type },
},
} = await uploadFile({ title: fileName, sort, measurement });
console.log({ key, _name, _size, _type });
// throw error if uploadFile standing doesn't return 204 code
if (standing == !204) {
throw Error("Unable add. An error occured");
}
// replace toast state
setToastState({
message: `File uploaded efficiently!`,
code: "success",
});
// replace toast state
setToastState({
message: `Including file to dashboard...`,
});
// create file in webiny dashboard
const file = await createFile({
key,
title: _name,
measurement: _size,
sort: _type,
});
console.log({ file });
console.log({ username, title, caption });
// replace toast state
setToastState({
message: `File added efficiently!`,
code: "success",
});
console.log({ caption: caption.worth, url: file.src, authorId: creator.id });
// replace toast state
setToastState({
message: `Creating photograph entry...`,
});
// create photograph entry
const photograph = await createPhoto({
caption: caption.worth,
url: file.src,
authorId: creator.id,
});
console.log({ photograph });
// save last information to state
information.worth = photograph;
console.log({ information });
// replace toast state
setToastState({
message: `Picture created efficiently!`,
code: "success",
});
// reset type state
resetForm(e);
} catch (error) {
console.log({ error });
// set information to null
information.worth = null;
error.worth = error;
// replace toast state with error
setToastState({
message: `An error occured: ${error}`,
code: "error",
});
}
// finish loading state
isLoading.worth = false;
};
</script>

That is rather a lot however let’s break it down and listing out the features outlined right here and what they do:

  • handleFileSelect(e): This perform is fired by the @change handler of the file enter and will get the chosen file and checks if the file is a picture.
    Whether it is, it then saves the worth of the chosen file to file ref.
  • getPresignedPostData(fileData): Makes a request to our /api/getPresignedPostData endpoint with the chosen file information after which returns the payload.
  • uploadToS3({ url, fields }, file): Takes within the url and fields information supplied by the payload and file information contained within the element and makes a request to the url supplied with the fields and file information to add. As soon as the add is full, it responds with a 204 success code which the perform returns.
  • uploadFile(fileData): Runs the earlier two features getPresignedPostData and uploadToS3 and returns the add standing and payload information.
  • createFile({ title, key, sort, measurement, tags }): Makes a request to our api/createFile endpoint so as to add the file to our Webiny dashboard file supervisor.
  • createPhoto({ caption, url, authorId }): Makes a request to api/createPhoto to create and publish a brand new photograph entry
  • checkAuthor(): Takes the username from the username ref and makes a request to the api/getAuthorByUsername which returns the creator information or null if that creator doesn’t exist but.
  • createAuthor(userData):Takes within the supplied person information and makes a request to api/createAuthor to create and publish a brand new creator with the small print.
  • handlePhotoSubmit(e): Most significantly, now we have this perform that ties all the pieces collectively and runs when the shape is submitted. It:
    1. checks if creator with the present username exists by assigning native variable creator to chechAuthor() which returns null if the creator doesn’t exist
    2. prompts loading state by setting isLoading.worth = true
    3. Runs a situation to examine if the creator exists, if it does, it prompts the person to substantiate if to proceed with the present person and if no creator exists, it notifies the person {that a} new creator can be created.
    4. Begins the file add and photograph creation course of by working:
      • uploadFile
      • createFile and
      • createPhoto
    5. Saves the created photograph information to element state information.worth = photograph
    6. Reset the shape by working resetForm(e)
    7. Runs setToastState() on the totally different levels of the creation course of to inform the person on the progress of the photograph creation.
  • resetForm(e): Lastly, resets the element state and clears the shape

Now, let’s add the <template> a part of our element:

<!-- ./elements/PhotoForm.vue -->
<script setup> ... </script>
<template>
<part class="upload-section">
<header class="upload-header">
<slot title="header">
<h1>Add a photograph</h1>
</slot>
</header>
<!-- Preview the picture file with the URL supplied -->
<div class="file-preview">
<div v-if="file" class="img-cont">
<img :src="file.url" alt="" />
</div>
</div>
<!-- Type container -->
<div class="upload-cont">
<type @submit="handlePhotoSubmit" id="upload-form" class="type">
<div class="wrapper">
<div class="form-section form-control add">
<enter
@change="handleFileSelect"
sort="file"
title="photograph"
id="photograph"
settle for="picture/*"
required
/>
</div>
<div class="form-section">
<div class="form-control">
<label for="caption">Caption</label>
<enter
id="caption"
title="caption"
sort="textual content"
class="form-input"
required
v-model="caption"
:disabled="isLoading"
/>
</div>
<div class="form-group">
<div class="form-control">
<label for="username">Username</label>
<enter
id="username"
title="username"
sort="textual content"
class="form-input"
required
v-model="username"
@change="authorExists = false"
:disabled="isLoading"
/>
</div>
<div class="form-control">
<label for="title">Title</label>
<enter
id="title"
title="title"
sort="textual content"
class="form-input"
required
:worth="title"
@change="(e) => !authorExists && (title = e.goal.worth)"
:disabled="isLoading || authorExists"
/>
</div>
<div class="action-cont check-user">
<button
@click on="checkAuthor"
:class="{ legitimate: authorExists }"
class="cta alt"
sort="button"
:disabled="authorExists"
>
{{ isLoading ? "..." : authorExists ? "✅" : "🔍" }}
</button>
</div>
</div>
</div>
<div class="action-cont">
<button sort="submit" class="cta" :disabled="isLoading">
{{
isLoading
? "..."
: authorExists
? "Add"
: "Add as new Creator"
}}
</button>
</div>
</div>
</type>
</div>
</part>
</template>

Earlier than we will run this, nonetheless, we have to arrange our toastState composable which permits us to handle the state of our <Toast /> element used to tell the person of the standing of the method.

Set Up toastState Composable

Create a brand new file: ./composables/toastState.js:

export const useToastState = () => {
return useState("toast-state", () => ({
message: "✅ It is Quiet now...",
code: "success",
lively: false,
time: 8000,
}));
};
export const useSetToastState = ({
message,
code = "loading",
lively = true,
time = 8000,
}) => {
return useState("set-toast-state", () => {
message && (useToastState().worth.message = message);
useToastState().worth.code = code;
useToastState().worth.lively = lively;
useToastState().worth.time = time;
console.log({ useToastState: useToastState().worth });
});
};

Right here, now we have two most important features:

  • useToastState returns the state values
  • useSetToastState modifies the useToastState values

Now that now we have outlined the state, we will create the <Toast /> element

Create <Toast /> Part

Create a brand new file – ./elements/Toast.vue

// ./elements/Toast.vue
<script setup>
// init element state
const state = useToastState();
const setState = useSetToastState;

// variable to retailer SetTimeout ID
let timeout;

// computed property to dynamically assign the `code` and `lively` state to element class
const computedClass = computed(() => {
let code = state.worth.code;
let lively = state.worth.lively;
return {
success: code == "success",
error: code == "error",
loading: code == "loading",
lively,
};
});

// Look ahead to change in state
// arrange timeout performance to robotically reset and conceal element
watch(state.worth, (worth) => {
console.log({
message: worth.message,
code: worth.code,
lively: worth.lively,
});
if (state.worth.lively) {
clearTimeout(timeout);
timeout = setTimeout(() => {
setState({ lively: false });
console.log({ state: state.worth });
}, state.worth.time);
}
});
</script>
<template>
<div class="toast" :class="computedClass">
<div class="wrapper">
<slot> {{ state.message }}</slot>
</div>
</div>
</template>

Nice! Now we will create the web page that may render our type.

Create /new Web page to Render the Type

Create a brand new file – ./pages/new.vue

// ./pages/new.vue
<script setup>
// add web page meta
useHead({
title: "Add new",
});
</script>
<template>
<most important class="site-main new">
<div class="wrapper">
<PhotoForm>
<template v-slot:header>
<h1>Create a Picture</h1>
</template>
</PhotoForm>
</div>
</most important>
</template>

Again in ./elements/SiteHeader.vue we will add a hyperlink to our new web page:

<!-- ./elements/siteHeader.vue -->
<template>
<header class="site-header">
<div class="wrapper">
<!-- ... -->
<nav class="site-nav">
<ul class="hyperlinks">
<!-- ... -->
<li class="hyperlink">
<NuxtLink to="/new">New</NuxtLink>
</li>
</ul>
</nav>
</div>
</header>
</template>

Now if we begin our dev server, we should always be capable of add pictures as proven within the video under:

./assets/build-photo-sharing-app-nuxt-webiny-headless-cms/nuxt-ui-file-upload.gif

Conclusion

Thus far we’ve seen how we will use the Headless CMS characteristic of Webiny to energy a photograph gallery app constructed with Nuxt.js. We lined construction our CMS by creating the content material fashions we want which incorporates:

  • Images content material mannequin
  • Creator content material mannequin

We additionally checked out create API Tokens to entry content material at particular permission ranges.
With the API tokens, we had been in a position to create server routes with GraphQL requests within the Nuxt.js venture, which in flip permits us to fetch and create information in our Headless CMS with out exposing our API tokens.

One other essential idea we lined was add recordsdata to our Webiny dashboard which includes:

  • Get a PreSignedPostPayload information with file information from Webiny
  • Add file to the Amazon S3 bucket with the PreSignedPostPayload information
  • Add the file to the Webiny file supervisor utilizing the createFile question

With that, we had been in a position so as to add the file URL to the Picture entry utilizing the CreatePhoto mutation.

Full supply code: https://github.com/webiny/write-with-webiny/tree/most important/tutorials/nuxt-photo-sharing-app


This text was written by a contributor to the Write with Webiny program. Would you want to jot down a technical article like this and receives a commission to take action? Try the Write with Webiny GitHub repo.



RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular

Recent Comments