logo My Digital Garden

First Vue JS Project

By James Kolean on Jan 25, 2023
Source repository: https://gitlab.com/jameskolean/first-vue
Demo: https://jameskolean.gitlab.io/first-vue
VueJavaScriptTailwind
banner

I have worked in React for several years and was recently called upon to help on a Vue project. I wrote my first VueJs app, a note app using Pinia for state management and Tailwind. I started using pnpm too. I am trying to keep an open mind and am pretty happy with Vue, but I can’t recommend creating a new project in Vue in 2023. Adoption rates for both Vue and React are on the decline. With the Vue community just a fraction of the React community size, it would not be responsible to lock an organization into a tech stack that may not be around in 5 years. Vue doesn’t do anything radically different, so I don’t see any reason to switch there. Innovation in the React space is going to outpace anything in Vue. If you are looking for the next big thing, you should look beyond Angular, React, and Vue. What that thing is, is unclear to me now. There is potential in Remix, Astro, and 11ty.

Let’s get started building my Vue app.

Create App

npm init vue@latest full-vue
Vue.js - The Progressive JavaScript Framework

 Add TypeScript? No / _Yes_
 Add JSX Support? _No_ / Yes
 Add Vue Router for Single Page Application development? No / _Yes_
 Add Pinia for state management? No / _Yes_
 Add Vitest for Unit Testing? No / _Yes_
 Add an End-to-End Testing Solution? No
 Add ESLint for code quality? No / _Yes_
 Add Prettier for code formatting? No / _Yes_

Scaffolding project in .../full-vue/full-vue...

Done. Now run:

  cd full-vue
  npm install
  npm run lint
  npm run dev

That gets us close, but I want to use pnpm and Tailwind. I want a modal component, so let’s pull that in too.

cd full-vue
brew install pnpm
pnpm  install
pnpm install -D tailwindcss concurrently
pnpm install vue-final-modal@3

Configure Tailwind and Modal

