Vue.js course Β· Module 14: Directives & Plugins

Project: API Dashboard with Directives and Plugins

2 min read
In this lesson3

This is the main module project! We'll combine everything we've learned: custom directives, plugins, and HTTP/API communication into one cohesive system - the NOVA LAB Command Center Dashboard.

Project Architecture

Our dashboard contains:

  1. Toast Plugin - notifications about station events
  2. Custom Directives - v-focus, v-tooltip, v-highlight
  3. Composable useFetch - fetching data from an API
  4. Loading/error/data states - error handling
  5. CRUD Actions - adding and removing data

Code Structure

Toast Plugin:

1const ToastPlugin = {
2  install(app) {
3    const toasts = ref([])
4    let id = 0
5
6    app.provide('toast', {
7      show(message, type = 'info') {
8        const toastId = id++
9        toasts.value.push({ id: toastId, message, type })
10        setTimeout(() => {
11          toasts.value = toasts.value.filter(t => t.id !== toastId)
12        }, 3000)
13      },
14      success(msg) { this.show(msg, 'success') },
15      error(msg) { this.show(msg, 'error') },
16      list: toasts
17    })
18  }
19}

Custom Directives:

1app.directive('focus', {
2  mounted(el) { el.focus() }
3})
4
5app.directive('highlight', (el, binding) => {
6  el.style.border = `2px solid ${binding.value || '#00ff88'}`
7})

Composable:

1function useFetch(url) {
2  const data = ref(null)
3  const loading = ref(false)
4  const error = ref(null)
5
6  async function execute() {
7    loading.value = true
8    error.value = null
9    try {
10      const res = await fetch(url)
11      data.value = await res.json()
12    } catch (err) {
13      error.value = err.message
14    } finally {
15      loading.value = false
16    }
17  }
18
19  execute()
20  return { data, loading, error, refetch: execute }
21}

Combining into a Dashboard

The dashboard displays:

  • A list of missions fetched from the API
  • A form for adding new missions
  • Toast notifications for actions
  • Directives for interactivity (focus, highlight)

This project demonstrates how different parts of Vue (directives, plugins, composables) work together in a real application.

