Header and footer

In this lesson, we will finalize the Header and Footer components, along with the navigation menu, dark mode switch, and social media links. Additionally, we'll also create a collection with the menu items.


Settings collection

Let's begin by creating the Settings collection. This collection will store the links for the navigation menu displayed in the Header component, as well as the copyright message and social media links in the Footer.

# collections/settings.ts

import { defineCollection } from '#pruvious'

// @see https://pruvious.com/docs/collections
export default defineCollection({
  name: 'settings',
  mode: 'single',
  apiRoutes: {
    read: 'public', // We want to be able to read the settings from the frontend
  },
  fields: {
    menu: {
      type: 'repeater',
      options: {
        subfields: {
          link: {
            type: 'link',
            options: {
              required: true,
            },
          },
          label: {
            type: 'text',
            options: {
              required: true,
            },
          },
        },
        addLabel: 'Add menu item',
        fieldLayout: [['link', 'label']], // Display the subfields in a single row
      },
    },
    copyrightText: {
      type: 'text',
      options: {
        required: true,
      },
    },
    socialMedia: {
      type: 'repeater',
      options: {
        subfields: {
          icon: {
            type: 'icon',
            options: {
              required: true,
            },
          },
          link: {
            type: 'link',
            options: {
              required: true,
            },
          },
          title: {
            type: 'text',
            options: {
              description: 'An optional title displayed on hover',
            },
          },
        },
        addLabel: 'Add social media link',
        fieldLayout: [['icon | 12rem', 'link', 'title']], // Display the subfields in a single row
      },
    },
  },
  dashboard: {
    // Group fields in tabs
    fieldLayout: [
      {
        Header: ['menu'],
        Footer: ['copyrightText', 'socialMedia'],
      },
    ],
  },
})

We can now add data to the collection:

Our Settings collection is publicly accessible through the API. You can preview the data at the following link: http://localhost:3000/api/collections/settings

To retrieve the populated data, add the query parameter populate=true to the URL: http://localhost:3000/api/collections/settings?populate=true

Navigation menu

We will create the navigation menu in the existing Menu.vue component in our project. To retrieve the Settings data, we can use the getCollectionData function from the #pruvious/client alias.

For smaller screens, such as tablets in portrait mode and phones, we will display the menu in a fullscreen overlay. To achieve this, we'll add a button to the header component, located to the right of the dark mode switch. To ensure that this button in Header.vue and the mobile menu in Menu.vue share the same visibility state, we will create the following composable:

# composables/mobile-menu.ts

export const useMobileMenuVisible = () => useState<boolean>('mobile-menu-visible', () => false)

Let's dig into the Menu.vue component:

# components/Menu.vue

<template>
  <nav
    v-if="menu.length"
    class="relative tp:fixed tp:inset-0 tp:z-20 tp:flex tp:overflow-y-auto tp:bg-white/75 tp:backdrop-blur tp:backdrop-filter tp:transition-all tp:duration-300 tp:dark:bg-heading/80"
    :class="{ 'tp:invisible tp:opacity-0': !mobileMenuVisible }"
  >
    <!-- Note: Add the spacing { 15: '3.75rem' } to tailwind.config.js -->
    <ul @mouseleave="updateDotPosition()" class="flex gap-15 tp:m-auto tp:flex-col tp:p-6 tp:text-center">
      <li v-for="({ label, link }, i) of menu" ref="menuItemEls">
        <NuxtLink
          :to="link"
          @mouseenter="updateDotPosition(i)"
          class="text-sm font-bold text-heading transition dark:text-white"
          :class="{ 'tp:underline tp:decoration-2 tp:underline-offset-4': link === route.path }"
        >
          {{ label }}
        </NuxtLink>
      </li>
    </ul>

    <span
      v-if="dotPosition !== null"
      class="absolute top-1/2 -ml-3.5 mt-px h-1.5 w-1.5 rounded-full bg-heading transition duration-300 tp:hidden dark:bg-white"
      :style="{ transform: `translate3d(${dotPosition}px, -50%, 0)` }"
    ></span>
  </nav>
</template>

<script lang="ts" setup>
import { getCollectionData } from '#pruvious/client'
import { useEventListener } from '@vueuse/core'

// Fetch menu data from the Settings collection
const { menu } = await getCollectionData('settings')

const mobileMenuVisible = useMobileMenuVisible()
const menuItemEls = ref<HTMLElement[]>([])
const dotPosition = ref<number | null>(null)
const route = useRoute()

let activeMenuIndex = 0

onMounted(() => {
  activeMenuIndex = menu.findIndex(({ link }) => link === route.path)
  updateDotPosition()
})

useEventListener('resize', () => updateDotPosition())

function updateDotPosition(index?: number) {
  if (activeMenuIndex > -1) {
    menuItemEls.value[index ?? activeMenuIndex].offsetLeft
    dotPosition.value = menuItemEls.value[index ?? activeMenuIndex].offsetLeft
  }
}
</script>

After adding the mobile menu button and adjusting the logo styles, the Header.vue component will look like this:

# components/Header.vue

<template>
  <Container>
    <header class="flex items-center gap-15 ph:gap-8">
      <NuxtLink to="/" class="text-heading transition duration-300 dark:text-white">
        <Logo />
      </NuxtLink>

      <Menu class="ml-auto" />

      <DarkModeSwitch class="tp:ml-auto" />

      <button
        :title="mobileMenuVisible ? 'Close menu' : 'Open menu'"
        @click="toggleMobileMenu()"
        class="z-20 hidden h-8 w-8 tp:block"
        :class="{ 'sticky top-0': mobileMenuVisible, 'relative': !mobileMenuVisible }"
      >
        <span
          v-for="i in 2"
          class="absolute left-1/2 top-1/2 h-[0.078125rem] w-5 -translate-x-1/2 -translate-y-1/2 bg-heading transition-all duration-300 dark:bg-white"
          :class="{
            '-mt-1': i === 1 && !mobileMenuVisible,
            'mt-1': i === 2 && !mobileMenuVisible,
            'rotate-45': i === 1 && mobileMenuVisible,
            '-rotate-45': i === 2 && mobileMenuVisible,
          }"
        ></span>
      </button>
    </header>
  </Container>
</template>

<script lang="ts" setup>
import { useScrollLock } from '@vueuse/core'

const mobileMenuVisible = useMobileMenuVisible()
const isLocked = useScrollLock(document?.body)

function toggleMobileMenu() {
  mobileMenuVisible.value = !mobileMenuVisible.value
  window.scrollTo({ top: 0, behavior: 'smooth' })
  isLocked.value = mobileMenuVisible.value
}
</script>

Here's a preview of our navigation menu:

Social media links

The social media links, like the navigation menu in the header, will also retrieve their data from the Settings collection. We will add the links directly to the Footer.vue component, along with the copyright message:

# components/Footer.vue

<template>
  <Container>
    <footer class="flex items-center justify-between gap-6 ph:flex-col">
      <p class="text-sm">{{ copyrightText }}</p>

      <ul v-if="socialMedia.length" class="flex flex-wrap gap-4">
        <li v-for="{ icon, link, title } of socialMedia">
          <a
            :href="link"
            :title="title"
            rel="noopener noreferrer nofollow"
            target="_blank"
            class="flex h-5 w-5 rounded-full bg-copy text-white transition dark:bg-white dark:text-heading"
          >
            <PruviousIcon :icon="icon" class="m-auto h-3 w-3" />
          </a>
        </li>
      </ul>
    </footer>
  </Container>
</template>

<script lang="ts" setup>
import { getCollectionData } from '#pruvious/client'

// Fetch data from the Settings collection (cached)
const { copyrightText, socialMedia } = await getCollectionData('settings')
</script>

Dark mode switch

Before we delve into the DarkModeSwitch.vue component, let's first install the @nuxtjs/color-mode module. This module will manage the color state (system, dark, light) on our website.

# Terminal

## pnpm
pnpm add @nuxtjs/color-mode

## npm
npm i @nuxtjs/color-mode

After installing it, we need to add it to the Nuxt modules like this:

# nuxt.config.ts

export default defineNuxtConfig({
  devtools: { enabled: true },
  modules: ['@nuxtjs/color-mode', '@nuxtjs/google-fonts', '@nuxtjs/tailwindcss', 'pruvious'],
  colorMode: {
    classSuffix: '', // Remove the '-mode' suffix
  },
  googleFonts: { ... },
  pruvious: { ... },
})

Let's create the switch now. We'll render it only on the client side. To prevent flickering during the initial loading of the website, we'll reserve the necessary space for the component in the header. Here's the code:

# components/DarkModeSwitch.vue

<template>
  <div class="relative h-5 w-5">
    <ClientOnly>
      <button
        :title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
        @click="
          () => {
            colorMode.preference = isDark ? 'light' : 'dark'
            clickCount++
          }
        "
      >
        <span
          class="absolute bottom-0 left-0 h-9 w-9 transition duration-300"
          :style="{ transform: `rotate(${rotation}deg)` }"
        >
          <IconSun
            class="absolute bottom-0 left-0 text-heading transition duration-300 dark:text-white"
            :class="{ 'h-5 w-5': !isDark, 'h-4 w-4 opacity-25': isDark }"
          />
          <IconMoon
            class="absolute right-0 top-0 text-heading transition duration-300 dark:text-white"
            :class="{ 'h-5 w-5 -rotate-180': isDark, 'h-4 w-4 opacity-25': !isDark }"
          />
        </span>
      </button>
    </ClientOnly>
  </div>
</template>

<script lang="ts" setup>
const colorMode = useColorMode() // Auto-import
const isDark = computed(
  () =>
    colorMode.preference === 'dark' ||
    (colorMode.preference === 'system' && window?.matchMedia?.('(prefers-color-scheme: dark)').matches),
)
const clickCount = ref(0)
const initialRotation = isDark.value ? 180 : 0
const rotation = computed(() => initialRotation + clickCount.value * 180)
</script>

To enable the dark mode styles, we need to instruct Tailwind to use the class strategy:

# tailwind.config.js

import defaultTheme from 'tailwindcss/defaultTheme'
import plugin from 'tailwindcss/plugin'

/** @type {import('tailwindcss').Config} */
export default {
  content: ['blocks/**/*.vue'],
  darkMode: 'class',
  theme: { ... },
  plugins: [ ... ],
}

We will also include dark mode styles for the text and headings:

# assets/css/tailwind.css

@tailwind base;
@tailwind components;
@tailwind utilities;

body {
  @apply text-copy transition duration-300 dark:bg-heading dark:text-white/80;
}

h1,
h2,
h3 {
  @apply font-heading font-medium text-heading transition duration-300 dark:text-white;
}

h1 {
  @apply text-[2rem] ph:text-[1.5rem];
}

h2 {
  @apply text-[1.5rem] ph:text-[1.25rem];
}

h3 {
  @apply text-[1.25rem] ph:text-[1rem];
}

Finishing touches

I also added dark mode styles to our Portfolio block from the previous lesson and removed the hover effects on the slider buttons. This is what our homepage currently looks like:

Playground

You can access the current project files at the following link:

Explore the GitHub repository.

Last updated on January 17, 2024 at 09:10