How to build a Trello Clone App with Vue.js [ Series - Portfolio Apps ]
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
[ 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
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>
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
# ../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 😉
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK