DEV Community

Cover image for How to build a Trello Clone App with Vue.js [ Series - Portfolio Apps ]
Sith Norvang
Sith Norvang

Posted on

How to build a Trello Clone App with Vue.js [ Series - Portfolio Apps ]

This third episode of "Portfolio Apps" series is dedicated to build a Trello Clone. Classic right ? I propose one softer tutorial than you can find on Vue Mastery. I hope you will enjoy it !

1.0 / Setup

2.0 / Components & Router

[ 1.1 ] Install Vue 3

# Install latest stable of Vue

yarn global add @vue/cli
Enter fullscreen mode Exit fullscreen mode

[ 1.2 ] Creating a new project

This time, let's manually select features for this new Vue application.

# run this command

vue create trello-clone
Enter fullscreen mode Exit fullscreen mode

Do you remember previous tutorial about "Shopping Cart App" ? We saved a preset configuration. Let's use it !

I named it "config-portfolio".

Config with preset

Config done

[ 1.3 ] Vuex Configuration

As each tutorial, we are going to use Vuex. Let's dive in.

# ../store/index.js

import { createStore } from "vuex";

import rootMutations from "./mutations.js";
import rootActions from "./actions.js";
import rootGetters from "./getters.js";

const store = createStore({
  state() {
    return {
      overlay: false,
      lastListId: 3,
      lastCardId: 5,
      currentData: null,
      lists: [
        {
          id: 1,
          name: "list #1",
        },
        {
          id: 2,
          name: "list #2",
        },
        {
          id: 3,
          name: "list #3",
        },
      ],
      cards: [
        {
          listId: 1,
          id: 1,
          name: "card 1",
        },
        {
          listId: 2,
          id: 2,
          name: "card 2",
        },
        {
          listId: 3,
          id: 3,
          name: "card 3",
        },
      ],
    };
  },
  mutations: rootMutations,
  actions: rootActions,
  getters: rootGetters,
});

export default store;

Enter fullscreen mode Exit fullscreen mode

We need creating 6 actions & mutations.

# ../store/actions.js

export default {
  createList(context, payload) {
    context.commit("createNewList", payload);
  },
  createCard(context, payload) {
    context.commit("createNewCard", payload);
  },
  toggleOverlay(context) {
    context.commit("toggleOverlay");
  },
  openForm(context, payload) {
    context.commit("openForm", payload);
  },
  saveCard(context, payload) {
    context.commit("saveCard", payload);
  },
  deleteCard(context, payload) {
    context.commit("deleteCard", payload);
  },
};

Enter fullscreen mode Exit fullscreen mode
# ../store/mutations.js

export default {
  createNewList(state, payload) {
    state.lastListId++;
    const list = {
      id: state.lastListId,
      name: payload,
    };
    state.lists.push(list);
  },
  createNewCard(state, payload) {
    state.lastCardId++;
    const card = {
      listId: payload.listId,
      id: this.lastCardId,
      name: payload.name,
    };
    state.cards.push(card);
  },
  toggleOverlay(state) {
    state.overlay = !state.overlay;
  },
  openForm(state, payload) {
    state.currentData = payload;
  },
  saveCard(state, payload) {
    state.cards = state.cards.map((card) => {
      if (card.id === payload.id) {
        return Object.assign({}, card, payload);
      }
      return card;
    });
  },
  deleteCard(state, payload) {
    const indexToDelete = state.cards
      .map((card) => card.id)
      .indexOf(payload.id);
    state.cards.splice(indexToDelete, 1);
  },
};

Enter fullscreen mode Exit fullscreen mode

To complete our store configuration, let's initialize 6 getters.

export default {
  lastListId(state) {
    return state.lastListId;
  },
  lastCardId(state) {
    return state.lastCardId;
  },
  lists(state) {
    return state.lists;
  },
  cards(state) {
    return state.cards;
  },
  overlay(state) {
    return state.overlay;
  },
  currentData(state) {
    return state.currentData;
  },
};