Code for this lesson: App.vue
1<script setup>
2import { ref, inject } from 'vue'
3import { useFetch } from './composables/useFetch.js'
4
5const toast = inject('toast')
6
7const vFocus = {
8  mounted(el) { el.focus() }
9}
10
11const vHighlight = (el, binding) => {
12  el.style.border = '2px solid ' + (binding.value || '#00ff88')
13  el.style.borderRadius = '8px'
14}
15
16const { data: missions, loading, error, refetch } = useFetch(
17  'https://jsonplaceholder.typicode.com/posts?_limit=8'
18)
19
20const newMission = ref('')
21const sending = ref(false)
22
23async function addMission() {
24  if (!newMission.value.trim()) return
25  sending.value = true
26
27  try {
28    const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
29      method: 'POST',
30      headers: { 'Content-Type': 'application/json' },
31      body: JSON.stringify({
32        title: newMission.value,
33        body: 'NOVA LAB station mission',
34        userId: 1
35      })
36    })
37    const created = await response.json()
38    missions.value.unshift({ ...created, id: Date.now() })
39    newMission.value = ''
40    toast.success('Mission added successfully!')
41  } catch (err) {
42    toast.error('Error adding mission: ' + err.message)
43  } finally {
44    sending.value = false
45  }
46}
47
48function removeMission(id) {
49  missions.value = missions.value.filter(m => m.id !== id)
50  toast.warning('Mission removed')
51}
52</script>
53
54<template>
55  <div class="nova-lab">
56    <h1>Dashboard - NOVA LAB Command Center</h1>
57
58    <section v-highlight="'#00b4d8'" class="section">
59      <h2>New mission</h2>
60      <form @submit.prevent="addMission" class="form">
61        <input
62          v-focus
63          v-model="newMission"
64          placeholder="Mission name..."
65          class="input"
66          :disabled="sending"
67        />
68        <button type="submit" class="btn" :disabled="sending">
69          {{ sending ? '...' : 'Add' }}
70        </button>
71      </form>
72    </section>
73
74    <section v-highlight class="section">
75      <div class="header-row">
76        <h2>Active missions</h2>
77        <button @click="refetch" class="btn small">Refresh</button>
78      </div>
79
80      <div v-if="loading" class="status loading">Loading...</div>
81      <div v-else-if="error" class="status error">{{ error }}</div>
82      <div v-else>
83        <div v-for="m in missions" :key="m.id" class="mission">
84          <div>
85            <strong class="mission-id">#{{ m.id }}</strong>
86            <span class="mission-title">{{ m.title }}</span>
87          </div>
88          <button @click="removeMission(m.id)" class="btn del">X</button>
89        </div>
90        <p class="count">Total: {{ missions.length }} missions</p>
91      </div>
92    </section>
93
94    <div class="toast-container">
95      <div
96        v-for="t in toast.list.value"
97        :key="t.id"
98        :class="['toast', t.type]"
99      >
100        {{ t.message }}
101      </div>
102    </div>
103  </div>
104</template>
105
106<style scoped>
107.nova-lab {
108  background: #0a0e27;
109  color: #c0c0c0;
110  min-height: 100vh;
111  padding: 20px;
112  font-family: monospace;
113}
114h1 { color: #00ff88; margin-bottom: 20px; font-size: 18px; }
115h2 { color: #00b4d8; font-size: 16px; margin: 0; }
116.section { padding: 15px; margin-bottom: 15px; }
117.form { display: flex; gap: 10px; margin-top: 10px; }
118.input {
119  flex: 1;
120  background: rgba(0,255,136,0.05);
121  border: 1px solid #00ff88;
122  color: #00ff88;
123  padding: 10px;
124  border-radius: 6px;
125  font-family: monospace;
126}
127.input:focus { outline: none; box-shadow: 0 0 8px rgba(0,255,136,0.3); }
128.btn {
129  background: rgba(0,255,136,0.15);
130  color: #00ff88;
131  border: 1px solid #00ff88;
132  padding: 8px 16px;
133  border-radius: 6px;
134  cursor: pointer;
135  font-family: monospace;
136}
137.btn.small { font-size: 12px; padding: 4px 10px; }
138.btn.del {
139  color: #e74c3c; border-color: #e74c3c;
140  background: rgba(231,76,60,0.15);
141  padding: 4px 10px;
142}
143.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
144.status { padding: 15px; text-align: center; }
145.status.loading { color: #00b4d8; }
146.status.error { color: #e74c3c; }
147.mission {
148  display: flex; justify-content: space-between; align-items: center;
149  padding: 8px 0;
150  border-bottom: 1px solid rgba(0,180,216,0.1);
151}
152.mission:last-child { border-bottom: none; }
153.mission-id { color: #00b4d8; margin-right: 8px; }
154.mission-title { font-size: 13px; }
155.count { color: #666; font-size: 12px; margin-top: 10px; text-align: right; }
156.toast-container {
157  position: fixed; top: 20px; right: 20px;
158  display: flex; flex-direction: column; gap: 8px; z-index: 1000;
159}
160.toast {
161  padding: 10px 18px; border-radius: 8px;
162  font-family: monospace; font-size: 13px;
163  animation: slideIn 0.3s ease;
164}
165.toast.success { background: rgba(46,204,113,0.9); color: white; }
166.toast.error { background: rgba(231,76,60,0.9); color: white; }
167.toast.warning { background: rgba(243,156,18,0.9); color: white; }
168.toast.info { background: rgba(0,180,216,0.9); color: white; }
169@keyframes slideIn {
170  from { transform: translateX(100%); opacity: 0; }
171  to { transform: translateX(0); opacity: 1; }
172}
173</style>

Spotted a mistake in this lesson?

Useful articles