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
[ 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
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;
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);
},
};
# ../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);
},
};
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;
},
};
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>
# ../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");
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
# ../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>
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
# ../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>
# ../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>
# ../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>
```
Perfect ! Only one last step and we will be able to use this Trello Clone.
## [ 2.2 ] View & Router <a name="view-router"></a>
Let's import all components needs in "Board.vue" view.
```js
# ../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>
```
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
```js
# ../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;
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 😉
Top comments (1)
How to update the listId of card when dragged to another list?