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>
Gallery Component
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>
Modal Component
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