Enter fullscreen mode Exit fullscreen mode

Great ! Our Vuex configuration is over now 👍

[ 1.4 ] App.vue & Main.js

Before create our components, we need to change some detail in App.vue and main.js files :

# ../App.vue

<template>
  <router-view />
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

body {
  margin: 0;
  overflow: hidden;
}

input {
  border: none;
  font-size: 15px;
  outline: none;
}
</style>

Enter fullscreen mode Exit fullscreen mode
# ../main.js

import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store/index.js";

const app = createApp(App);

app.use(router);
app.use(store);
app.mount("#app");

Enter fullscreen mode Exit fullscreen mode

Well configuration files is over ! Next step ? Creating all components.

[ 2.1 ] Components

This Trello Clone needs four components :

components
|-- Card.vue
|-- CardList.vue
|-- Overlay.vue
|-- Popup.vue

Enter fullscreen mode Exit fullscreen mode
# ../components/Card.vue

<template>
  <input
    class="input-card"
    type="text"
    placeholder="Create a Card"
    v-model="cardName"
    @keyup.enter="createCard"
  />
</template>

<script>
export default {
  props: ["listId"],
  methods: {
    createCard() {
      if (this.cardName !== "") {
        const card = {
          listId: this.listId,
          name: this.cardName,
        };
        this.$store.dispatch("createCard", card);
        this.cardName = "";
      }
    },
  },
};
</script>

<style>
.input-card {
  position: relative;
  background-color: white;
  min-height: 30px;
  width: 100%;
  display: flex;
  align-items: center;
  border-radius: 5px;
  padding: 10px;
  word-break: break-all;
  font-size: 16px;
}
</style>

Enter fullscreen mode Exit fullscreen mode

About CardsList.vue component, we need installing a new dependency which allow us using "drag and drop" easily.

https://github.com/anish2690/vue-draggable-next

npm install vue-draggable-next

# or

yarn add vue-draggable-next
Enter fullscreen mode Exit fullscreen mode
# ../components/CardsList.vue

<template>
  <draggable :options="{ group: 'cards' }" group="cards" ghostClass="ghost">
    <span
      class="element-card"
      v-for="(card, index) in cards"
      :key="index"
      @click="togglePopup(card)"
    >
      {{ card.name }}
    </span>
  </draggable>
</template>

<script>
import { VueDraggableNext } from "vue-draggable-next";

export default {
  props: ["listId", "listName"],
  components: {
    draggable: VueDraggableNext,
  },
  methods: {
    togglePopup(data) {
      const currentData = {
        listId: this.listId,
        listName: this.listName,
        id: data.id,
        name: data.name,
      };
      this.$store.dispatch("toggleOverlay");
      this.$store.dispatch("openForm", currentData);
    },
  },
  computed: {
    cards() {
      const cardFilteredByListId = this.$store.getters["cards"];
      return cardFilteredByListId.filter((card) => {
        if (card.listId === this.listId) {
          return true;
        } else {
          return false;
        }
      });
    },
    overlayIsActive() {
      return this.$store.getters["overlay"];
    },
  },
};
</script>

<style>
.element-card {
  position: relative;
  background-color: white;
  height: auto;
  display: flex;
  align-items: center;
  padding: 10px;
  border-radius: 5px;
  min-height: 30px;
  margin-bottom: 10px;
  word-break: break-all;
  text-align: left;
}
</style>

Enter fullscreen mode Exit fullscreen mode
# ../components/Overlay.vue

<template>
  <transition>
    <div v-if="overlayIsActive" class="overlay" @click="closeOverlay"></div>
  </transition>
</template>

<script>
export default {
  methods: {
    closeOverlay() {
      this.$store.dispatch("toggleOverlay");
    },
  },
  computed: {
    overlayIsActive() {
      return this.$store.getters["overlay"];
    },
  },
};
</script>