/* tailwind.config.js */
/* eslint-disable no-undef */
/** @type {import('tailwindcss').Config} */
module.exports = {
    content: ["./src/**/*.{html,js,vue}"],
    theme: {
        extend: {},
    },
    plugins: [],
};
/* tailwind.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

Edit package.json

...
  "scripts": {
    "tailwind": "npx tailwindcss -i ./tailwind.css -o ./src/assets/tailwind.css",
    "dev": "concurrently --kill-others \"npm run tailwind --watch\" \"vite\"",
    "build": "run-p tailwind type-check build-only",
...
// src/main.ts
import { createPinia } from "pinia";
import { createApp } from "vue";
import { vfmPlugin } from "vue-final-modal";
import App from "./App.vue";
import router from "./router";

import "./assets/main.css";
import "./assets/tailwind.css";

const app = createApp(App);

app.use(createPinia());
app.use(router);

app.use(
  vfmPlugin({
    key: "$vfm",
    componentName: "VueFinalModal",
    dynamicContainerName: "ModalsContainer",
  })
);
app.mount("#app");

Create a state manager

// src/stores/note.ts
import { defineStore } from "pinia";

export interface Note {
title: string;
body: string;
}
export interface State {
notes: Note[];
}

export const useNoteStore = defineStore("note", {
state: (): State => ({
notes: [],
}),
actions: {
addNote(title: string, body: string) {
const existingNoteIndex = this.notes.findIndex(
(note) => note.title === title
);
if (existingNoteIndex >= 0) {
const newNotes = [
...this.notes.slice(0, existingNoteIndex),
{ title, body },
...this.notes.slice(existingNoteIndex + 1),
];
this.notes = newNotes;
} else {
this.notes.push({ title, body });
}
},
removeNote(title: string) {
const newNotes = this.notes.filter((note) => note.title !== title);
this.notes = newNotes;
},
},
});

Update Router

// src/router/index.ts
import { createRouter, createWebHistory } from "vue-router";
import HomeView from "../views/HomeView.vue";

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: "/",
      name: "home",
      component: HomeView,
    },
    {
      path: "/notes",
      name: "notes",
      // route level code-splitting
      // this generates a separate chunk (About.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () => import("../views/NotesView.vue"),
    },
  ],
});

export default router;
<!-- src/App.vue -->
<script setup lang="ts">
    import {
        RouterLink,
        RouterView
    } from "vue-router";
</script>

<template>
    <header>
        <nav>
            <RouterLink to="/">Home</RouterLink>
            <RouterLink to="/notes">Notes</RouterLink>
        </nav>
    </header>
    <RouterView />
</template>

<style scoped>
    nav {
        text-align: center;
        font-size: 1rem;
        padding: 1rem 0;
        margin-top: 1rem;
    }

    nav a {
        display: inline-block;
        padding: 0 1rem;
        border-left: 1px solid var(--color-border);
    }

    nav a:first-of-type {
        border: 0;
    }
</style>

Display Notes

<!-- src/views/NotesView.vue -->
<script setup lang="ts">
    import AddNote from "../components/AddNote.vue";
    import NoteCard from "../components/NoteCard.vue";
    import {
        useNoteStore
    } from "../stores/note";
    const noteStore = useNoteStore();
</script>

<template>
    <main>
        <AddNote />
        <div class="flex flex-col gap-3">
            <div v-for="note in noteStore.notes" :key="note.title">
                <NoteCard :note="note" />
            </div>
        </div>
    </main>
</template>

<style scoped>
    .cards-wrapper {
        display: flex;
        flex-direction: column;
        gap: 10px;
    }
</style>
<!-- src/components/NoteCard.vue -->
<script setup lang="ts">
    import {
        defineProps
    } from "vue";
    import type {
        Note
    } from "../stores/note";
    import {
        useNoteStore
    } from "../stores/note";
    defineProps < {
        note: Note;
    } > ();
    const noteStore = useNoteStore();
    const removeNote = (title: string) => {
        noteStore.removeNote(title);
    };
</script>

<template>
    <div class="block max-w-3xl p-6 bg-white border border-gray-200 rounded-lg shadow hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700">
        <button @click="removeNote(note.title)" class="absolute top-5 right-5 text-xl z-10">
            &times;
        </button>

        <h5 class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
            {{ note.title }}
        </h5>
        <p class="font-normal text-gray-700 dark:text-gray-400">
            {{ note.body }}
        </p>
    </div>
</template>

<style scoped>
    .note-card {
        padding: 10px;
        background-color: lightgray;
        color: black;
        border-radius: 10px;
    }

    h2 {
        font-weight: 500;
        font-size: 2.6rem;
        top: -10px;
    }

    p {
        font-size: 1.2rem;
    }
</style>
<!-- src/components/AddNote.vue -->
<script setup lang="ts">
    import {
        ref
    } from "vue";
    import {
        useNoteStore
    } from "../stores/note";
    const noteStore = useNoteStore();
    const showModal = ref(false);
    const title = ref("");
    const body = ref("");
    const error = ref("");
    const addNote = (titleVal: string, bodyVal: string) => {
        if (!titleVal) {
            error.value = "Title is required.";
            return;
        }
        if (!bodyVal) {
            error.value = "Note body is required.";
            return;
        }
        noteStore.addNote(titleVal, bodyVal);
        title.value = "";
        body.value = "";
        showModal.value = false;
    };
</script>
<template>
    <div>
        <vue-final-modal v-model="showModal" classes="modal-container" content-class="modal-content">
            <button class="modal__close" @click="showModal = false">&times;</button>
            <span class="modal__title block text-gray-400 text-3xl font-bold mb-2">Create a note</span>
            <div class="modal__content">
                <form>
                    <div class="mb-4">
                        <label class="block text-gray-400 text-sm font-bold mb-2" for="title">
                            Title
                        </label>
                        <input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="title" type="text" v-model="title" />
                    </div>
                    <div class="mb-6">
                        <label class="block text-gray-400 text-sm font-bold mb-2" for="body">
                            Note Body
                        </label>
                        <textarea rows="4" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" id="body" v-model="body"></textarea>
                    </div>
                    <p v-if="error" class="text-red-500 text-xs italic">{{ error }}</p>
                </form>
            </div>
            <div class="modal__action">
                <button @click="addNote(title, body)" type="button" class="text-gray-900 bg-white border border-gray-300 focus:outline-none hover:bg-gray-100 focus:ring-4 focus:ring-gray-200 font-medium rounded-full text-sm px-5 py-2.5 mr-2 mb-2 dark:bg-gray-800 dark:text-white dark:border-gray-600 dark:hover:bg-gray-700 dark:hover:border-gray-600 dark:focus:ring-gray-700">
                    add
                </button>
                <button @click="showModal = false" type="button" class="py-2.5 px-5 mr-2 mb-2 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-full border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700">
                    cancel
                </button>
            </div>
        </vue-final-modal>
        <button @click="showModal = true" class="text-fuchsia-300 background-transparent font-bold uppercase px-3 py-1 text-xs outline-none focus:outline-none mr-1 mb-1 ease-linear transition-all duration-150" type="button">
            add note &rarr;
        </button>
    </div>
</template>
<style scoped>
    ::v-deep .modal-container {
        display: flex;
        justify-content: center;
        align-items: center;
        min-width: 500px;
    }

    ::v-deep .modal-content {
        position: relative;
        display: flex;
        flex-direction: column;
        max-height: 90%;
        margin: 0 1rem;
        padding: 1rem;
        border: 1px solid #e2e8f0;
        border-radius: 0.25rem;
        background: #000;
    }

    .modal__content {
        flex-grow: 1;
        overflow-y: auto;
        width: 50vw;
        min-width: 400px;
    }

    .modal__action {
        display: flex;
        justify-content: center;
        align-items: center;
        flex-shrink: 0;
        padding: 1rem 0 0;
    }

    .modal__close {
        position: absolute;
        top: 0.5rem;
        right: 0.5rem;
    }
</style>
© Copyright 2024 Digital Garden cultivated by James Kolean.