Building a Photo Sharing App with Nuxt 3, GraphQL, Cloudinary, Postgres and Strapi
Tags: cloudinary, nuxt, graphql, postgres
Published: 2022-07-26 08:52:59

---
canonical_url: https://strapi.io/blog/how-to-build-a-photo-sharing-app-with-nuxt-3-graph-ql-cloudinary-postgres-and-strapi?utm_campaign=Strapi%20Blog&utm_source=devto&utm_medium=blog
---

_In this tutorial we’ll build an SSR application with Nuxt and integrate GraphQL, Postgres and Cloudinary with Strapi._

**Author: Miracle Onyenma**

A headless CMS is a great tool for building modern applications. Unlike a traditional monolithic CMS like WordPress and Drupal, a headless CMS allows developers to connect their content to any frontend architecture of their choice, allowing for more flexibility, functionality and performance.

With a customizable Headless CMS like [Strapi](https://strapi.io) for example, we have the ability to choose a database for our content, integrate a media library and even expand the backend functionality using plugins that can be found on the Strapi marketplace. 

## Overview

In this tutorial, we'll cover the concept of a Headless CMS and its benefits. We'll set up a working Strapi backend with PostgreSQL as our database and Cloudinary for image uploads. 

We'll also look into how we can build our frontend using Nuxt 3 which gives us SSR support right out of the box and is compatible with Vue3. We're also going to set up GraphQL instead of the default REST API.


## Goals

At the end of this tutorial, we'd have created a modern photo sharing app where users can sign in and upload photos and add comments.

We will learn how to set up Strapi with Postgres and integrate our Strapi backend with the Strapi comments plugin. We’ll also be able to build the frontend with the new Vue 3 Composition API, GraphQL, and SSR.


## An Overview of Headless CMS, Strapi & Postgres

Let’s take a quick look at these concepts and technologies that are at the core of our backend.

### Headless CMS
Headless CMS is a type of Content Management System (CMS) that usually provides an Application Programming Interface (API) which allows the frontend to be built independently from the backend (or CMS). Unlike traditional CMS options like WordPress, a Headless CMS allows developers to build the frontend of their application with any framework of their choice.

#### Strapi
Strapi is a world-leading open-source headless CMS. Strapi makes it very easy to build custom APIs either REST APIs or GraphQL that can be consumed by any client or front-end framework of choice.

Strapi runs an HTTP server based on [Koa, a back-end JavaScript framework.](https://koajs.com/) It also supports databases like SQLite and Postgres (which we’ll be using in this tutorial).

#### Postgres
PostgreSQL, also known as Postgres, is a free and open-source relational database management system emphasizing extensibility and SQL compliance. As one of the databases Strapi currently supports, Postgres is a solid choice over SQLite and we’ll see how we can set it up.


## Prerequisites

To follow this tutorial, you’ll need to have a few things in place, including:

- Basic JavaScript knowledge,
- Node.js (I’ll be using v16.13.0), and
- A code editor. I’ll be using VScode; you can get it from [the official website](https://code.visualstudio.com/download).


## Step 1: Set up Backend with Strapi and Postgres

First, we’ll create a new Strapi app. To do this:
* Navigate to the directory of your choice and run:
```bash
    yarn create strapi-app photo-app-api
```
* Next, Choose the installation type:
> Quickstart is recommended, which uses the default database (SQLite)

Once the installation is complete, the Strapi admin dashboard should automatically open in your browser at `http://localhost:1337/admin/`. 

* Next, download and install Postgres for your machine from the [Postgres download page](https://www.postgresql.org/download/). 

> For windows users, refer to this guide on how to [Set Up a PostgreSQL Database on Windows](https://www.microfocus.com/documentation/idol/IDOL_12_0/MediaServer/Guides/html/English/Content/Getting_Started/Configure/_TRN_Set_up_PostgreSQL.htm). Also, refer to the [Postgres documentation to see how to create a new database](https://www.postgresql.org/docs/14/tutorial-createdb.html).

* Once installed, start the Postgres server and obtain the *port, username, and password.* 

* To create a new database, in your terminal, run:
```bash
    createdb -U postgres photosdb
```
* Then, enter the password for your `postgres` superuser

### Configure Postgres in Strapi
To use PostgreSQL in Strapi, we’ll add some configurations in our `./config/database.js` file.

* Open the `./config/database.js` and paste the below code in the file:

```js
    // ./config/database.js
    module.exports = ({ env }) => ({
      connection: {
        client: 'postgres',
        connection: {
          host: env('DATABASE_HOST', 'localhost'),
          port: env.int('DATABASE_PORT', 5432),
          database: env('DATABASE_NAME', 'photosdb'),
          user: env('DATABASE_USERNAME', 'user'),
          password: env('DATABASE_PASSWORD', '1234'),
          schema: env('DATABASE_SCHEMA', 'public'), // Not required
          ssl: {
            rejectUnauthorized: env.bool('DATABASE_SSL_SELF', false),
          },
        },
        debug: false,
      },
    });
```

- In the `connection` object, we set the `client` to `postgres`. This client is the PostgreSQL database client to create the connection to the DB.
- The `host` is the hostname of the PostgreSQL server we set it to `localhost`.
- The `port` is set to `5432`, and this is the default port of the PostgreSQL server.
- The `database` is set to the `photosdb`, and this is the name of the database we created in the PostgreSQL server.
- The `password` is the password of our PostgreSQL server.
- The `username` is the username of our PostgreSQL. It is set to `Postgres` because it is the username of our PostgreSQL server.
- The `schema` is the database schema, and it is set to the `public` here. This schema is used to expose databases to the public.

With this, our Strapi is using PostgreSQL to store data. 
* Now, start Strapi.

```bash
    yarn develop
```
**N/B:** If you encounter an error: `Cannot find module 'pg'`, install `pg` . Run:

```bash
    yarn add pg 
    #OR
    npm install pg
```

Once the app has started, it should open the admin registration page at `http://localhost:1337`. **Proceed to create an admin account.**

#### Set up GraphQL with Strapi
To get started with GraphQL in our Strapi backend, we’ll install the GraphQL plugin first. To do that:
* Stop the server and run the following command:
```bash
    yarn strapi install graphql
    #OR
    npm run strapi install graphql
```

* Then, start the app and open your browser at [http://localhost:1337/graphql](http://localhost:1337/graphql). We’ll see the interface (**GraphQL Playground**) that will help us write GraphQL query to explore our data.

#### Create Post Content Type
Let’s create the content type for our posts. 
* Navigate to ***CONTENT-TYPE BUILDER > COLLECTION TYPES > CREATE NEW COLLECTION TYPE***.
* First, in the **Configurations** modal, set the display name - `**Post**`, then create the fields for the collection

    - `Caption` - Text (Long Text)
    - `photo` - Media (Multiple media)
    - `user` - Relation (one-to-many relation with `Users` from `users-permissions`)

The collection type should look like this:

![Collection type structure for post](https://paper-attachments.dropbox.com/s_81A70859F8B66737FB32A08BCA6F0249ACDBDA7A963E3F0A04B43A4D285336AF_1654103214894_image.png)

* Click on **SAVE** and wait for the server to restart.

### Set up Strapi Comments Plugin 
Finally, we have to install the Strapi comments plugin. 

* Stop the server and run:
```bash
    yarn add strapi-plugin-comments@latest
```
* Next, in order to properly add the types and access our comments actions with GraphQL, we have to create a plugin config for `comments`.

```js
    // ./config/plugins.js
    
    module.exports = ({ env }) => ({
      //...
      comments: {
        relatedContentTypes: {
          posts: {
            contentManager: true,
            isSingle: true,
            key: "title",
            value: "id",
          },
        },
        enabled: true,
        config: {
          enabledCollections:["api::post.post"],
          badWords: false,
          moderatorRoles: ["Authenticated"],
          approvalFlow: ["api::post.post"],
          entryLabel: {
            "*": ["Title", "title", "Name", "name", "Subject", "subject"],
            "api::post.post": ["MyField"],
          },
          reportReasons: {
            MY_CUSTOM_REASON: "MY_CUSTOM_REASON",
          },
          gql: {
            // ...
          },
        },
      },
      graphql: {
      }
    });
```

You can see more [information on configuration from the Strapi Comments plugin docs.](https://github.com/VirtusLab-Open-Source/strapi-plugin-comments#in-v202-and-older--default-configuration-state-for-v203-and-newer)

* We now have to rebuild and start our app; run:
```bash
    yarn build && yarn develop
```
When we open our admin dashboard, the **Comments** plugin should appear in the **Plugins** section of Strapi sidebar.

![Comments plugin added](https://paper-attachments.dropbox.com/s_81A70859F8B66737FB32A08BCA6F0249ACDBDA7A963E3F0A04B43A4D285336AF_1653987752186_image.png)


* Next, we’ll configure our Comments plugin.
Navigate to ***SETTINGS > COMMENTS PLUGIN > CONFIGURATION*** then confgure the following settings:

    - General configuration - *Enable comments only for* : **`Posts`**
    - Additional configuration  
    - Bad words filtering: **`Enabled`**
    - GraphQL queries authorization: **`Enabled`**

* Click on **SAVE** to save the configuration and restart the server for the changes to take effect.

#### Configure Permissions for Public and Authenticated Users
By default, we won't be able to access any API from Strapi unless we set the permissions. 
* To set the permissions, navigate to **SETTINGS > USERS & PERMISSIONS PLUGIN > ROLES.**
* Go to **PUBLIC** and enable the following actions for

    - Posts
        - ✅ `find`
        - ✅ `findOne`
    
![Enable actions for posts under Public](https://paper-attachments.dropbox.com/s_81A70859F8B66737FB32A08BCA6F0249ACDBDA7A963E3F0A04B43A4D285336AF_1653991026238_image.png)

   - Comments
        - ✅ `findAllFlat`
        - ✅ `findAllInHierarchy`

![Enable actions for comments under Public](https://paper-attachments.dropbox.com/s_81A70859F8B66737FB32A08BCA6F0249ACDBDA7A963E3F0A04B43A4D285336AF_1653991007758_image.png)

- Users-permissions
    - ✅ User > `findOne`
    - ✅ User > `find`
    
![Enable actions for user-permissions under public](https://paper-attachments.dropbox.com/s_81A70859F8B66737FB32A08BCA6F0249ACDBDA7A963E3F0A04B43A4D285336AF_1654243170776_image.png)

Next, to set permissions for authenticated users, go to **AUTHENTICATED** and enable the following actions for

- Posts - Enable all ✅


![Enable all actions for posts under Authenticated](https://paper-attachments.dropbox.com/s_81A70859F8B66737FB32A08BCA6F0249ACDBDA7A963E3F0A04B43A4D285336AF_1653991061781_image.png)



- Comments - Enable all ✅
    
![Enable all actions for comments under Authenticated](https://paper-attachments.dropbox.com/s_81A70859F8B66737FB32A08BCA6F0249ACDBDA7A963E3F0A04B43A4D285336AF_1653991044406_image.png)

- Users-permissions
    - ✅ User > `count`
    - ✅ User > `findOne`
    - ✅ User > `find`


![Enable permissions for user-permissions under Authenticated](https://paper-attachments.dropbox.com/s_81A70859F8B66737FB32A08BCA6F0249ACDBDA7A963E3F0A04B43A4D285336AF_1654243369218_image.png)



## Step 2: Setting up the Frontend
In the second stage, we are going to set up our frontend. 

* To create our Nuxt 3 frontend, run:
```bash
    npx nuxi init photos-app-frontend
```
* Next, navigate into the newly-created project folder and install.
```bash
    cd photos-app-frontend
    yarn install
    #OR
    npm install
```

### Add Tailwind CSS
Once the installation is complete, add Tailwind CSS to the project using the `@nuxt/tailwind` module. We’ll also install the tailwind form plugin. This module helps set up [Tailwind CSS](https://tailwindcss.com/) (version 3) in our [Nuxt 3](https://v3.nuxtjs.org/) application in seconds.
 
* Run:

```bash
    yarn add --dev @nuxtjs/tailwindcss @tailwindcss/forms
    #OR
    npm install --save-dev @nuxtjs/tailwindcss @tailwindcss/forms
```
* Add it to the `modules` section in `nuxt.config.ts`:

```ts
    // nuxt.config.ts
    // ...
    export default defineNuxtConfig({
      modules: ['@nuxtjs/tailwindcss']
    })
```
* Create `tailwind.config.js` by running:

```bash
    npx tailwindcss init
```
* Add Tailwind form plugin to `tailwind.config.js`

```js
    // tailwind.config.js
    module.exports = {
      theme: {
        // ...
      },
      plugins: [
        require('@tailwindcss/forms'),
        // ...
      ],
    }
```
* Next, let’s create our `/.assets/css/main.css` file:

```
    // ./assets/css/main.css
    
    @tailwind base;
    @tailwind components;
    @tailwind utilities;
```
* In the `nuxt.config.ts` file, enter the following:

```ts
    // ./nuxt.config.ts
    // ...
    export default defineNuxtConfig({
      modules: ['@nuxtjs/tailwindcss'],
      tailwindcss: {
        cssPath: '~/assets/css/main.css',
      }
    })
```    

#### Add Heroicons
We’ll use [Heroicons](https://heroicons.com/) for our icon library. To get started, install the Vue library:

```bash
    yarn add @heroicons/vue
    #OR
    npm install @heroicons/vue
```

## Step 3: User Registration and Authentication

We want users to be able to register, sign in, and sign out. To do that, we first need to define our `user` and `session` state.

### Configure Runtime Config
We’ll be using Nuxt runtime config to access global config in our application like the Strapi GraphQL URLs. In the `./nuxt.config.ts` file,

```ts
    // ./nuxt.config.ts
    
    import { defineNuxtConfig } from 'nuxt'
    export default defineNuxtConfig({
      // ...
      runtimeConfig: {
        public: {
          graphqlURL: process.env.STRAPI_GRAPGHQL || 'http://localhost:1337/graphql',
          strapiURL: process.env.STRAPI_URL || 'http://localhost:1337',
        }
      },
    })
```
[Click here to view the code on GitHub.](https://github.com/miracleonyenma/strapi-photos-app-frontend/blob/91bdf2a67a666811e76a40e23bd1bf8e24cd81b3/nuxt.config.ts#L11) 

This way, we can have access to these URLs throughout our application.

#### Create Application State
Nuxt 3 provides `useState` composable to create a reactive and SSR-friendly shared state across components.

* Create a new file `./composables/state.js` and enter the following:

```js
    // ./composables/state.js
    
    // READ USER STATE
    export const useUser = () => useState("user", () => ({}));
    // SET USER STATE
    export const useSetUser = (data) => useState("set-user", () => (useUser().value = data));
    // USE SESSION STATE
    export const useSession = () => useState("session", () => ({ pending: true, data: null }));
    // SET SESSION STATE
    export const useSetSession = (data) => {
      // save session state to localstorage
      localStorage.setItem("session", JSON.stringify(data));
      useState("set-session", () => {
        // update session state
        useSession().value.pending = false;
        useSession().value.data = data;
        // update user state
        useUser().value = data?.user;
      });
    };
```
[Click here to view the code on Github](https://github.com/miracleonyenma/strapi-photos-app-frontend/blob/main/composables/state.js)

Here, we’re using `[useState](https://v3.nuxtjs.org/guide/features/state-management)`[,](https://v3.nuxtjs.org/guide/features/state-management)  an SSR-friendly `[ref](https://vuejs.org/api/reactivity-core.html#ref)` [](https://vuejs.org/api/reactivity-core.html#ref)replacement. Its value will be preserved after server-side rendering (during client-side hydration) and shared across all components using a unique key.
The `useState` function accepts two parameters, the `key` which is a string and a function.
So, we create state for `useUser` & `useSession` which will be used to read/get the current user and session state. `setUser` & `setSession` will be used to set state with `setSession` saving the session data to `localStorage` and setting the `useUser` state as well.

**Next, we’ll create a composable function that we’ll use to send requests to our Strapi backend.** 
* Create a new file `./composables/sendReq.js`

```js
    // ./composables/sendReq.js
    
    // function to send requests
    // pass GraphQL URL and request options
    export const sendReq = async (graphqlURL, opts) => {
      try {
        let res = await fetch(graphqlURL, {
          method: "POST",
          // fetch options
          ...opts,
        });
        let result = await res.json();
        console.log(result.errors);
        // Handle request errors
        if (result.errors) {
          result.errors.forEach((error) => alert(error.message));
          // Throw an error to exit the try block
          throw Error(JSON.stringify(result.errors));
        }
        // save result response to page data state
        return result.data;
      } catch (error) {
        console.log(error);
        return {
          errors: error,
        };
      }
    }
```
[Click here to view the code on GitHub](https://github.com/miracleonyenma/strapi-photos-app-frontend/blob/main/composables/sendReq.js)

Here, we create a  `sendReq` function which will be used to send requests and return the `data` or `errors`. In order to catch errors from the request, we check if `result.errors` and then loop through `errors` array to alert each error message. Then throw the `Error` to exit the `try`  block to the `catch` block.

#### Create Default Layout
Now, let's proceed to create our authentication page. 
* First, create a default layout for our application. Create a new layout file `layouts/default.vue`

```js
    <!-- layouts/default.vue -->
    <template>
      <div class="layout default">
        <SiteHeader />
        <slot />
      </div>
    </template>
```
[View code on GitHub](https://github.com/miracleonyenma/strapi-photos-app-frontend/blob/main/layouts/default.vue)

* Let’s create the `<SiteHeader/>` component. In a new file `components/SiteHeader.vue` enter the following:

```js
    <!-- ./components/SiteHeader.vue -->
    <script setup>
    import { RefreshIcon } from "@heroicons/vue/outline";
    const session = useSession();
    console.log({ session: session });
    </script>
    <template>
      <header class="site-header">
        <div class="wrapper">
          <NuxtLink to="/">
            <figure class="site-logo"><h1>Photos</h1></figure>
          </NuxtLink>
          <nav class="site-nav">
            <!-- Hide if session state is pending -->
            <ul v-if="!session.pending" class="links">
              <!-- Render Register link if no user in session -->
              <li v-if="!session.data?.user" class="link">
                <NuxtLink to="/auth/register">
                  <button class="cta">Register</button>
                </NuxtLink>
              </li>
              <!-- Render Sign in link if no user in session -->
              <li v-if="!session.data?.user" class="link">
                <NuxtLink to="/auth/sign-in">
                  <button class="cta">Sign in</button>
                </NuxtLink>
              </li>
              <!-- Else, Render Sign out link if user in session -->
              <li v-else class="link">
                <NuxtLink to="/auth/sign-out">
                  <button class="cta">Sign out</button>
                </NuxtLink>
              </li>
            </ul>
            <!-- Display loading if session state is pending -->
            <div v-else class="cta">
              <RefreshIcon class="icon stroke animate-rotate" />
            </div>
          </nav>
        </div>
      </header>
    </template>
    
    <style scoped>
    /* ... */
    </style>
```
[View complete code on GitHub](https://github.com/miracleonyenma/strapi-photos-app-frontend/blob/main/components/SiteHeader.vue)

In the `<script>`, we get the `session` state from `useSession()` which is automatically imported by Nuxt. Using `v-if`, we conditionally render the links to our auth pages depending on if `session.user` exists or not.

* Next, we have to configure our app to use layouts. In `./app.vue`:

```js
    <!-- ./app.vue -->
    <script setup>
    import "@/assets/css/main.css";
    const router = useRouter();
    const session = useSession();
    onMounted(() => {
      // set session from localStorage
      useSetSession(JSON.parse(localStorage.getItem("session")));
    
      // route guard to prevent users from routing to 
      // sign in and register page when alrady logged in
      router.beforeEach((to, from) => {
        if ((to.path === "/auth/register" || to.path === "/auth/sign-in") && session.value?.jwt) {
          return false;
        }
      });
    });
    </script>
    <template>
      <NuxtLayout name="default">
        <!-- Render page depending on route -->
        <NuxtPage />
      </NuxtLayout>
    </template>
```
[View code on GitHub](https://github.com/miracleonyenma/strapi-photos-app-frontend/blob/main/app.vue)

In the `<script>`, we set the session from `localStorage` and use `useRouter`  to implement a route guard to prevent routing to `register` and `sign-in` pages when already signed in.

Now, we create our home page `./pages/index.vue`:

```js
    <!-- ./pages/index.vue -->
    <script setup>
    const user = useUser();
    </script>
    <template>
      <main class="home-main">
        <div class="wrapper">
          <header>
            <h1 class="text-4xl font-bold">Hey, {{ user?.username }}</h1>
          </header>
        </div>
      </main>
    </template>
    <style scoped>
      /* ... */
    </style>
```
[View code on GitHub](https://github.com/miracleonyenma/strapi-photos-app-frontend/blob/main/pages/index.vue)

Here, in the `<script>`, we simply get the `user` state from `useUser()`. In the `<template/>`, we display the user username.
Next, we’ll create our authentication pages

## Create Authentication Pages
* First, we’ll create the register page at `./pages/auth/register.vue`

```js
    <!-- ./pages/register.vue -->
    <script setup>
    
    // retrive GraphQL URL from runtime config
    const {
      public: { graphqlURL },
    } = useRuntimeConfig();
    
    // session & set session
    const session= useSession();
    const setSession = useSetSession;
    
    // page state
    const isLoading = ref(false);
    
    // form state
    const name = ref("");
    const email = ref("");
    const password = ref("");
    
    // handle form submit
    const handleRegister = async (e) => {
      e.preventDefault();
      if (name && email && password) {
        isLoading.value = true;
    
        // GraphQL Register Query
        let registerQuery = {
          query: `mutation($username: String!, $email: String!, $password: String!,){
          register(input: {username: $username, email: $email, password: $password}){
            jwt
            user{
              id
              username
              email
            }
          }
        }`,
          variables: {
            username: name.value,
            email: email.value,
            password: password.value,
          },
        };
    
        try {
          const { register, errors } = await sendReq(graphqlURL, { body: JSON.stringify(registerQuery), headers: { "Content-Type": "application/json" } });
          
          // throw error if any
          if (errors) throw Error(errors);
    
          // set session, notify and navigate to the home page
          setSession(register);
          alert("Registeration successful!");
          navigateTo("/");
        } catch (error) {
          console.log(error);
        } finally {
          isLoading.value = false;
        }
      }
    };
    </script>
    <template>
      <main class="site-main register-main">
        <div class="wrapper">
          <div class="form-cont">
            <form @submit="handleRegister" class="form auth-form">
              <div class="wrapper">
                <header class="form-header">
                  <h3 class="text-3xl mb-4">Sign Up</h3>
                </header>
                <section class="input-section">
                  <div class="form-control">
                    <label for="name">Name</label>
                    <input v-model="name" id="name" name="name" type="text" class="form-input" required />
                  </div>
                  <div class="form-control">
                    <label for="email">Email address</label>
                    <input v-model="email" id="email" name="email" type="email" class="form-input" required />
                  </div>
                  <div class="form-control">
                    <label for="password">Password</label>
                    <input v-model="password" id="password" name="password" type="password" class="form-input" required />
                  </div>
                  <div class="action-cont">
                    <button :disabled="isLoading" class="cta">{{ isLoading ? "..." : "Register" }}</button>
                  </div>
                </section>
              </div>
            </form>
          </div>
        </div>
      </main>
    </template>
```
[View code on GitHub](https://github.com/miracleonyenma/strapi-photos-app-frontend/blob/auth/pages/auth/register.vue)

In the `<script>`, we have `handleRegister()` function which gets called when the registration form is submitted. We get the form data from the `name`, `email` and `password` refs and `v-model` on the input elements in the `<template>`.

Then, we create a GraphQL `mutation` with the data inserted as variables. We’re using the Fetch API to send the request to our Strapi GraphQL endpoint which will be configured in our Nuxt `runtimeConfig`.

If the request was succesful, we save the response to `session`, if there were errors, we `throw`  it. These are the values from our query that will be returned:

```
    jwt
    user{
      id
      username
      email
    }
```
**Next, we’ll create the sign in page.**
* Create a new file `./pages/auth/sign-in.vue`

```js
    <!-- ./pages/auth/sign-in.vue -->
    <script setup>
    
    // retrive GraphQL URL from runtime config
    const {
      public: { graphqlURL },
    } = useRuntimeConfig();
    
    // session & user state
    const session = useSession();
    const setSession = useSetSession;
    
    // page state
    const isLoading = ref(false);
    const data = ref({});
    const error = ref({});
    
    // form state
    const name = ref("");
    const email = ref("");
    const password = ref("");
    
    // handle form submit
    const handleSignIn = async (e) => {
      e.preventDefault();
      if (name && email && password) {
        // GraphQL Sign in Query
        let signInQuery = {
          query: `mutation( $email: String!, $password: String!) {
            login(input: { identifier: $email, password: $password }) {
              jwt,
              user{
                id
                username
                email
              }
            }
          }`,
          variables: { email: email.value, password: password.value },
        };
        try {
          isLoading.value = true;
          const { login, errors } = await sendReq(graphqlURL, { body: JSON.stringify(signInQuery), headers: { "Content-Type": "application/json" } });
          if (errors) throw Error(errors);
          setSession(login);
    
          // notify and navigate to the home page
          alert("Sign in successful!");
          navigateTo("/");
        } catch (error) {
          console.log(error);
        } finally {
          isLoading.value = false;
        }
      }
    };
    </script>
    <template>
      <main class="site-main sign-in-main">
        <div class="wrapper">
          <div class="form-cont">
            <form @submit="handleSignIn" class="form auth-form">
              <div class="wrapper">
                <header class="form-header">
                  <h3 class="text-3xl mb-4">Sign In</h3>
                </header>
                <section class="input-section">
                  <div class="form-control">
                    <label for="email">Email address</label>
                    <input v-model="email" id="email" name="email" type="email" class="form-input" required />
                  </div>
                  <div class="form-control">
                    <label for="password">Password</label>
                    <input v-model="password" id="password" name="password" type="password" class="form-input" required />
                  </div>
                  <div class="action-cont">
                    <button :disabled="isLoading" class="cta">{{ isLoading ? "..." : "Sign in" }}</button>
                  </div>
                </section>
              </div>
            </form>
          </div>
        </div>
      </main>
    </template>
```
[View code on GitHub](https://github.com/miracleonyenma/strapi-photos-app-frontend/blob/auth/pages/auth/sign-in.vue)

The `sign-in` page is basically the same as the `register` page. Except we’re using the `signInQuery` which returns the response with `login`.
Also, we remove the `name` input from the form in the `<template>`.

    

**The `sign-out` page is pretty simple.**
Create a new file `./pages/sign-out.vue`. 

```js
    <!-- ./pages/sign-out.vue -->
    
    <script setup>
    onMounted(() => {
      // set session to null
      useSetSession(null);
      
      setTimeout(() => {
        alert("You've signed out. See you back soon!");
        navigateTo("/");
      }, 1000);
    });
    </script>
    <template>
      <main class="sign-out-main auth-main">
        <div class="wrapper">
          <h3 class="text-3xl">Signing Out...</h3>
        </div>
      </main>
    </template>
```
[View code on GitHub](https://github.com/miracleonyenma/strapi-photos-app-frontend/blob/auth/pages/auth/sign-out.vue)

Here, we simply set the `session` state to `null`, which in turn sets the `localStorage`  and `user` state to `null`. 

Now, let’s try to register a new user.


![Authentication in the application](https://paper-attachments.dropbox.com/s_81A70859F8B66737FB32A08BCA6F0249ACDBDA7A963E3F0A04B43A4D285336AF_1654934604156_023a1e7c-c267-4282-97e5-0b48b803ce29_gif.gif)


Awesome.


## Step 4: Fetch and Display Posts

Let’s hop back into our admin dashboard and create a new entry in the `Post` collection type with our new user.

![Create new user entry](https://paper-attachments.dropbox.com/s_81A70859F8B66737FB32A08BCA6F0249ACDBDA7A963E3F0A04B43A4D285336AF_1654934086198_image.png)


Click on **SAVE** and **PUBLISH**.

### Create Post Component
We’ll have to create a `Post` component which will be used to display a post.
* Create a new file `./components/Post.vue`

```js
    <!-- ./components/Post.vue -->
    <script setup>
    // import icons
    import { ChatIcon, PaperAirplaneIcon } from "@heroicons/vue/solid";
    // define `post` props
    defineProps(["post"]);
    const {
      public: { strapiURL },
    } = useRuntimeConfig();
    // helper function to get username from `post` attributes
    const getUsername = (post) => post?.attributes?.user.data?.attributes?.username;
    // helper to get and display date
    const showDateTime = (post) => new Date(post?.attributes?.updatedAt).toDateString() + " | " + new Date(post?.attributes?.updatedAt).toLocaleTimeString();
    </script>
    <template>
      <li class="post">
        <header class="post-header">
          <div class="author-cont">
            <div class="avatar">
              <!-- Get first letter in user name -->
              <p>{{ getUsername(post)?.substr(0, 1) }}</p>
            </div>
            <p class="username">{{ getUsername(post) }}</p>
          </div>
          <!-- Post options go here -->
        </header>
        <ul class="photos">
          <li v-for="photo in post?.attributes?.photo.data" :key="photo.id" class="photo">
            <div class="img-cont">
              <img :src="strapiURL + photo?.attributes?.url" :alt="post?.attributes?.caption" />
            </div>
          </li>
        </ul>
        <p class="caption">{{ post?.attributes?.caption }}</p>
        <div class="info-cont text-xs text-gray-600">
          <div class="time">{{ showDateTime(post) }}</div>
        </div>
        <div class="comment-cont">
          <ChatIcon class="icon solid" />
          <form class="comment-form">
            <div class="form-control">
              <textarea name="comment" id="comment" cols="30" rows="1" class="form-textarea comment-textarea" placeholder="Leave a comment"> </textarea>
            </div>
            <button class="cta submit-btn">
              <PaperAirplaneIcon class="icon solid rotate-90" />
            </button>
          </form>
        </div>
      </li>
    </template>
    
    <style scoped>
    /* ... */
    </style>
```
[View code on GitHub](https://github.com/miracleonyenma/strapi-photos-app-frontend/blob/fetch-display-posts/components/Post.vue)

Here, we define the `props` for our component with the `defineProps()` method which accepts a props array or object. We also have a few helper functions which help with displaying some information like avatar and date.
Next, let’s modify our home page to fetch the posts and render the component.

#### Fetch Posts with AsyncData
Back in our `./pages/index.vue` page,

```js
    <!-- ./pages/index.vue -->
    
    <script setup>
    import { RefreshIcon } from "@heroicons/vue/outline";
    
    const {
      public: { graphqlURL, strapiURL },
    } = useRuntimeConfig();
    
    const session = useSession();
    const user = useUser();
    
    // page state
    const isLoading = ref(false);
    const posts = ref({});
    const error = ref({});
    
    // function to get posts
    const getPosts = async (page) => {
    
      // post query with page and page size variables
      const postsQuery = {
        query: `query($page: Int, $pageSize: Int) {
      posts(pagination: {page: $page, pageSize:$pageSize}, sort: "updatedAt:desc") {
        data {
          id
          attributes {
            user {
              data {
                id
                attributes {
                  username
                }
              }
            }
            photo {
              id
              data {
                attributes {
                  url
                }
              }
            }
            caption
            updatedAt
          }
        }
        meta {
          pagination {
            total
            pageSize
            page
          }
        }
      }
    }
    `,
        variables: {
          page: parseInt(page),
          pageSize: 10,
        },
      };
      try {
        let { posts, errors } = await sendReq(graphqlURL, { body: JSON.stringify(postsQuery), headers: { "Content-Type": "application/json" } });
        if (errors) throw Error(errors);
        return posts;
      } catch (error) {
        console.log({ error });
      }
    };
    
    // Get current route page by destructuring useAsyncData context to get route > query > page
    const { data } = await useAsyncData("posts", async ({ _route: { query } }) => {
      let { page } = query;
      return await getPosts(page);
    });
    </script>
```
[View Code on GitHub](https://github.com/miracleonyenma/strapi-photos-app-frontend/blob/fetch-display-posts/pages/index.vue)

As we can see here in our `<script>` section, we have an async `getPosts()` function which takes `page` as a parameter. Within this function, we define the `postsQuery` with `$page` and `$pageSize` variables which determines the page and number of posts in a page we get from our query.

In order to determine the page, within the `useAsyncData()` function, we have access to the request context from the `ctx` parameter in the `useAsyncData(``'``posts``'``, (ctx) => {})`. By destructuring obtain the `page`  from the route query.

Next, in our `<template>`

```js
    <!-- ./pages/index.vue -->
    
    <template>
      <main class="site-main home-main">
        <div class="wrapper">
          <header>
            <h1 class="text-4xl font-bold">Hey, {{ user?.username || "Stranga!" }}</h1>
          </header>
          <div class="content-wrapper">
            <aside class="create-post-aside">
              <div class="wrapper">
                <header class="px-2 mb-2">
                  <h3 class="font-medium">Create a new post</h3>
                </header>
                <!-- Editor goes here -->
                <!-- Pagination goes here -->
              </div>
            </aside>
            <section class="posts-section">
              <ul class="posts">
                <Post v-for="post in data?.data" :key="post.id" :post="post" />
              </ul>
            </section>
          </div>
        </div>
      </main>
    </template>
    <style scoped>
    /* ... */
    </style>
```
With that, we should have our first post displayed like this:


![A Sample Screenshot](https://paper-attachments.dropbox.com/s_81A70859F8B66737FB32A08BCA6F0249ACDBDA7A963E3F0A04B43A4D285336AF_1654970415557_image.png)

Next, we’ll create our `Editor` component to upload pictures and create posts.

#### Add Pagination
Eventually, our posts will be more than just one and we’ll need a way to navigate through them, let’s quickly create a pagination component for that. 
* Create a new file, `./components/Pagination.vue`

```js
    <!-- ./components/Pagination.vue -->
    
    <script setup>
    defineProps(["pagination"]);
    // helper function to create pagination
    const getPagination = ({ page, pageSize, total, pageCount }) => {
      // Get previous page number with `1` as the lowest
      let prev = Math.abs(page - 1) > 0 ? Math.abs(page - 1) : 1;
      // Get next page with number of total pages as highest
      let next = page + 1 < pageCount ? page + 1 : pageCount;
      // console.log({ pages, prev, next });
      return {
        pageCount, page, pageSize, total, prev, next,};
    };
    </script>
    <template>
      <div class="pagination">
        <ul class="list">
          <li>
            <a :href="`?page=` + getPagination(pagination).prev">
              <button class="cta">Previous</button>
            </a>
          </li>
          <li v-for="n in getPagination(pagination).pageCount" :key="n">
            <a :href="`?page=${n}`">
              <button class="cta">
                {{ n }}
              </button>
            </a>
          </li>
          <li>
            <a :href="`?page=` + getPagination(pagination).next">
              <button class="cta">Next</button>
            </a>
          </li>
        </ul>
      </div>
    </template>
```

In this component we get the `pagination` data as a prop which we defined in the `defineProps()` function. Then, we create a `getPagination()` helper function which calculates the `next` and `previous` page numbers. Let’s add it in our `./pages/index.vue` file:

```js
    <!-- ./pages/index.vue -->
    
    <template>
      <main class="site-main home-main">
        <div class="wrapper">
          <!-- ... -->
          <div class="content-wrapper">
            <aside class="create-post-aside">
              <div class="wrapper">
                <!-- ... -->
                <!-- Editor goes here -->
                <Pagination :pagination="data?.meta?.pagination" />
              </div>
            </aside>
            <!-- ... -->
          </div>
        </div>
      </main>
    </template>
```
Next, we’ll work on the `Editor` component to create posts.


## Step 5: Create Posts

In order to create posts, we will need to send authenticated requests with our mutation queries. To create a Post we will need to send two requests to:

- First, upload images and obtain the uploaded image IDs
- Then, create the post with the caption and array of uploaded image IDs

**To do this:**

* Create a new file `./components/Editor.vue`.

```js
    <!-- ./components/Editor.vue -->
    
    <script setup>
    // import icons
    import { PlusCircleIcon } from "@heroicons/vue/solid";
    
    // Get graphqlURL from runtime config
    const {
      public: { graphqlURL },
    } = useRuntimeConfig();
    
    // init useRouter
    const router = useRouter();
    
    // Get `user` & `session` application state
    const user = useUser();
    const session = useSession().value;
    
    // data state
    const isLoading = ref(false);
    const data = ref(null);
    const error = ref({});
    
    // component states
    const imagesURL = ref([]);
    const images = ref({});
    const fileBtnText = ref("Choose Images");
    const caption = ref("");
    
    // header object for fetch request
    let headersList = {
      Accept: "*/*",
      // set authorization token
      Authorization: `Bearer ${session.data.jwt}`,
      "Content-Type": "application/json",
    };
    
    // function to generate preview URLs for selected images before upload
    const previewImg = (e) => {
      // save files to `images` state
      images.value = e.target.files;
    
      // create iterable array from `FileList`
      let files = Array.from(images.value);
    
      // iterate over `files` array to create temporary URLs
      // URLs will be used to preview selected images before upload
      imagesURL.value = [];
    
      files.forEach((file, i) => {
        let url = URL.createObjectURL(file);
        imagesURL.value.push(url);
      });
    
      // set upload file button content to [number of] files selected
      let length = imagesURL.value.length;
      fileBtnText.value = `${length} file${length != 1 ? "s" : ""} selected`;
    };
    
    // function to upload files to Strapi media library and get the uploaded file `ids`
    const uploadFiles = async () => {
    
      // mutation to upload multiple files
      const uploadMultipleMutation = {
        query: `
          mutation($files: [Upload]!){
            multipleUpload(files: $files){
              data{
                id
              }
            }
          }
        `,
        variables: {
          // dynamically generate array with null values to match the number of images selected
          // files: [null, null, null]
          files: imagesURL.value.map((x) => null),
        },
      };
    
      // image variables map
      /*
      {
        "0": [
            "variables.files.0"
        ],
        "1": [
            "variables.files.1"
        ],
        "2": [
            "variables.files.2"
        ]
      }
      */
      let map = {};
    
      // init `FormData()`
      const formData = new FormData();
    
      // append `operations` to FormData(), which conatin the graphQL query
      formData.append("operations", JSON.stringify(uploadMultipleMutation));
    
      // create map for variable files
      for (let i = 0; i < images.value.length; i++) {
        map[`${i}`] = [`variables.files.${i}`];
      }
    
      // add mapp to `FormData()`
      formData.append("map", JSON.stringify(map));
    
      // append images to formData
      // with names corresponding to the keys in `map`
      for (let i = 0; i < images.value.length; i++) {
        const image = images.value[i];
        formData.append(`${i}`, image);
      }
    
      try {
        // send request with `formdata` and authorization headers
        let { multipleUpload, errors } = await sendReq(graphqlURL, { body: formData, headers: { Authorization: headersList.Authorization } });
        if (errors) throw Error(errors);
        console.log(multipleUpload);
        return multipleUpload;
      } catch (error) {
        console.log(error);
      }
    };
    
    // function to create post
    const createPost = async (e) => {
      e.preventDefault();
      isLoading.value = true;
      // run if images and caption are not null
      if (images && caption) {
        // upload images and get the IDs
        let uploads = await uploadFiles();
    
        // mutation query for creating posts
        let createPostQuery = {
          query: `
          mutation($data: PostInput!){
            createPost(data: $data){
              data{
                id
                attributes{
                  caption
                  photo{
                    data{
                      id
                      attributes{
                        url
                      }
                    }
                  }
                  user{
                    data{
                      id
                    }
                  }
                  updatedAt
                }
              }
            }
          }
          `,
          variables: {
            data: {
              caption: caption.value,
              user: user.value.id,
              // photo: [array of uploaded photo ids]
              // e.g. photo: [21, 22, 23]
              photo: uploads.map((file) => file.data.id),
              publishedAt: new Date(),
            },
          },
        };
    
        try {
          // send request to create post
          const { createPost, errors } = await sendReq(graphqlURL, { body: JSON.stringify(createPostQuery), headers: headersList });
          if (errors) throw Error(errors);
    
          // save to state
          data.value = createPost;
    
          // reload page
          window.location.replace("/");
        } catch (error) {
          console.log(error);
        } finally {
          isLoading.value = false;
        }
      }
    };
    </script>
    <template>
      <form :disabled="isLoading || data" @submit="createPost" class="upload-form">
        <div class="wrapper">
          <div class="form-control file">
            <div class="img-cont upload-img-cont">
              <!-- Selected image previews -->
              <ul v-if="imagesURL.length >= 1" class="images">
                <li v-for="(src, i) in imagesURL" :key="i" class="image">
                  <img :src="src" alt="Image" class="upload-img" />
                </li>
              </ul>
            </div>
            <label for="image-input" class="file-label" :class="{ 'mt-4': imagesURL.length > 0 }">
              <span type="button" class="cta file-btn">
                {{ fileBtnText }}
              </span>
              <input
                :disabled="isLoading || data"
                @change="previewImg"
                id="image-input"
                class="file-input"
                type="file"
                accept=".png, .jpg, .jpeg"
                multiple
                required
              />
            </label>
          </div>
          <div class="form-control">
            <label for="caption">Caption</label>
            <textarea
              :disabled="isLoading || data"
              v-model="caption"
              name="caption"
              id="caption"
              cols="30"
              rows="1"
              class="form-textarea caption-textarea"
              placeholder="Enter you caption"
              required
            >
            </textarea>
          </div>
          <div class="action-cont">
            <button :disabled="isLoading || data" type="submit" class="cta w-icon">
              <PlusCircleIcon v-if="!data" class="icon solid" />
              <span v-if="!data">{{ !isLoading ? "Create Post" : "Hang on..." }}</span>
              <span v-else>Post created! 🚀</span>
            </button>
          </div>
        </div>
      </form>
    </template>
    <style scoped>
    /* ... */
    </style>
```
Let’s go over a few things happening here.

First, we have the `previewImg()` function which generates a temporary URL which will be used to display images that have been selected for uploads. It also gets the total number of selected files and updates the `fileBtnText` state accordingly.

Next, we have the `uploadFiles()` function which does the following:

- Initialize a new `FormData()` and append the `uploadMultipleMutation` query to it with the `operations` name.
- Create a map using a `for` loop with each file from the `images` array state mapped to it’s index, the map should end up like this:
```js    
      // create map for variable files
      for (let i = 0; i < images.value.length; i++) {
        map[`${i}`] = [`variables.files.${i}`];
      }
      
      // image variables map
      {
        "0": [
            "variables.files.0"
        ],
        "1": [
            "variables.files.1"
        ],
        "2": [
            "variables.files.2"
        ]
      }
```
Then, the  `map` is appended to `formData()` with the `"``map``"` name.

Finally, we send the request with `formdata` and authorization headers.

After that, we have the `createPost()` function which runs on form submit. When fired, the function runs the `uploadFiles()` function and gets the image IDs. We also define the `createPostQuery` mutatation query where we pass in the `caption`, `user`, `photo` - which is an array of the IDs of the uploaded images and `publishedAt` to immediately publish the post once created.

Back in our `./pages/index.vue` homepage, let’s add the `<Editor />` component where we added the placeholder comment.

```js
    <!-- ./pages/index.vue -->
    
    <template>
      <main class="site-main home-main">
        <div class="wrapper">
          <!-- ... -->
          <div class="content-wrapper">
            <aside class="create-post-aside">
              <div class="wrapper">
                <!-- ... -->
                <!-- Render editor if session is not pending -->
                <div v-if="!session.pending" class="editor-con">
                  <Editor v-if="user?.email" />
                  <div v-else class="unauthenticated-message px-2">
                    <p>Sorry, you have to sign in to post</p>
                  </div>
                </div>
                <div v-if="session.pending" :class="{ 'loading-state': session.pending }">
                  <RefreshIcon class="icon stroke animate-rotate" />
                </div>
                <Pagination :pagination="data?.meta?.pagination" />
              </div>
            </aside>
            <!-- ... -->
          </div>
        </div>  </main>
    </template>
```
Now, if we try to create a post, we should have something like this:


![](https://paper-attachments.dropbox.com/s_81A70859F8B66737FB32A08BCA6F0249ACDBDA7A963E3F0A04B43A4D285336AF_1655010362423_c08b6aa9-6c52-41da-b2c5-7105934a3284_gif.gif)


Splendid. Next, let’s see how we can edit and delete posts.


## Step 6: Edit and Delete Posts

In order to be able to edit and delete posts, we will have to make a few changes to our current code. 
### Add Current Post State
First,  let’s add the state that holds the current post to be edited.
In our `./composables/state.js` file:

```js
    // ./composables/state.js
    // ...
    
    // READ CURRENT POST STATE
    export const usePost = () => useState("post", () => ({}));
    // SET CURRENT POST STATE
    export const useSetPost = (data) =>
      useState("set-post", () => {
        usePost().value = data;
        console.log({ data, usePost: usePost().value });
      });
```
Next, we have to add the options to edit and delete a post to the <Post /> component. We must also make sure that these options only appear if the signed in user owns that post. 

#### Add Edit and Delete Actions in Post Component
In our `./components/Post.vue` template, we render the options dropdown only when the `user.id` matches.

```js
    <!-- ./components/Post.vue -->
    
    <template>
      <li class="post">
        <header class="post-header">
          <div class="author-cont">
            <div class="avatar">
              <!-- Get first letter in user name -->
              <p>{{ getUsername(post)?.substr(0, 1) }}</p>
            </div>
            <p class="username">{{ getUsername(post) }}</p>
          </div>
          <!-- Render if logged in user id matches with post user id -->
          <div v-if="user.id == getUserID(post)" class="options dropdown">
            <button class="dropdown-btn">
              <DotsHorizontalIcon class="icon solid" />
            </button>
            <ul class="dropdown-list">
              <li>
                <button @click="editPost" class="w-icon w-full">
                  <PencilIcon class="icon solid" />
                  <span>Edit</span>
                </button>
              </li>
              <li>
                <button @click="deletePost" class="w-icon w-full">
                  <TrashIcon class="icon solid" />
                  <span>Delete</span>
                </button>
              </li>
            </ul>
          </div>
        </header>
        <!-- ... -->
      </li>
    </template>
```

In the `<script>`, let’s initialize the current post state and also create the `getUserID()`, `editPost()` and `deletePost()` functions.

```js
    <!-- ./components/Post.vue -->
    
    <script setup>
    // import icons
    import { ChatIcon, PaperAirplaneIcon, DotsHorizontalIcon, PencilIcon, TrashIcon } from "@heroicons/vue/solid";
    
    // define `post` props
    const props = defineProps(["post"]);
    
    // Get graphqlURL from runtime config
    const {
      public: { graphqlURL, strapiURL },
    } = useRuntimeConfig();
    
    // Get global session and user state
    const session = useSession();
    const user = useUser();
    
    // Get & Set global curresnt post state
    const currentPost = usePost();
    const setCurrentPost = useSetPost;
    
    // header object for fetch request
    let headersList = {
      Accept: "*/*",
      Authorization: `Bearer ${session.value?.data?.jwt}`,
      "Content-Type": "application/json",
    };
    
    // ... other helper functions
    // helper function to get username from `post` attributes
    const getUserID = (post) => post?.attributes?.user.data?.id;
    
    // function to edit post
    const editPost = () => {
      // get post data from props
      const post = props.post;
      // set post action to edit
      post.action = "edit";
      // set modified post to global state
      setCurrentPost(post);
    };
    
    // function to delete post
    const deletePost = async () => {
      // confirm deletion
      const confirmDelete = confirm("Are you sure you want to delete this post?");
      // run if user confirms deletion
      if (confirmDelete) {
        // mutation query for deleting post
        let deletePostQuery = {
          query: `
            mutation($id: ID!){
              deletePost(id: $id){
                data{
                  id
                }
              }
            }
          `,
          variables: {
            id: props.post.id,
          },
        };
        try {
          // send request to delete post
          const { deletePost, errors } = await sendReq(graphqlURL, { body: JSON.stringify(deletePostQuery), headers: headersList });
          if (errors) throw Error(errors);
    
          // reload page
          window.location.reload();
        } catch (error) {
          console.log(error);
        } finally {
          isLoading.value = false;
        }
      }
    };
    
    </script>
```
Let’s quickly go over the two functions we just created:

- `editPost()` - This function simply gets the `post` data from the component `props`, sets the `action` key to `"``edit``"` and sets the `currentPost` application state to the modified `post`. 
    
    This way, we can  set up a `watch`  function in the `<Editor />` component to modify the editor state accordingly.
- `deletePost()` - This function, after the action has been confirmed, sends a request with  `deletePost`  mutation in `deletePostQuery`  which deletes the post by `id`.


![A Sample Screenshot](https://paper-attachments.dropbox.com/s_81A70859F8B66737FB32A08BCA6F0249ACDBDA7A963E3F0A04B43A4D285336AF_1655134561859_image.png)


Now, we have to be able to handle the edit post action from the `<Editor />` component.

#### Handle Edit Post Action from Editor Component
In our `./components/Editor.vue` file, we have to add a few things. 
First, in the `<template>` we need to run a different function - `handleSubmit()` when the form is submitted. This function will be responsible for determining whether to run the `createPost()` or a new `editPost()` function.

We also slightly modify the submit button and add a new `reset` button which will run the `resetAction()` function to reset the component state.

```js
    <!-- ./components/Editor.vue -->
    
    <template>
      <form :disabled="isLoading || data" @submit="handleSubmit" class="upload-form">
        <div class="wrapper">
        <!-- ... -->
          <div class="action-cont">
            <!-- Submit button -->
            <button :disabled="isLoading || data" type="submit" class="cta w-icon capitalize">
              <PlusCircleIcon v-if="!data" class="icon solid" />
              <span v-if="!data">{{ !isLoading ? `${action} Post` : "Hang on..." }}</span>
              <span v-else>Successfull! 🚀</span>
            </button>
            <!-- Reset button -->
            <button @click="resetAction" type="button" v-show="action == 'edit'" class="cta w-icon">
              <XCircleIcon class="icon solid" />
              <span>Reset</span>
            </button>
          </div>
        </div>
      </form>
    </template>
```
In our `<script>`, we’ll first initialize the state variables

```js
    <!-- ./components/Editor.vue -->
    <script setup>
    // import icons
    import { PlusCircleIcon, XCircleIcon } from "@heroicons/vue/solid";
    
    // Get graphqlURL & strapiURL from runtime config
    const {
      public: { graphqlURL, strapiURL },
    } = useRuntimeConfig();
    
    // Get `user` & `session` application state
    const user = useUser();
    const session = useSession();
    
    // Get global current post state
    const currentPost = usePost();
    const setCurrentPost = useSetPost;
    
    // to hold the image data from the current post
    const currentImages = ref([]);
    
    // data state
    const isLoading = ref(false);
    const data = ref(null);
    
    // component states
    const imagesURL = ref([]);
    const images = ref({});
    const fileBtnText = ref("Choose Images");
    const caption = ref("");
    const action = ref("create");
    
    // header object for fetch request
    let headersList = {
      Accept: "*/*",
      // set authorization token
      Authorization: `Bearer ${session.value.data?.jwt}`,
      "Content-Type": "application/json",
    };
    
    // ...
    </script>
```
Next, we create the required functions:

```js
    <!-- ./components/Editor.vue -->
    <script setup>
    // ...
    
    // function to run the create or edit post functions depending on the action
    const handleSubmit = (e) => {
      e.preventDefault();
      action.value == "create" ? createPost() : editPost();
    };
    
    // function to create post
    const createPost = async () => {
      isLoading.value = true;
      // run if `images` has no files and caption are not null
      if (images.value.length > 0 && caption) {
        // ... 
      }
    }
    
    // function to edit post
    const editPost = async () => {
      isLoading.value = true;
      // if caption is not null
      if (caption) {
        // set uploads to uploaded files if user selects new files to upload
        // else, set uploads to images from the current post data
        let uploads = images.value.length > 0 ? await uploadFiles().then((res) => res.map((file) => file.data)) : currentImages.value;
      
        // query to update post
        let updatePostQuery = {
          query: `
            mutation($id: ID!, $data: PostInput!) {
              updatePost(id: $id, data: $data) {
                data {
                  id
                  attributes {
                    user {
                      data {
                        id
                      }
                    }
                    caption
                    photo {
                      data {
                        id
                      }
                    }
                  }
                }
              }
            }
          `,
          variables: {
            // set id to current post id
            id: currentPost.value.id,
            data: {
              caption: caption.value,
    
              // photo: [array of uploaded photo ids]
              // e.g. photo: [21, 22, 23]
              photo: uploads.map((file) => file.id),
            },
          },
        };
        try {
          // send request to update post
          const { updatePost, errors } = await sendReq(graphqlURL, { body: JSON.stringify(updatePostQuery), headers: headersList });
    
          if (errors) throw Error(errors);
    
          // save to state
          data.value = updatePost;
          // reload page
          window.location.replace("/");
        } catch (error) {
          console.log(error);
        } finally {
          isLoading.value = false;
        }
      }
    };
    // function to reset action to "create"
    const resetAction = () => {
      // set current post to empty object
      setCurrentPost({});
      // reset component state
      action.value = "create";
      caption.value = "";
      imagesURL.value = [];
    };
    
    // watch for changes in the current post data
    watch(
      currentPost,
      (post) => {
        // catch block catches errors that occur whenever the state is reset
        try {
          // destructure post data
          const {
            attributes: { photo, caption: _caption },
            action: _action,
          } = post;
          // generate image URLs and ids from post photo data
          currentImages.value = photo.data.map((image) => {
            let src = strapiURL + "" + image.attributes.url;
            let id = image.id;
            return { src, id };
          });
          // set imagesURL to currentImages `src`
          imagesURL.value = currentImages.value.map((image) => image.src);
          // update component state to post data
          caption.value = _caption;
          action.value = _action;
        } catch (error) {
          console.log(error);
        }
      },
      { deep: true }
    );
    </script>
```
What’s going on here is pretty straightforward. The `handleSubmit()` button runs either the `createPost()` or `editPost()` function depending on the `action`.

The `editPost()` function if pretty similar to `createPost()` except for the fact that the `uploads` data is gotten differently.
In the `editPost()` function, we see this line of code:

```
    // set uploads to uploaded files if user selects new files to upload
    // else, set uploads to images from the current post data
    let uploads = images.value.length > 0 ? await uploadFiles().then((res) => res.map((file) => file.data)) : currentImages.value;
```
With this, if the user selects new images to update the post (which means that `images.value` will contain those selected images and will have a length greater than 0), the `uploadFiles()` function will upload the files and return an array of image `id`s.
If the user does not select any image on the other hand, the `uploads` will be equal to the original images of the post.

We also have the `watch` function which listens to changes to the `currentPost` state and updates the component state accordingly.


> 🚨 It’s important to note that you’ll have to remove the `required` attribute from the file `input` so that the form will be able to submit even if the user did not select any new files.

![A Sample Screenshot](https://paper-attachments.dropbox.com/s_81A70859F8B66737FB32A08BCA6F0249ACDBDA7A963E3F0A04B43A4D285336AF_1655178430519_038f9665-7cbf-4b0c-a91d-64f2bc63aceb_gif.gif)




## Step 7: Add Comment Functionality

Let’s create a new component for displaying, creating and editing comments for each post. Create a new file - `./components/Comments.vue` and copy over the comment markup from the `<Post />` component. 

In the `<script>`, we’ll create a few functions to get, create, edit and remove comments. First, let’s set up the application state and a few helper functions:

```js
    <!-- ./components/Comments.vue -->
    
    <script setup>
    // import icons
    import { ChatIcon, PaperAirplaneIcon, XCircleIcon } from "@heroicons/vue/solid";
    import { RefreshIcon } from "@heroicons/vue/outline";
    
    // define component props
    const { post } = defineProps(["post"]);
    
    // Get graphqlURL from runtime config
    const {
      public: { graphqlURL },
    } = useRuntimeConfig();
    
    // Get `user` & `session` application state
    const user = useUser();
    const session = useSession();
    
    // component state
    const comments = ref([]);
    const comment = ref({});
    const action = ref("create");
    const isLoading = ref(false);
    
    // ref for comment <ul> list item
    const commentList = ref(null);
    
    // header object for fetch request
    let headersList = {
      Authorization: `Bearer ${session.value?.data?.jwt}`,
      "Content-Type": "application/json",
    };
    
    // helper to get and display date
    const showDateTime = (dateString) => new Date(dateString).toDateString() + " | " + new Date(dateString).toLocaleTimeString();
    
    // function to activate edit action
    const handleEdit = (data) => {
      comment.value = { ...data };
      action.value = "edit";
    };
    
    // function to reset comment state
    const resetAction = () => {
      comment.value = {};
      action.value = "create";
    };
    
    // ...
    
    </script>
```
We’re doing a few things here. For the component state, we have:

- `comments` - Which will be an array of all comments fetched from a post.
- `comment` - An object containing a single comment data
- `action` - Which will determine if a comment is to be created or edited when the user clicks the submit button. 
- `isLoading` - Component loading state

We also have a `ref` to a DOM element - `commentList`, which is the `<ul>` elemt which will contain all fetched comments.

A few helper functions we should take note of are:

- `handleEdit()` - Which sets the `comment` value and also sets the `action` to `"``edit``"`
- `resetAction()` - This resets the `comment` value and resets the `action` to `"``create``"`

Next, let’s look at how we can fetch comments.

### Get Comments
To get comments, we’ll need to use the `findAllFlat` query provided by the Strapi Comments plugin for fetching all comments.

```js
    <!-- ./components/Comments.vue -->
    
    <script setup>
    
    // ...
    
    // function to get comments
    const getComments = async () => {
      // comments query
      const commentsQuery = {
        query: `
        query($relation: String!) {
          findAllFlat(relation: $relation, sort: "updatedAt:desc") {
            data {
              id
              content
              approvalStatus
              updatedAt
              author {
                id
                name
              }
            }
          }
        }
      `,
        variables: {
          relation: `api::post.post:${post?.id}`,
        },
      };
      try {
        // send request and return results or errors
        let { findAllFlat, errors } = await sendReq(graphqlURL, { body: JSON.stringify(commentsQuery), headers: { "Content-Type": "application/json" } });
        if (errors) throw Error(errors);
        return findAllFlat;
      } catch (error) {
        console.log({ error });
      }
    };
    
    // watch function to update the comments list scroll postion
    watch(
      comments,
      (comments) => {
        // if commentList element exists
        if (commentList.value) {
          setTimeout(() => {
            // scoll comment list to the top
            commentList.value.scroll(0, 0);
          }, 100);
        }
      },
      { deep: true }
    );
    
    // fetch comments and save to acomments array
    comments.value = await getComments();
    </script>

Here, we get the comments by running `await getComments()` and assigning the result to `comments.value`
We also have a simple `watch` function which scrolls the `commentList` element to the top whenever the `comments` state changes. 
Let’s take a look at how we can create comments
```

#### Create Comment
To create a comment, we’ll have to use the `createComment` mutation query. Also, we’ll create a `handleSubmit()` function which will either run a create or edit function depending in the current action.

```js
    <!-- ./components/Comments.vue -->
    
    <script setup>
    // ...
    
    // function to either create or edit comments
    const handleSubmit = (e) => {
      e.preventDefault();
      action.value == "create" ? createComment() : editComment(comment.value?.id);
    };
    
    // function to create comments
    const createComment = async () => {
      // check if the comment is not empty or just spaces
      if (comment.value.content.trim()) {
        isLoading.value = true;
        // create comment mutation
        const createCommentQuery = {
          query: `
            mutation($input: CreateComment!) {
              createComment(input: $input) {
                id
                content
                approvalStatus
                updatedAt
                author {
                  id
                  name
                }
              }
            }
          `,
          variables: {
            input: { relation: `api::post.post:${post?.id}`, content: comment.value.content.trim() },
          },
        };
        try {
          // send request and return results or errors
          let { createComment, errors } = await sendReq(graphqlURL, { body: JSON.stringify(createCommentQuery), headers: headersList });
          if (errors) throw Error(errors);
    
          // add created comment to top of comments array
          comments.value.data = [createComment, ...comments.value.data];
    
          // reset comment and action state
          resetAction();
        } catch (error) {
          console.log({ error });
        } finally {
          isLoading.value = false;
        }
      }
    };
    
    // ...
    </script>
```
Here, you can see that we add the newly created comment `createComment` to the `comments` array. We then reset the comment state with `resetAction()`. Let’s proceed to how we can edit a comment.

#### Edit Comment
This is pretty similar to how we created a comment. Here, we’ll be using the `updateComment` mutation query.

```js
    <!-- ./components/Comments.vue -->
    
    <script setup>
    // ...
    
    // function to edit comment based on it's `id`
    const editComment = async (id) => {
      // check if the comment is not empty or just spac
      if (comment.value.content.trim()) {
        isLoading.value = true;
        // update comment mutataion
        const updateCommentQuery = {
          query: `
            mutation($input: UpdateComment!) {
              updateComment(input: $input) {
                id
                content
                approvalStatus
                updatedAt
                author {
                  id
                  name
                }
              }
            }
          `,
          variables: {
            input: { id, relation: `api::post.post:${post?.id}`, content: comment.value.content.trim() },
          },
        };
        try {
          // send request and return results or errors
          let { updateComment, errors } = await sendReq(graphqlURL, { body: JSON.stringify(updateCommentQuery), headers: headersList });
          if (errors) throw Error(errors);
          // get index of comment to edit
          let editIndex = comments.value.data.findIndex(({ id }) => id == updateComment.id);
          // remove comment from array at that index
          comments.value.data.splice(editIndex, 1);
          // add edited comment to top of array
          comments.value.data.unshift(updateComment);
          // reset comment and action state
          resetAction();
        } catch (error) {
          console.log({ error });
        } finally {
          isLoading.value = false;
        }
      }
    };
    
    // ...
    </script>
```
In this case, we don’t simply add the updated comment `updateComment` to the `comments` array. Instead we get the index - `editIndex` by matching the `id`. Then we remove the current comment from the array and add the updated comment to the top of the array with the `unshift()` method.

#### Remove Comment
Finally, to remove a comment we simply use the `removeComment` mutation query.

```js
    <!-- ./components/Comments.vue -->
    
    <script setup>
    // ...
    
    // function to remove comment
    const removeComment = async (id) => {
      // confirm if the user wants to proceed to remove the comment
      let confirmRemove = confirm("Are you sure you want to remove this comment?");
      // if confirmed
      if (confirmRemove) {
        isLoading.value = true;
        // remove comment mutation
        const removeCommentQuery = {
          query: `
            mutation($input: RemoveComment!) {
              removeComment(input: $input) {
                id
                content
                approvalStatus
                author {
                  id
                  name
                }
              }
            }
          `,
          variables: {
            input: { relation: `api::post.post:${post?.id}`, id },
          },
        };
        try {
          // reset comment and action state
          let { removeComment, errors } = await sendReq(graphqlURL, { body: JSON.stringify(removeCommentQuery), headers: headersList });
          if (errors) throw Error(errors);
    
          // get index of comment to remove
          let removeIndex = comments.value.data.findIndex(({ id }) => id == removeComment.id);
          // remove comment from array
           comments.value.data.splice(removeIndex, 1);
        } catch (error) {
          console.log({ error });
        } finally {
          isLoading.value = false;
        }
      }
    };
    
    // ...
    </script>
```
Here, once the comment has been removed, we remove from the `comments` array by getting the index - `removeIndex` and using the `splice()` method to remove it at that index.

Now we can create our template.

#### The <template>

```html
    <template>
      <div class="comments">
        <ul ref="commentList" class="comment-list" :class="{ 'border-t': comments.data[0] }">
          <li v-for="comment in comments?.data" :key="comment.id" class="comment">
            <main class="body">
              <p class="author">
                {{ comment.author?.name }}
              </p>
              <pre class="content">{{ comment?.content }}</pre>
            </main>
            <footer class="actions">
              <span class="details"> {{ showDateTime(comment?.updatedAt) }} </span>
              <div v-if="user?.id == comment.author?.id" class="options">
                <button @click="() => handleEdit(comment)">Edit</button> |
                <button @click="() => removeComment(comment.id)" class="hover:text-red-500">Remove</button>
              </div>
            </footer>
          </li>
        </ul>
        <div v-if="user?.id" class="comment-cont">
          <ChatIcon class="icon solid" />
          <form @submit="handleSubmit" class="comment-form">
            <div class="form-control">
              <textarea
                v-model="comment.content"
                name="comment"
                id="comment"
                cols="30"
                rows="1"
                class="form-textarea comment-textarea"
                placeholder="Leave a comment"
                required
              >
              </textarea>
            </div>
            <!-- Reset button -->
            <button @click="resetAction" type="button" v-show="action == 'edit'" class="cta submit-btn">
              <XCircleIcon class="icon solid" />
            </button>
            <!-- Send Button -->
            <button :disabled="isLoading" class="cta submit-btn">
              <PaperAirplaneIcon v-if="!isLoading" class="icon solid rotate-90" />
              <RefreshIcon v-else class="icon stroke animate-rotate" />
            </button>
          </form>
        </div>
      </div>
    </template>
    <style scoped> /* ... */ </style>
```

> 🚨 Take note of  the `v-if`'s.  We’re mostly using the condition `v-if="user?.id"` to display certain elements only when the user is signed in. On the other hand, we use the condition `v-if="user?.id == comment.author?.id"` to render elements only if the signed in user owns that comment.


Back in the `<Post />` component, let’s add our new `<Comments />` component:

```js
    <!-- ./components/Post.vue -->
    
    <template>
      <li class="post">
        <header class="post-header">
          <!-- ... -->
        </header>
        <ul class="photos">
          <!-- ... -->
        </ul>
        <article class="details">
          <h3 class="caption">{{ post?.attributes?.caption }}</h3>
          <span class="time">{{ showDateTime(post) }}</span>
        </article>
        <Comments :post="post" />
      </li>
    </template>
```


![A Sample Screenshot](https://paper-attachments.dropbox.com/s_81A70859F8B66737FB32A08BCA6F0249ACDBDA7A963E3F0A04B43A4D285336AF_1655313118632_09b8389e-78d5-4676-b3fa-f22a4eb4a05b_gif.gif)




## Step 8: Integrating Cloudinary

To use Cloudinary as a media provider, we have to first set up a Cloudinary account. Go to your [Cloudinary Console](https://cloudinary.com/console/) to obtain API keys.


![A Sample Screenshot](https://paper-attachments.dropbox.com/s_81A70859F8B66737FB32A08BCA6F0249ACDBDA7A963E3F0A04B43A4D285336AF_1655323042959_image.png)


 Let’s head back to our Strapi backend and integrate Cloudinary.  Create a new `.env` file in the root folder of the project.
 
```
    #.env
    CLOUDINARY_NAME=<cloudinary_name_here>
    CLOUDINARY_KEY=<cloudinary_key_here>
    CLOUDINARY_SECRET=<cloudinary_secret_here>
    CLOUDINARY_FOLDER=strapi-photos-app
```
Stop the server and install the [**@strapi/provider-upload-cloudinary**](https://www.npmjs.com/package/@strapi/provider-upload-cloudinary) package:
 
```bash
    # using yarn
    yarn add @strapi/provider-upload-cloudinary
    
    # using npm
    npm install @strapi/provider-upload-cloudinary --save
```
Next, configure the plugin in the `./config/plugins.js` file

```js
    // ./config/plugins.js
    
    module.exports = ({ env }) => ({
      //...
      upload: {
        config: {
          provider: 'cloudinary',
          providerOptions: {
            cloud_name: env('CLOUDINARY_NAME'),
            api_key: env('CLOUDINARY_KEY'),
            api_secret: env('CLOUDINARY_SECRET'),
          },
          actionOptions: {
            upload: {},
            uploadStream: {
              folder: env('CLOUDINARY_FOLDER'),
            },
            delete: {},
          },
        },
      },
      //...
    });
```
> NOTE: In order to fix the preview issue on the Strapi dashboard where after uploading a photo, it will upload to Cloudinary, but the preview of the photo won’t display on the Strapi admin dashboard, replace  `strapi::security` string with the object below in `./config/middlewares.js`.

```js
    // config/middlewares.js
         
    module.exports = [
      'strapi::errors',
      {
        name: 'strapi::security',
        config: {
          contentSecurityPolicy: {
            useDefaults: true,
            directives: {
              'connect-src': ["'self'", 'https:'],
              'img-src': ["'self'", 'data:', 'blob:', 'dl.airtable.com', 'res.cloudinary.com'],
              'media-src': ["'self'", 'data:', 'blob:', 'dl.airtable.com', 'res.cloudinary.com'],
              upgradeInsecureRequests: null,
            },
          },
        },
      },
      'strapi::cors',
      'strapi::poweredBy',
      'strapi::logger',
      'strapi::query',
      'strapi::body',
      'strapi::session',
      'strapi::favicon',
      'strapi::public',
    ];
```
Awesome. Restart the Strapi app for the changes to take effect.

Back in our frontend however, we need to make a few changes to make sure that the images display correctly. This is because, up until now, the image URLs returned by Strapi were relative URLs (e.g. `/uploads/<file_name>.jpeg`) and we had to append this image URL to the Strapi URL (`http://localhost:1337/`) in order to load the image.

This won’t be necessary anymore with Cloudinary integrated. We can now replace any instance of `:src="strapiURL + photo?.attributes?.url"` with just `:src="photo?.attributes?.url"`. 

With Cloudinary integrated, we can now easily deploy our Strapi application to Heroku and not worry about losing the images due to Heroku’s storage. We won’t be covering deployment however as it’s beyond the scope of this tutorial.


## Conclusion

In this tutorial we’ve managed to build a photo hsaring app with many of its core functionalities. With this we're able to learn and understand how we can setup a Strapi nstance with a Postgres database instead of the default SQLite, add and and manage our content with GraphQL instead of REST and finally integrate Cloudinary for image uploads. On the frontend, we learnt how to setup an SSR enabled application with Nuxt 3 and communicate with our GraphQL backend using the native Fetch API.

With this we can now see how we can build almost anything using Strapi as a Headless CMS.


## Further Reading & Resources

Here are a few resources you might find useful.

### Code
Are you stuck anywhere? Here are links to the code for this project:

- Frontend - https://github.com/miracleonyenma/strapi-photos-app-frontend
    Here are the different branches you can explore for each feature implemented in the tutorial
    - [With authentication implemented](https://github.com/miracleonyenma/strapi-photos-app-frontend/tree/auth)
    - [With fetch and display posts feature implemented](https://github.com/miracleonyenma/strapi-photos-app-frontend/tree/fetch-display-posts)
    - [With create posts feature implemented](https://github.com/miracleonyenma/strapi-photos-app-frontend/tree/create-posts)
    - [With edit posts feature implemented](https://github.com/miracleonyenma/strapi-photos-app-frontend/tree/create-posts)
    - [With comments feature implemented](https://github.com/miracleonyenma/strapi-photos-app-frontend/tree/comments-functionality)
    - [With Cloudinary support](https://github.com/miracleonyenma/strapi-photos-app-frontend/tree/cloudinary-support)
    
- Backend - https://github.com/miracleonyenma/strapi-photos-app-backend
    Here are the different branches you can explore for each feature implemented in the tutorial
    - [With Postgres & Strapi comments plugin support](https://github.com/miracleonyenma/strapi-photos-app-backend/tree/postgres-strapi-comments-support)
    - [With cloudinary support](https://github.com/miracleonyenma/strapi-photos-app-backend/tree/cloudinary-support)

#### Reading

- [Strapi GraphQL Documentation](https://docs.strapi.io/developer-docs/latest/plugins/graphql.html)
- [Strapi Comments plugin documentation](https://github.com/VirtusLab-Open-Source/strapi-plugin-comments)
- [Strapi Cloudinary Plugin](https://www.npmjs.com/package/@strapi/provider-upload-cloudinary)