<style>
.overlay {
  background-color: rgba(0, 0, 0, 0.5);
  position: absolute;
  height: 100%;
  width: 100%;
  z-index: 500;
}

.v-enter-from {
  opacity: 0;
}

.v-enter-active {
  transition: all 0.3s ease-out;
}

.v-enter-to {
  opacity: 1;
}

.v-leave-from {
  opacity: 1;
}

.v-leave-active {
  transition: all 0.3s ease-in;
}

.v-leave-to {
  opacity: 0;
}
</style>

Enter fullscreen mode Exit fullscreen mode
# ../components/Popup.vue

<template>
  <transition>
    <div v-if="overlay" class="modal">
      <h1>List Name : {{ currentData.listName }}</h1>
      <input :placeholder="currentData.name" v-model="cardName" />
      <div class="container-button">
        <button class="blue" @click="saveElement">save</button>
        <button class="red" @click="deleteElement">delete</button>
      </div>
    </div>
  </transition>
</template>

<script>
import { mapGetters } from "vuex";

export default {
  data() {
    return {
      cardName: null,
    };
  },
  computed: {
    ...mapGetters(["overlay", "currentData"]),
  },
  methods: {
    saveElement() {
      if (this.cardName === null) {
        this.cardName = this.currentData.name;
      }
      const card = {
        listId: this.currentData.listId,
        id: this.currentData.id,
        name: this.cardName,
      };
      this.$store.dispatch("saveCard", card);
      this.cardName = null;
      this.$store.dispatch("toggleOverlay");
    },
    deleteElement() {
      this.$store.dispatch("deleteCard", this.currentData);
      this.$store.dispatch("toggleOverlay");
    },
  },
};
</script>

<style scoped>
.v-enter-from {
  opacity: 0;
}

.v-enter-active {
  transition: all 0.3s ease-out;
}

.v-enter-to {
  opacity: 1;
}

.v-leave-from {
  opacity: 1;
}

.v-leave-active {
  transition: all 0.3s ease-in;
}

.v-leave-to {
  opacity: 0;
}

