·읽는데 11분
포트폴리오 페이지 만들기
이번 글에서는 갤러리 형식으로 포트폴리오를 보여주고, 클릭시에는 간단한 설명을 모달창으로 보여주는 페이지를 만들어보겠습니다.
포트폴리오 가져오기
이전에 작성했던 useRepository()
함수에 코드를 추가해 모든 포트폴리오를 가져오도록 만들어 보겠습니다. 해당 코드는 모든 포스트를 가져오는 코드와 유사하지만, 디렉터리 경로를 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,
)
}
로케일 메시지 작성하기
포트폴리오 페이지의 제목, 설명 그리고 포트폴리오에 대한 메타데이터를 로케일 파일에 입력합니다.
pages:
index:
title: About.
subtitle: Besfir의 블로그 튜토리얼 입니다.
description: 기존 블로그 튜토리얼들은 즉시 사용하기에는 디자인적으로 다소 부족한 점이 있었습니다. 이에 미니멀하면서도 바로 활용 가능한 수준의 블로그 튜토리얼을 구성해 보았습니다.
blog:
title: 글 목록
subtitle: 기록은 기억을 지배한다.
description: 기억은 시간이 지나면 흐려지거나 때로는 왜곡될 수 있습니다. 기록은 이러한 기억을 보완해 주며, 나의 성장 과정을 돌아보고 경험을 체계적으로 쌓는 데 도움을 줍니다.
read: 읽어보기
slug:
updatedAt: "{time}에 수정됨"
portfolio:
title: 포트폴리오
description: 제 손을 거쳐간 프로젝트를 소개합니다.
tech-stacks: 기술 스택
roles: 담당 직무
created-at: 완성 일자
visit: 방문하기
error:
404: 페이지를 찾을 수 없습니다.
reading-time: "읽는데 {time}분"
Portfolios.vue
페이지 컴포넌트
포트폴리오 페이지는 크게 제목 영역, 갤러리 영역, 모달 영역으로 구성되도록 설계했습니다. 이 중 갤러리 영역과 모달 영역은 별도의 컴포넌트로 분리했는데, 해당 영역의 역할을 명확히 하고 필요한 로직을 한곳에 모아 유지보수를 편하게 하기 위해서입니다.
<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>
갤러리 컴포넌트
포트폴리오 갤러리는 기입한 Tech Stack을 기준으로 각 포트폴리오 카드를 필터링 할 수 있도록 설계했습니다. 필터링 시에는 Isotope Layout으로 애니메이션 효과를 적용해 동작 여부를 직관적 파악할 수 있도록 했습니다.
<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>
카드 컴포넌트
이번에는 포트폴리오 갤러리에 표시할 카드 컴포넌트를 만들어 보겠습니다.
<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>
모달 컴포넌트
이 프로젝트에서는 현재 동적인 모달창이 필요하지 않지만, 향후 확장성을 고려하여 레이아웃과 콘텐츠 부분을 분리해 동적인 모달창을 구현해 보겠습니다.
<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>
여기까지 구조
소스 코드의 파일 경로는 이 섹션을 참고해주세요
.
├── 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
│ │ └── 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