게시물 상세 이동 지연 개선 및 전체 목록 조회 제거 (v1.10.37)
- 본문만 블로킹 조회, 태그·광고는 lazy로 전환 - 이전·다음 글을 상세 API에서 window 함수로 계산해 응답에 포함 - 전체 게시물(본문 포함) 조회 제거, 광고 site-settings 캐시 재사용 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -582,7 +582,7 @@ components/content/
|
|||||||
### 공개 API (`/api/`)
|
### 공개 API (`/api/`)
|
||||||
|
|
||||||
- `GET /api/posts` - 게시물 목록. `limit`, `featured=1`, `tag=:slug` 쿼리로 서버 목록 범위를 제한할 수 있다. `paginated=1&page=N&limit=N`이면 `{ items, pagination }` 형태로 응답하고, 없으면 기존처럼 배열을 반환한다.
|
- `GET /api/posts` - 게시물 목록. `limit`, `featured=1`, `tag=:slug` 쿼리로 서버 목록 범위를 제한할 수 있다. `paginated=1&page=N&limit=N`이면 `{ items, pagination }` 형태로 응답하고, 없으면 기존처럼 배열을 반환한다.
|
||||||
- `GET /api/posts/:slug` - 게시물 상세
|
- `GET /api/posts/:slug` - 게시물 상세. 응답에 목록과 같은 발행일 정렬 기준의 인접 글을 `previous`(더 최신)·`next`(더 예전) 객체(`{ title, slug }` 또는 `null`)로 포함해, 게시물 상세 하단 이전·다음 글 이동에 전체 목록 조회 없이 바로 사용한다.
|
||||||
- `GET /api/posts/:slug/comments` - 게시물 댓글 목록
|
- `GET /api/posts/:slug/comments` - 게시물 댓글 목록
|
||||||
- `POST /api/posts/:slug/comments` - 게시물 댓글 작성(회원 세션 필요, 대댓글 1단)
|
- `POST /api/posts/:slug/comments` - 게시물 댓글 작성(회원 세션 필요, 대댓글 1단)
|
||||||
- `POST /api/posts/:slug/comments/:commentId/like` - 댓글 좋아요 토글(회원 세션 필요)
|
- `POST /api/posts/:slug/comments/:commentId/like` - 댓글 좋아요 토글(회원 세션 필요)
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.10.37
|
||||||
|
|
||||||
|
- 게시물 상세 이동 지연 개선: 본문만 블로킹 조회, 태그·광고는 lazy로 전환해 이동 후 채움.
|
||||||
|
- 이전·다음 글 계산을 위해 받던 전체 게시물(본문 포함) 조회 제거.
|
||||||
|
- 상세 API가 window 함수(LAG/LEAD)로 인접 글 `previous`·`next`를 계산해 응답에 포함(`getAdjacentPosts` 추가).
|
||||||
|
- 광고용 `site-settings`를 `app.vue`와 같은 키로 재사용해 중복 요청 제거.
|
||||||
|
|
||||||
## v1.10.36
|
## v1.10.36
|
||||||
|
|
||||||
- 게시물 카드 보조 피드백 추가: 제목 강조와 함께 썸네일 은은한 확대(데스크톱 hover 기기·모바일 `:active` 눌림).
|
- 게시물 카드 보조 피드백 추가: 제목 강조와 함께 썸네일 은은한 확대(데스크톱 hover 기기·모바일 `:active` 눌림).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.10.36",
|
"version": "1.10.37",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -8,14 +8,15 @@ definePageMeta({
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const slug = computed(() => String(route.params.slug || ''))
|
const slug = computed(() => String(route.params.slug || ''))
|
||||||
|
|
||||||
|
// 본문은 화면 구성의 필수 데이터이므로 블로킹으로 받아 이동 시점에 완성된 글을 보여준다.
|
||||||
|
// 이전·다음 글은 상세 응답에 포함되어 별도 목록 요청 없이 즉시 표시된다.
|
||||||
const { data: post } = await useFetch(() => `/api/posts/${slug.value}`)
|
const { data: post } = await useFetch(() => `/api/posts/${slug.value}`)
|
||||||
const { data: tags } = await useFetch('/api/tags?tagType=all', {
|
// 태그 표시명/색, 광고는 이동을 막지 않도록 lazy로 받아 이동 후 채운다.
|
||||||
|
const { data: tags } = useLazyFetch('/api/tags?tagType=all', {
|
||||||
default: () => []
|
default: () => []
|
||||||
})
|
})
|
||||||
const { data: posts } = await useFetch('/api/posts', {
|
const { data: siteSettings } = useLazyFetch('/api/site-settings', {
|
||||||
default: () => []
|
key: 'site-settings-public',
|
||||||
})
|
|
||||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
|
||||||
default: () => ({})
|
default: () => ({})
|
||||||
})
|
})
|
||||||
const postToc = useState('post-detail-toc', () => [])
|
const postToc = useState('post-detail-toc', () => [])
|
||||||
@@ -59,21 +60,12 @@ const canEditPost = computed(() => Boolean(
|
|||||||
))
|
))
|
||||||
const postEditPath = computed(() => `/admin/posts/${post.value.id}`)
|
const postEditPath = computed(() => `/admin/posts/${post.value.id}`)
|
||||||
|
|
||||||
const currentIndex = computed(() => posts.value.findIndex((item) => item.slug === post.value.slug))
|
|
||||||
const previousPost = computed(() => {
|
const previousPost = computed(() => {
|
||||||
if (currentIndex.value <= 0) {
|
const candidate = post.value?.previous
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidate = posts.value[currentIndex.value - 1]
|
|
||||||
return candidate ? { title: candidate.title, to: `/post/${candidate.slug}` } : null
|
return candidate ? { title: candidate.title, to: `/post/${candidate.slug}` } : null
|
||||||
})
|
})
|
||||||
const nextPost = computed(() => {
|
const nextPost = computed(() => {
|
||||||
if (currentIndex.value === -1 || currentIndex.value >= posts.value.length - 1) {
|
const candidate = post.value?.next
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidate = posts.value[currentIndex.value + 1]
|
|
||||||
return candidate ? { title: candidate.title, to: `/post/${candidate.slug}` } : null
|
return candidate ? { title: candidate.title, to: `/post/${candidate.slug}` } : null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { getPostBySlug } from '../../repositories/content-repository'
|
import { getAdjacentPosts, getPostBySlug } from '../../repositories/content-repository'
|
||||||
import { canAccessMembershipContent } from '../../repositories/member-repository'
|
import { canAccessMembershipContent } from '../../repositories/member-repository'
|
||||||
import { getMemberSession } from '../../utils/member-auth'
|
import { getMemberSession } from '../../utils/member-auth'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공개 게시물 상세 API
|
* 공개 게시물 상세 API
|
||||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
* @returns {Object} 게시물 상세
|
* @returns {Object} 게시물 상세(이전·다음 글 포함)
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const slug = getRouterParam(event, 'slug')
|
const slug = getRouterParam(event, 'slug')
|
||||||
const session = getMemberSession(event)
|
const session = getMemberSession(event)
|
||||||
const includeMembership = await canAccessMembershipContent(session?.userId)
|
const includeMembership = await canAccessMembershipContent(session?.userId)
|
||||||
const post = await getPostBySlug(slug, {
|
const [post, adjacent] = await Promise.all([
|
||||||
includeMembership
|
getPostBySlug(slug, { includeMembership }),
|
||||||
})
|
getAdjacentPosts(slug, { includeMembership })
|
||||||
|
])
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -22,5 +23,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return post
|
return {
|
||||||
|
...post,
|
||||||
|
previous: adjacent.previous,
|
||||||
|
next: adjacent.next
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1085,6 +1085,73 @@ export const getPostBySlug = async (slug, { includeMembership = false } = {}) =>
|
|||||||
return rows[0] ? mapPostRow(rows[0]) : null
|
return rows[0] ? mapPostRow(rows[0]) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 게시물 상세의 이전(더 최신)·다음(더 예전) 글을 조회한다.
|
||||||
|
* 목록과 같은 정렬(발행일 DESC) 기준으로 인접 글의 제목·슬러그만 반환한다.
|
||||||
|
* @param {string} slug - 기준 게시물 슬러그
|
||||||
|
* @param {{ includeMembership?: boolean }} [options] - 조회 옵션
|
||||||
|
* @returns {Promise<{ previous: { title: string, slug: string } | null, next: { title: string, slug: string } | null }>} 인접 글
|
||||||
|
*/
|
||||||
|
export const getAdjacentPosts = async (slug, { includeMembership = false } = {}) => {
|
||||||
|
const sql = getPostgresClient()
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
const samplePosts = getSamplePosts()
|
||||||
|
.filter((post) => post.status === 'published' && (!post.publishedAt || new Date(post.publishedAt) <= new Date()))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftTime = new Date(left.publishedAt || left.createdAt || 0).getTime()
|
||||||
|
const rightTime = new Date(right.publishedAt || right.createdAt || 0).getTime()
|
||||||
|
return rightTime - leftTime
|
||||||
|
})
|
||||||
|
const index = samplePosts.findIndex((post) => post.slug === slug)
|
||||||
|
if (index === -1) {
|
||||||
|
return { previous: null, next: null }
|
||||||
|
}
|
||||||
|
const toNeighbor = (post) => (post ? { title: post.title, slug: post.slug } : null)
|
||||||
|
return {
|
||||||
|
previous: index > 0 ? toNeighbor(samplePosts[index - 1]) : null,
|
||||||
|
next: index < samplePosts.length - 1 ? toNeighbor(samplePosts[index + 1]) : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await sql`
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT
|
||||||
|
posts.slug,
|
||||||
|
posts.title,
|
||||||
|
LAG(posts.slug) OVER w AS previous_slug,
|
||||||
|
LAG(posts.title) OVER w AS previous_title,
|
||||||
|
LEAD(posts.slug) OVER w AS next_slug,
|
||||||
|
LEAD(posts.title) OVER w AS next_title
|
||||||
|
FROM posts
|
||||||
|
WHERE (
|
||||||
|
posts.status = 'published'
|
||||||
|
OR (${includeMembership} = true AND posts.status = 'members')
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
posts.status = 'members'
|
||||||
|
OR posts.published_at IS NULL
|
||||||
|
OR posts.published_at <= now()
|
||||||
|
)
|
||||||
|
WINDOW w AS (ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC)
|
||||||
|
)
|
||||||
|
SELECT previous_slug, previous_title, next_slug, next_title
|
||||||
|
FROM ordered
|
||||||
|
WHERE slug = ${slug}
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
if (!row) {
|
||||||
|
return { previous: null, next: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
previous: row.previous_slug ? { title: row.previous_title, slug: row.previous_slug } : null,
|
||||||
|
next: row.next_slug ? { title: row.next_title, slug: row.next_slug } : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공개 고정 페이지 목록 조회
|
* 공개 고정 페이지 목록 조회
|
||||||
* @param {{ listedOnly?: boolean, limitPerSection?: number|null }} [options] - 조회 옵션
|
* @param {{ listedOnly?: boolean, limitPerSection?: number|null }} [options] - 조회 옵션
|
||||||
|
|||||||
Reference in New Issue
Block a user