5

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

 2 years ago
source link: https://dev.to/sithcode/how-to-build-a-trello-clone-app-with-vue-js-series-portfolio-apps-3fn9
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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 modeExit 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 modeExit 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".

[ 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 modeExit 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 modeExit 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 modeExit 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 modeExit 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 modeExit 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 modeExit 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 modeExit 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 modeExit 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 modeExit 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 modeExit 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 modeExit 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 modeExit 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 modeExit 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 modeExit 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 modeExit 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 😉


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK