·9 min read

Building the Portfolio Page

In this post, we will create a portfolio page that displays projects in a gallery format. When clicked, a modal window will appear with a brief description.

Fetching the Portfolio

We will modify the previously written useRepository() function to retrieve all portfolio items. This function is similar to the one that fetches all blog posts, but the key difference is that it specifies the directory path as portfolios.

import type { BlogPost } from '~/types/BlogPost'
import type { Portfolio } from '~/types/Portfolio'

const _fetchContent = async <T>(
  queryFn: () => Promise<T>,
) => {
  const path = useRoute().path
  const { data } = await useAsyncData(path, queryFn)

  return data as Ref<T>
}

export const getPosts = () => {
  const { locale } = useI18n()
  return _fetchContent(
    queryContent<BlogPost>('blog').locale(locale.value).find,
  )
}

export const getPostBySlug = async () => {
  const { locale, t } = useI18n()
  const data = await _fetchContent(
    queryContent<BlogPost>('blog', ...(useRoute().params.slug as string))
      .locale(locale.value)
      .findOne,
  )
  if (!data.value) {
    throw createError({ statusCode: 404, message: t('pages.error.404') })
  }
  return data
}

export const getPortfolios = () => {
  const { locale } = useI18n()
  return _fetchContent(
    queryContent<Portfolio>('portfolios').locale(locale.value).find,
  )
}

Writing Locale Messages

The title, description, and other metadata of the portfolio page will be entered in each locale files.

pages:
  index:
    title: About.
    subtitle: This is Besfir's blog tutorial.
    description: Existing blog tutorials often fell short in terms of aesthetics for immediate use. To address this, I’ve created a tutorial that is minimalistic yet fully ready for use as a blog.
  blog:
    title: Posts
    subtitle: Records is the rule of memory.
    description: Memory can fade or sometimes become distorted over time. Keeping records helps complement those memories, allowing you to reflect on your growth and systematically accumulate your experiences.
    read: Read
  slug:
    updatedAt: "Updated at {time}"
  portfolio:
    title: Portfolio
    description: Here are the projects I've worked on.
    tech-stacks: No Tech Stacks | Tech Stack | Tech Stacks
    roles: No Roles | Role | Roles
    created-at: Created at
    visit: Visit
  error:
    404: Page not founded.
reading-time: "{time} min read"

Portfolios.vue Page Component

The portfolio page is designed with three main sections: the title area, the gallery area, and the modal area. The gallery and modal areas are separated into individual components to clearly define their roles and centralize the necessary logic for easier maintenance.

<script setup lang="ts">
  import type { Portfolio } from '~/types/Portfolio'

  const { t } = useI18n()
  useSeoMeta({
    title: t('pages.portfolio.title'),
    description: t('pages.portfolio.description'),
  })

  const portfolios = await getPortfolios()

  const showModal = ref(false)
  const selectedPortfolio = ref<Portfolio>(portfolios.value[0])
</script>

<template>
  <the-page inner-class="max-w-6xl pb-10 md:py-14 space-y-10 md:space-y-14">
    <h1
      class="text-3xl font-extrabold leading-9 tracking-tight text-slate-900 dark:text-slate-100 sm:text-4xl sm:leading-10 md:px-6 md:text-6xl md:leading-14 text-center"
    >
      <span class="relative inline-block px-6">
        <span
          class="absolute left-0 -translate-x-full top-1/2 -translate-y-1/2 h-1 md:h-1.5 bg-primary-500 dark:bg-primary-400 w-14 sm:w-16 md:w-20"
        />
        Portfolio
        <span
          class="absolute right-0 translate-x-full top-1/2 -translate-y-1/2 h-1 md:h-1.5 bg-primary-500 dark:bg-primary-400 w-14 sm:w-16 md:w-20"
        />
      </span>
    </h1>

    <portfolio-gallery
      v-model:modal="showModal"
      v-model:portfolio="selectedPortfolio"
      :portfolios="portfolios"
    />

    <portfolio-modal
      v-if="showModal"
      v-model="showModal"
      :portfolio="selectedPortfolio"
      @close="showModal = false"
    />
  </the-page>
</template>

The portfolio gallery is designed to allow filtering of portfolio cards based on the selected tech stack. To enhance usability, Isotope Layout is applied to animate the filtering process, making the changes visually intuitive.

<script setup lang="ts">
import type Isotope from 'isotope-layout'
import type { Portfolio } from '~/types/Portfolio'

const props = defineProps<{ portfolios: Portfolio[] }>()
const portfolios = props.portfolios

const selectedSkill = ref('*')
const allSkill = [{ label: 'All', value: '*' }, ...useUnion(portfolios.flatMap(({ techStacks }) => techStacks)).map(v => ({ label: v, value: useSnakeCase(v) }))]

function onSkillSelected(skill: string) {
  selectedSkill.value = skill
  isotope.value!.arrange({ filter: skill === '*' ? '*' : `.${skill}` })
}

const isotope = ref<Isotope>()

async function useIsotope() {
  const Isotope = (await import('isotope-layout')).default
  return new Isotope('.isotope-grid', {
    itemSelector: '.isotope-item',
    layoutMode: 'masonry',
    hiddenStyle: {
      transform: 'scale(.2) skew(30deg)',
      opacity: 0,
    },
    visibleStyle: {
      transform: 'scale(1) skew(0deg)',
      opacity: 1,
    },
    transitionDuration: '.5s',
  })
}

useNuxtApp().hooks.hookOnce('page:finish', async () => {
  isotope.value = await useIsotope()
})

/* Modal */
const showModal = defineModel('modal', { required: true })
const selectedPortfolio = defineModel('portfolio', { required: true })

function onClick(id: string) {
  selectedPortfolio.value = portfolios.find(({ _id }) => _id === id)!
  showModal.value = true
}
</script>

<template>
  <div class="text-center pb-3 max-w-screen-md mx-auto">
    <app-button
      v-for="{ label, value } in allSkill"
      :key="label"
      :color="selectedSkill === value ? 'primary' : 'gray'"
      :variant="selectedSkill === value ? 'solid': 'ghost'"
      class="min-w-14 justify-center"
      :class="selectedSkill === value && 'font-bold'"
      @click="onSkillSelected(value)"
    >
      {{ label }}
    </app-button>
  </div>

  <div class="mt-24 isotope-grid">
    <portfolio-card
      v-for="item in portfolios"
      v-bind="item"
      :key="item.title"
      class="isotope-item w-full md:w-[calc(50%-16px)] lg:w-[calc(33.33%-16px)]"
      @click="onClick(item._id)"
    />
  </div>
</template>

Card Component

Now, we will create a card component to be displayed in the portfolio gallery.

<script setup lang="ts">
import type { Portfolio } from '~/types/Portfolio'

defineProps<Portfolio>()
</script>

<template>
  <div
    class="mr-4 mb-4 w-full break-inside-avoid relative group overflow-hidden cursor-pointer shadow"
    :class="techStacks.map(stack => useSnakeCase(stack)).join(' ')"
  >
    <div class="opacity-0 group-hover:opacity-80 absolute inset-0 bg-gradient-to-br from-cyan-500 to-purple-500 transition-opacity z-10" />
    <figure class="relative">
      <nuxt-picture
        :src="image.url"
        :width="image.width"
        :height="image.height"
        :alt="title"
        format="avif,webp"
        :img-attrs="{ class: 'bg-slate-100 object-contain w-full group-hover:scale-110 transition-transform', loading: 'lazy' }"
        sizes="sm:100vw 2xl:1536px"
        density="1x 2x"
      />
      <figcaption
        class="flex justify-center items-center flex-col gap-4 absolute inset-0 opacity-0 group-hover:opacity-100 z-10 text-white"
      >
        <h2 class="text-xl font-bold">
          <span class="border-b-2">{{ title }}</span>
        </h2>

        <div class="flex flex-wrap justify-center gap-2 px-6">
          <span
            v-for="stack in techStacks"
            :key="stack"
            class="text-sm"
          >
            <icon
              name="ph:tag"
              size="14"
              class="align-text-top"
            /> {{ stack }}
          </span>
        </div>
      </figcaption>
    </figure>
  </div>
