공개 상세 경로와 새 글 에디터 보정
This commit is contained in:
@@ -881,7 +881,7 @@ watch(() => props.modelValue, (value) => {
|
|||||||
|
|
||||||
const currentValue = serializeBlocks()
|
const currentValue = serializeBlocks()
|
||||||
|
|
||||||
if (value === currentValue) {
|
if (value === currentValue && editorBlocks.value.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,16 +13,16 @@ const { data: tags } = await useFetch('/api/tags', {
|
|||||||
<span>Home pages</span>
|
<span>Home pages</span>
|
||||||
<span>⌄</span>
|
<span>⌄</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink class="left-sidebar__nav-link py-2 pl-3" to="/tags/note">
|
<NuxtLink class="left-sidebar__nav-link py-2 pl-3" to="/tags">
|
||||||
Tags
|
Tags
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink class="left-sidebar__nav-link py-2 pl-3" to="/pages/about">
|
<NuxtLink class="left-sidebar__nav-link py-2 pl-3" to="/pages/about">
|
||||||
Authors
|
Authors
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink class="left-sidebar__nav-link py-2 pl-3" to="/posts/hello-sori-studio">
|
<NuxtLink class="left-sidebar__nav-link py-2 pl-3" to="/post/hello-sori-studio">
|
||||||
Style
|
Style
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink class="left-sidebar__nav-link flex items-center justify-between py-2 pl-3" to="/posts/custom-writing-tool">
|
<NuxtLink class="left-sidebar__nav-link flex items-center justify-between py-2 pl-3" to="/post/custom-writing-tool">
|
||||||
<span>Post types</span>
|
<span>Post types</span>
|
||||||
<span>⌄</span>
|
<span>⌄</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
@@ -46,7 +46,7 @@ const { data: tags } = await useFetch('/api/tags', {
|
|||||||
v-for="tag in tags"
|
v-for="tag in tags"
|
||||||
:key="tag.id"
|
:key="tag.id"
|
||||||
class="left-sidebar__category flex items-center gap-3"
|
class="left-sidebar__category flex items-center gap-3"
|
||||||
:to="`/tags/${tag.slug}`"
|
:to="`/tag/${tag.slug}`"
|
||||||
>
|
>
|
||||||
<span class="left-sidebar__category-color h-4 w-1 rounded-full" :style="{ backgroundColor: tag.color }" />
|
<span class="left-sidebar__category-color h-4 w-1 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||||
<span class="left-sidebar__category-name">{{ tag.name }}</span>
|
<span class="left-sidebar__category-name">{{ tag.name }}</span>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
<span>↗</span>
|
<span>↗</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="right-sidebar__links mt-4 grid gap-3 text-sm">
|
<div class="right-sidebar__links mt-4 grid gap-3 text-sm">
|
||||||
<NuxtLink class="right-sidebar__link font-semibold" to="/posts/hello-sori-studio">
|
<NuxtLink class="right-sidebar__link font-semibold" to="/post/hello-sori-studio">
|
||||||
sori.studio 첫 글과 방향
|
sori.studio 첫 글과 방향
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink class="right-sidebar__link font-semibold" to="/pages/projects">
|
<NuxtLink class="right-sidebar__link font-semibold" to="/pages/projects">
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-02 v0.0.18
|
||||||
|
|
||||||
|
### 공개 URL 복수형/단수형 기준 결정
|
||||||
|
|
||||||
|
게시물과 태그의 전체 목록은 컬렉션이므로 `/posts`, `/tags` 복수형을 사용한다. 개별 게시물과 특정 태그 상세는 하나의 리소스를 가리키므로 `/post/:slug`, `/tag/:slug` 단수형을 기준 경로로 정한다.
|
||||||
|
|
||||||
|
기존에 사용하던 `/posts/:slug`, `/tags/:slug`는 외부 링크나 기존 이동 흐름이 깨지지 않도록 새 단수형 경로로 리다이렉트한다. 관리자 API와 관리자 화면 경로는 내부 관리 리소스 컬렉션이므로 기존 `/admin/posts/:id`, `/admin/tags/:id`를 유지한다.
|
||||||
|
|
||||||
## 2026-05-02 v0.0.17
|
## 2026-05-02 v0.0.17
|
||||||
|
|
||||||
### 대표 이미지와 미디어 화면 밀도 개선 결정
|
### 대표 이미지와 미디어 화면 밀도 개선 결정
|
||||||
|
|||||||
@@ -71,8 +71,12 @@
|
|||||||
| 파일 | 화면 |
|
| 파일 | 화면 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| pages/index.vue | 홈 |
|
| pages/index.vue | 홈 |
|
||||||
| pages/posts/[slug].vue | 블로그 글 상세 |
|
| pages/posts/index.vue | 게시물 전체 목록 |
|
||||||
| pages/tags/[slug].vue | 태그별 글 목록 |
|
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
|
||||||
|
| pages/post/[slug].vue | 블로그 글 상세 |
|
||||||
|
| pages/tags/index.vue | 태그 전체 목록 |
|
||||||
|
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
||||||
|
| pages/tag/[slug].vue | 태그별 글 목록 |
|
||||||
| pages/pages/[slug].vue | 고정 페이지 상세 |
|
| pages/pages/[slug].vue | 고정 페이지 상세 |
|
||||||
|
|
||||||
## 서버 API
|
## 서버 API
|
||||||
|
|||||||
@@ -48,6 +48,14 @@
|
|||||||
- 헤더와 사이드바를 사용하지 않고 본문 중심 전체 화면으로 표시
|
- 헤더와 사이드바를 사용하지 않고 본문 중심 전체 화면으로 표시
|
||||||
- 진입 경로는 추후 메뉴/링크 설정을 통해 연결
|
- 진입 경로는 추후 메뉴/링크 설정을 통해 연결
|
||||||
|
|
||||||
|
### 공개 URL 구조
|
||||||
|
|
||||||
|
- `/posts` - 게시물 전체 목록
|
||||||
|
- `/post/:slug` - 개별 게시물 상세
|
||||||
|
- `/tags` - 태그 전체 목록
|
||||||
|
- `/tag/:slug` - 태그별 게시물 목록
|
||||||
|
- 기존 `/posts/:slug`, `/tags/:slug` 상세 경로는 새 단수형 상세 경로로 리다이렉트한다.
|
||||||
|
|
||||||
### 레이아웃 파일
|
### 레이아웃 파일
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -217,6 +225,7 @@ components/content/
|
|||||||
- 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
|
- 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
|
||||||
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
||||||
- 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다.
|
- 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다.
|
||||||
|
- 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다.
|
||||||
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
|
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
|
||||||
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
||||||
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 새 이미지 업로드로 설정한다.
|
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 새 이미지 업로드로 설정한다.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
## 1차 관리자 개발
|
## 1차 관리자 개발
|
||||||
|
|
||||||
- [ ] 블록 에디터 브라우저 수동 QA: 빈 줄 Enter, `/` 메뉴 필터, 방향키, Enter 선택, 한글 조합 입력 확인
|
- [ ] 블록 에디터 브라우저 수동 QA: 빈 줄 Enter, `/` 메뉴 필터, 방향키, Enter 선택, 한글 조합 입력 확인
|
||||||
|
- [ ] 공개 URL 수동 QA: `/posts`, `/post/:slug`, `/tags`, `/tag/:slug`, 기존 복수형 상세 리다이렉트 확인
|
||||||
- [ ] 블록 에디터 저장/수정 왕복 QA: 기존 글 수정 시 블록 파싱, 저장 후 다시 열기 확인
|
- [ ] 블록 에디터 저장/수정 왕복 QA: 기존 글 수정 시 블록 파싱, 저장 후 다시 열기 확인
|
||||||
- [ ] 이미지/갤러리 블록 브라우저 수동 QA: 업로드, 너비 옵션, 저장 후 공개 렌더링, 갤러리 라이트박스 확인
|
- [ ] 이미지/갤러리 블록 브라우저 수동 QA: 업로드, 너비 옵션, 저장 후 공개 렌더링, 갤러리 라이트박스 확인
|
||||||
- [ ] 미디어 선택 창 브라우저 수동 QA: 기존 이미지 선택, 갤러리 추가, 빈 미디어 상태 확인
|
- [ ] 미디어 선택 창 브라우저 수동 QA: 기존 이미지 선택, 갤러리 추가, 빈 미디어 상태 확인
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v0.0.18
|
||||||
|
|
||||||
|
- 새 글 작성 화면에서 빈 본문 블록이 생성되지 않던 문제 수정.
|
||||||
|
- 공개 게시물 목록 경로 `/posts` 추가.
|
||||||
|
- 공개 게시물 상세 경로를 `/post/:slug` 기준으로 추가.
|
||||||
|
- 기존 `/posts/:slug` 상세 경로를 `/post/:slug`로 리다이렉트하도록 수정.
|
||||||
|
- 공개 태그 목록 경로 `/tags` 추가.
|
||||||
|
- 공개 태그 상세 경로를 `/tag/:slug` 기준으로 추가.
|
||||||
|
- 기존 `/tags/:slug` 상세 경로를 `/tag/:slug`로 리다이렉트하도록 수정.
|
||||||
|
- 공개 화면과 관리자 미리보기 링크를 단수형 상세 경로 기준으로 정리.
|
||||||
|
- 패키지 버전을 0.0.18로 갱신.
|
||||||
|
|
||||||
## v0.0.17
|
## v0.0.17
|
||||||
|
|
||||||
- 관리자 글 작성/수정 폼의 대표 이미지 URL 직접 입력을 이미지 선택 UI로 변경.
|
- 관리자 글 작성/수정 폼의 대표 이미지 URL 직접 입력을 이미지 선택 UI로 변경.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.17",
|
"version": "0.0.18",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.17",
|
"version": "0.0.18",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.17",
|
"version": "0.0.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ const deletePost = async () => {
|
|||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="post.status === 'published'"
|
v-if="post.status === 'published'"
|
||||||
class="admin-post-edit__view rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
|
class="admin-post-edit__view rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
|
||||||
:to="`/posts/${post.slug}`"
|
:to="`/post/${post.slug}`"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
보기
|
보기
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ const deletePost = async (post) => {
|
|||||||
{{ post.title }}
|
{{ post.title }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<p class="admin-posts__slug mt-1 text-xs text-muted">
|
<p class="admin-posts__slug mt-1 text-xs text-muted">
|
||||||
/posts/{{ post.slug }}
|
/post/{{ post.slug }}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="admin-posts__cell px-4 py-4">
|
<td class="admin-posts__cell px-4 py-4">
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const mapPostCard = (post) => ({
|
|||||||
excerpt: post.excerpt,
|
excerpt: post.excerpt,
|
||||||
tag: post.tags?.[0]?.toUpperCase() || 'POST',
|
tag: post.tags?.[0]?.toUpperCase() || 'POST',
|
||||||
publishedAt: formatPostDate(post.publishedAt),
|
publishedAt: formatPostDate(post.publishedAt),
|
||||||
to: `/posts/${post.slug}`
|
to: `/post/${post.slug}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const postCards = computed(() => posts.value.map(mapPostCard))
|
const postCards = computed(() => posts.value.map(mapPostCard))
|
||||||
|
|||||||
34
pages/post/[slug].vue
Normal file
34
pages/post/[slug].vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'post'
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const slug = computed(() => String(route.params.slug || ''))
|
||||||
|
|
||||||
|
const { data: post } = await useFetch(() => `/api/posts/${slug.value}`)
|
||||||
|
|
||||||
|
if (!post.value) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: '게시물을 찾을 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const postTag = computed(() => post.value.tags?.[0]?.toUpperCase() || 'POST')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ContentRenderer>
|
||||||
|
<ProseHeaderCard>
|
||||||
|
<p class="post-detail__eyebrow text-sm uppercase text-white/70">
|
||||||
|
{{ postTag }}
|
||||||
|
</p>
|
||||||
|
<h1 class="post-detail__title mt-3 text-4xl font-semibold leading-tight">
|
||||||
|
{{ post.title }}
|
||||||
|
</h1>
|
||||||
|
</ProseHeaderCard>
|
||||||
|
|
||||||
|
<ContentMarkdownRenderer class="post-detail__content" :content="post.content" />
|
||||||
|
</ContentRenderer>
|
||||||
|
</template>
|
||||||
@@ -1,34 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
definePageMeta({
|
|
||||||
layout: 'post'
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const slug = computed(() => String(route.params.slug || ''))
|
const slug = String(route.params.slug || '')
|
||||||
|
|
||||||
const { data: post } = await useFetch(() => `/api/posts/${slug.value}`)
|
await navigateTo(`/post/${slug}`, {
|
||||||
|
redirectCode: 301
|
||||||
if (!post.value) {
|
})
|
||||||
throw createError({
|
|
||||||
statusCode: 404,
|
|
||||||
statusMessage: '게시물을 찾을 수 없습니다.'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const postTag = computed(() => post.value.tags?.[0]?.toUpperCase() || 'POST')
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ContentRenderer>
|
<div />
|
||||||
<ProseHeaderCard>
|
|
||||||
<p class="post-detail__eyebrow text-sm uppercase text-white/70">
|
|
||||||
{{ postTag }}
|
|
||||||
</p>
|
|
||||||
<h1 class="post-detail__title mt-3 text-4xl font-semibold leading-tight">
|
|
||||||
{{ post.title }}
|
|
||||||
</h1>
|
|
||||||
</ProseHeaderCard>
|
|
||||||
|
|
||||||
<ContentMarkdownRenderer class="post-detail__content" :content="post.content" />
|
|
||||||
</ContentRenderer>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
48
pages/posts/index.vue
Normal file
48
pages/posts/index.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup>
|
||||||
|
const { data: posts } = await useFetch('/api/posts', {
|
||||||
|
default: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 표시 형식 변환
|
||||||
|
* @param {string | null} value - ISO 날짜 문자열
|
||||||
|
* @returns {string} 화면 표시 날짜
|
||||||
|
*/
|
||||||
|
const formatPostDate = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${year}.${month}.${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const postCards = computed(() => posts.value.map((post) => ({
|
||||||
|
title: post.title,
|
||||||
|
excerpt: post.excerpt,
|
||||||
|
tag: post.tags?.[0]?.toUpperCase() || 'POST',
|
||||||
|
publishedAt: formatPostDate(post.publishedAt),
|
||||||
|
to: `/post/${post.slug}`
|
||||||
|
})))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MainColumn>
|
||||||
|
<section class="posts-index site-section">
|
||||||
|
<div class="posts-index__header site-section-header">
|
||||||
|
<p class="posts-index__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
|
Posts
|
||||||
|
</p>
|
||||||
|
<h1 class="posts-index__title mt-3 text-4xl font-semibold leading-tight">
|
||||||
|
게시물
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<PostCard v-for="post in postCards" :key="post.to" :post="post" />
|
||||||
|
</MainColumn>
|
||||||
|
</template>
|
||||||
57
pages/tag/[slug].vue
Normal file
57
pages/tag/[slug].vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup>
|
||||||
|
const route = useRoute()
|
||||||
|
const slug = computed(() => String(route.params.slug || ''))
|
||||||
|
|
||||||
|
const { data: tags } = await useFetch('/api/tags', {
|
||||||
|
default: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: posts } = await useFetch('/api/posts', {
|
||||||
|
default: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 표시 형식 변환
|
||||||
|
* @param {string | null} value - ISO 날짜 문자열
|
||||||
|
* @returns {string} 화면 표시 날짜
|
||||||
|
*/
|
||||||
|
const formatPostDate = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${year}.${month}.${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = computed(() => tags.value.find((item) => item.slug === slug.value))
|
||||||
|
|
||||||
|
const tagPosts = computed(() => posts.value
|
||||||
|
.filter((post) => post.tags.includes(slug.value))
|
||||||
|
.map((post) => ({
|
||||||
|
title: post.title,
|
||||||
|
excerpt: post.excerpt,
|
||||||
|
tag: tag.value?.name || slug.value.toUpperCase(),
|
||||||
|
publishedAt: formatPostDate(post.publishedAt),
|
||||||
|
to: `/post/${post.slug}`
|
||||||
|
})))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MainColumn>
|
||||||
|
<TagHeader
|
||||||
|
:title="tag?.name || slug.toUpperCase()"
|
||||||
|
:description="tag?.description || ''"
|
||||||
|
/>
|
||||||
|
<PostCard v-for="post in tagPosts" :key="post.to" :post="post" />
|
||||||
|
<section v-if="tagPosts.length === 0" class="tag-posts site-section">
|
||||||
|
<div class="tag-posts__empty site-section-body text-sm text-muted">
|
||||||
|
이 태그에 연결된 글이 없습니다.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</MainColumn>
|
||||||
|
</template>
|
||||||
@@ -1,57 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const slug = computed(() => String(route.params.slug || ''))
|
const slug = String(route.params.slug || '')
|
||||||
|
|
||||||
const { data: tags } = await useFetch('/api/tags', {
|
await navigateTo(`/tag/${slug}`, {
|
||||||
default: () => []
|
redirectCode: 301
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: posts } = await useFetch('/api/posts', {
|
|
||||||
default: () => []
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 날짜 표시 형식 변환
|
|
||||||
* @param {string | null} value - ISO 날짜 문자열
|
|
||||||
* @returns {string} 화면 표시 날짜
|
|
||||||
*/
|
|
||||||
const formatPostDate = (value) => {
|
|
||||||
if (!value) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(value)
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
||||||
const day = String(date.getDate()).padStart(2, '0')
|
|
||||||
|
|
||||||
return `${year}.${month}.${day}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const tag = computed(() => tags.value.find((item) => item.slug === slug.value))
|
|
||||||
|
|
||||||
const tagPosts = computed(() => posts.value
|
|
||||||
.filter((post) => post.tags.includes(slug.value))
|
|
||||||
.map((post) => ({
|
|
||||||
title: post.title,
|
|
||||||
excerpt: post.excerpt,
|
|
||||||
tag: tag.value?.name || slug.value.toUpperCase(),
|
|
||||||
publishedAt: formatPostDate(post.publishedAt),
|
|
||||||
to: `/posts/${post.slug}`
|
|
||||||
})))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<MainColumn>
|
<div />
|
||||||
<TagHeader
|
|
||||||
:title="tag?.name || slug.toUpperCase()"
|
|
||||||
:description="tag?.description || ''"
|
|
||||||
/>
|
|
||||||
<PostCard v-for="post in tagPosts" :key="post.to" :post="post" />
|
|
||||||
<section v-if="tagPosts.length === 0" class="tag-posts site-section">
|
|
||||||
<div class="tag-posts__empty site-section-body text-sm text-muted">
|
|
||||||
이 태그에 연결된 글이 없습니다.
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</MainColumn>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
45
pages/tags/index.vue
Normal file
45
pages/tags/index.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup>
|
||||||
|
const { data: tags } = await useFetch('/api/tags', {
|
||||||
|
default: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: posts } = await useFetch('/api/posts', {
|
||||||
|
default: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 태그에 연결된 게시물 수 조회
|
||||||
|
* @param {string} slug - 태그 슬러그
|
||||||
|
* @returns {number} 게시물 수
|
||||||
|
*/
|
||||||
|
const getPostCount = (slug) => posts.value.filter((post) => post.tags.includes(slug)).length
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MainColumn>
|
||||||
|
<section class="tags-index site-section">
|
||||||
|
<div class="tags-index__header site-section-header">
|
||||||
|
<p class="tags-index__eyebrow text-xs font-semibold uppercase text-muted">
|
||||||
|
Tags
|
||||||
|
</p>
|
||||||
|
<h1 class="tags-index__title mt-3 text-4xl font-semibold leading-tight">
|
||||||
|
태그
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="tags-index__body site-section-body grid gap-3">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="tag in tags"
|
||||||
|
:key="tag.id"
|
||||||
|
class="tags-index__item flex items-center justify-between gap-4 rounded border border-line bg-white px-4 py-3 hover:opacity-80"
|
||||||
|
:to="`/tag/${tag.slug}`"
|
||||||
|
>
|
||||||
|
<span class="tags-index__name flex items-center gap-3 font-semibold">
|
||||||
|
<span class="tags-index__color h-4 w-1 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||||
|
{{ tag.name }}
|
||||||
|
</span>
|
||||||
|
<span class="tags-index__count text-sm text-muted">{{ getPostCount(tag.slug) }} posts</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</MainColumn>
|
||||||
|
</template>
|
||||||
@@ -132,7 +132,7 @@ const getMediaUsage = (url, posts, pages) => {
|
|||||||
title: post.title,
|
title: post.title,
|
||||||
slug: post.slug,
|
slug: post.slug,
|
||||||
adminUrl: `/admin/posts/${post.id}`,
|
adminUrl: `/admin/posts/${post.id}`,
|
||||||
publicUrl: `/posts/${post.slug}`,
|
publicUrl: `/post/${post.slug}`,
|
||||||
status: post.status,
|
status: post.status,
|
||||||
...usage
|
...usage
|
||||||
})))
|
})))
|
||||||
|
|||||||
Reference in New Issue
Block a user