.modal {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 20px;
  position: absolute;
  height: 500px;
  width: 500px;
  border-radius: 10px;
  background-color: rgba(235, 236, 240, 1);
  z-index: 550;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

input {
  width: 250px;
  height: 50px;
  padding: 10px 20px 10px 20px;
  border: 1px solid rgba(60, 60, 60, 0.2);
  border-radius: 15px;
}

button {
  display: flex;
  border: none;
  color: white;
  padding: 15px 32px;
  text-align: center;
  text-decoration: none;
  display: inline-block;
  font-size: 16px;
  border-radius: 15px;
  cursor: pointer;
  transition-duration: 0.4s;
}

button:hover {
  color: white;
}

.blue {
  background-color: rgba(1, 100, 255, 1);
}

.blue:hover {
  background-color: rgba(1, 100, 255, 0.8);
}

.red {
  background-color: rgba(250, 52, 75, 1);
}
.red:hover {
  background-color: rgba(250, 52, 75, 0.8);
}

.container-button {
  display: flex;
  flex-direction: row;
  gap: 30px;
}
</style>

Enter fullscreen mode Exit fullscreen mode

Perfect ! Only one last step and we will be able to use this Trello Clone.

[ 2.2 ] View & Router

Let's import all components needs in "Board.vue" view.

# ../views/Board.vue

<template>
  <main class="list-container">
    <Overlay />
    <Popup />
    <section class="list-wrapper">
      <draggable
        :options="{ group: 'lists' }"
        group="lists"
        ghostClass="ghost"
        class="list-draggable"
      >
        <div class="list-card" v-for="(list, index) in lists" :key="index">
          <label class="list-header">{{ list.name }}</label>
          <div class="list-content">
            <CardsList :listId="list.id" :listName="list.name" />
          </div>
          <div class="list-footer">
            <Card :listId="list.id" />
          </div>
        </div>
      </draggable>
      <input
        type="text"
        class="input-new-list"
        placeholder="Create a List"
        v-model="listName"
        @keyup.enter="createList"
      />
    </section>
  </main>
</template>

<script>
import { VueDraggableNext } from "vue-draggable-next";
import CardsList from "@/components/CardsList";
import Card from "@/components/Card.vue";
import Overlay from "@/components/Overlay";
import Popup from "@/components/Popup";

export default {
  components: {
    draggable: VueDraggableNext,
    CardsList,
    Card,
    Overlay,
    Popup,
  },
  data() {
    return {
      listName: "",
    };
  },
  methods: {
    createList() {
      if (this.listName !== "") {
        this.$store.dispatch("createList", this.listName);
        this.listName = "";
      }
    },
  },
  computed: {
    lists() {
      return this.$store.getters["lists"];
    },
  },
};
</script>

<style>
.list-container {
  position: relative;
  display: flex;
  width: 100vw;
  height: 100vh;
  border: 1px;
  z-index: 10;
}

.list-wrapper {
  position: relative;
  display: flex;
  flex-direction: row;
  box-sizing: border-box;
  min-width: 100vw;
  height: 100vh;
  padding: 20px;
  background-repeat: no-repeat;
  background-attachment: fixed;
  background-position: center;
  background-size: cover;
  background-image: url("../assets/background-image.jpg");
  gap: 20px;
  overflow-x: scroll;
  overflow-y: hidden;
}

.ghost {
  opacity: 0.5;
}

.list-draggable {
  display: flex;
  gap: 20px;
}

.input-new-list {
  display: flex;
  height: 30px;
  padding: 10px;
  border-radius: 5px;
  background-color: rgba(235, 236, 240, 0.5);
  min-width: 260px;
}

.input-new-list::placeholder {
  color: white;
}

.list-card {
  position: relative;
  display: flex;
  flex-direction: column;
  min-width: 300px;
  height: auto;
}

.list-header {
  position: relative;
  display: flex;
  justify-content: center;
  word-break: break-all;
  align-items: center;
  min-width: 280px;
  max-width: 280px;
  line-height: 50px;
  padding: 0px 10px 0px 10px;
  background-color: rgba(235, 236, 240, 1);
  border-radius: 10px 10px 0px 0px;
  color: rgba(24, 43, 77, 1);
  user-select: none;
}

.list-content {
  overflow-y: scroll;
  position: relative;
  display: flex;
  flex-direction: column;
  min-width: 280px;
  max-width: 280px;
  height: auto;
  background-color: rgba(235, 236, 240, 1);
  padding: 0px 10px 0px 10px;
  box-shadow: 1.5px 1.5px 1.5px 0.1px rgba(255, 255, 255, 0.1);
  color: rgba(24, 43, 77, 1);
}

.list-footer {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 280px;
  background-color: rgba(235, 236, 240, 1);
  border-radius: 0px 0px 10px 10px;
  color: white;
  border-top: 0.5px solid rgba(255, 255, 255, 0.25);
  padding: 0px 10px 10px 10px;
}
</style>

Enter fullscreen mode Exit fullscreen mode

About the background, you just download a image on https://unsplash.com/ and import your file and rename it as following :

assets
|-- background-image.jpg

Enter fullscreen mode Exit fullscreen mode
# ../router/index.js

import { createRouter, createWebHistory } from "vue-router";
import Board from "../views/Board.vue";

const routes = [
  {
    path: "/",
    name: "Board",
    component: Board,
  },
];

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes,
});

export default router;

Enter fullscreen mode Exit fullscreen mode

It's done ! Want to check result ? You can run "yarn serve / npm run serve" in your terminal or just click on link below.

https://trello-clone-sith.netlify.app/

See you in the next episode 😉

Trello Clone

Discussion (0)