</template>

Although this project does not currently require a dynamic modal, we will implement one with a separate layout and content structure to ensure future scalability.

<script setup lang="ts">
  import type { HTMLAttributes } from 'vue'
  import { twMerge } from 'tailwind-merge'

  interface Props {
    class?: HTMLAttributes['class']
  }
  const props = defineProps<Props>()

  const show = defineModel({ required: true })

  useHead({
    htmlAttrs: {
      class: () => show.value ? 'overflow-hidden' : null,
    },
  })

  defineEmits(['close'])

  function close() {
    show.value = false
  }

  onKeyStroke('Escape', close)

  defineOptions({
    inheritAttrs: false,
  })
</script>

<template>
  <teleport to="body">
    <div
      :class="twMerge('fixed inset-0 z-[100] bg-black/20 backdrop-blur-sm flex items-start justify-center p-4 sm:p-6 md:p-[10vh] lg:p-[12vh] h-screen w-screen', props.class)"
      v-bind="$attrs"
      @click.self="close"
    >
      <div class="bg-white dark:bg-slate-800 p-6 md:p-10 w-full max-w-4xl rounded-lg relative grid md:grid-cols-2 gap-6 md:gap-12 items-center">
        <div class="absolute top-1 right-1 translate-x-1/2 -translate-y-1/2 p-1 shadow-md rounded-full border dark:border-slate-600 bg-white dark:bg-slate-950">
          <app-button
            square
            color="primary"
            variant="solid"
            aria-label="Close search panel"
            class="rounded-full"
            @click="close"
          >
            <icon name="ph:x" />
          </app-button>
        </div>
        <slot />
      </div>
    </div>
  </teleport>
</template>

Project Structure So Far

Refer to this section for the file paths of the source code.

.
├── README.md
├── app.vue
├── assets
│   ├── css
│   │   └── main.pcss
│   └── icons
│       └── logo.svg
├── bun.lockb
├── components
│   ├── App
│   │   ├── Button.vue
│   │   ├── Link.vue
│   │   ├── Modal.vue
│   │   └── SnsLinks.vue
│   ├── Blog
│   │   ├── Hero.vue
│   │   ├── PostCard.vue
│   │   └── PostList.vue
│   ├── Index
│   │   ├── Hero.vue
│   │   └── Snaps.vue
│   ├── Portfolio
│   │   ├── Card.vue
│   │   ├── Gallery.vue
│   │   └── Modal.vue
│   ├── The
│   │   ├── Footer.vue
│   │   ├── Header.vue
│   │   ├── LangSwitcher.vue
│   │   ├── Page.vue
│   │   └── ThemeSwitcher.vue
│   └── content
│       ├── Gallery.vue
│       ├── ProseH2.vue
│       ├── ProseH3.vue
│       ├── ProseH4.vue
│       └── ProseImg.vue
├── composables
│   └── useRepository.ts
├── constant
│   └── ButtonStyle.ts
├── content
│   ├── en
│   │   └── blog
│   │       └── 1.style guide.md
│   └── ko
│       ├── blog
│       │   └── 1.style guide.md
│       └── portfolios
│           └── 1.project1.md
├── eslint.config.mjs
├── i18n
│   ├── i18n.config.ts
│   └── locales
│       ├── en.yaml
│       └── ko.yaml
├── nuxt.config.ts
├── package.json
├── pages
│   ├── blog
│   │   ├── [...slug].vue
│   │   └── index.vue
│   ├── index.vue
│   └── portfolios.vue
├── public
│   ├── _robots.txt
│   ├── favicon.ico
│   └── images
│       ├── index
│       │   ├── hero.jpeg
│       │   ├── snap-1.jpg
│       │   ├── snap-2.jpg
│       │   ├── snap-3.jpg
│       │   ├── snap-4.jpg
│       │   └── snap-5.jpg
│       └── portfolios
│           └── img.png
├── server
│   └── tsconfig.json
├── tailwind.config.ts
├── tsconfig.json
└── types
    ├── BlogPost.ts
    ├── Button.ts
    └── Portfolio.ts