게시글 목차와 댓글 등록 상태 정리 v1.5.16

This commit is contained in:
2026-06-01 11:43:36 +09:00
parent edbbd3c83c
commit dc50780ff8
9 changed files with 191 additions and 22 deletions

View File

@@ -19,6 +19,8 @@ const replyBody = ref('')
const activeReplyTargetId = ref('')
const sortOption = ref('best')
const brokenAvatarCommentIds = ref([])
const canSubmitComment = computed(() => Boolean(newCommentBody.value.trim()) && !submitting.value)
const canSubmitReply = computed(() => Boolean(replyBody.value.trim()) && !submittingReplyId.value)
/**
* 댓글 시간을 상대 시간 형식으로 변환한다.
@@ -313,15 +315,15 @@ onMounted(async () => {
</div>
<div class="mt-3 flex items-center gap-2 text-xs site-muted">
<label for="comment-sort">Sort by:</label>
<label for="comment-sort">정렬:</label>
<select
id="comment-sort"
v-model="sortOption"
class="rounded-md border border-[var(--site-line)] bg-transparent px-2 py-1 text-xs font-semibold text-[var(--site-ink)] outline-none"
>
<option value="best">Best</option>
<option value="latest">Latest</option>
<option value="oldest">Oldest</option>
<option value="best">인기순</option>
<option value="latest">최신순</option>
<option value="oldest">오래된순</option>
</select>
</div>
@@ -339,8 +341,8 @@ onMounted(async () => {
<div class="mt-2 flex justify-end">
<button
type="button"
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
:disabled="submitting"
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-60"
:disabled="!canSubmitComment"
@click="submitComment"
>
{{ submitting ? '등록 중...' : '댓글 등록' }}
@@ -448,7 +450,7 @@ onMounted(async () => {
</div>
</div>
<div v-if="activeReplyTargetId === comment.id" class="mt-2 rounded-[10px] border border-[var(--site-line)] p-2">
<div v-if="activeReplyTargetId === comment.id" class="mt-2 rounded-[10px] p-2">
<textarea
v-model="replyBody"
rows="3"
@@ -464,8 +466,8 @@ onMounted(async () => {
</button>
<button
type="button"
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
:disabled="submittingReplyId === comment.id"
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-60"
:disabled="!canSubmitReply"
@click="submitReply(comment.id)"
>
{{ submittingReplyId === comment.id ? '등록 중...' : '답글 등록' }}
@@ -543,4 +545,3 @@ onMounted(async () => {
</div>
</div>
</template>

View File

@@ -3,6 +3,9 @@ import { getExternalFaviconUrl } from '~/lib/external-favicon-url.js'
const route = useRoute()
const postToc = useState('post-detail-toc', () => [])
const tocNavRef = ref(null)
const activeTocId = ref('')
let tocScrollFrame = 0
const followLinks = [
{ id: 'facebook', label: 'Facebook', href: 'https://facebook.com', icon: 'facebook' },
@@ -45,6 +48,113 @@ const recommendedSites = computed(() => {
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
const postTocItems = computed(() => Array.isArray(postToc.value) ? postToc.value : [])
/**
* 고정 상단 영역을 고려한 TOC 판정 기준선을 반환한다.
* @returns {number} 뷰포트 상단 기준 오프셋
*/
const getTocActivationOffset = () => {
if (!import.meta.client) {
return 96
}
const topChromeHeight = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--site-top-chrome-height'))
return (Number.isFinite(topChromeHeight) ? topChromeHeight : 57) + 28
}
/**
* 현재 본문 스크롤 위치에 해당하는 TOC 항목 ID를 계산한다.
* @returns {string} 활성 제목 ID
*/
const findActiveTocId = () => {
if (!import.meta.client || !postTocItems.value.length) {
return ''
}
const offset = getTocActivationOffset()
const currentY = window.scrollY + offset
let activeId = postTocItems.value[0].id
for (const item of postTocItems.value) {
const target = document.getElementById(item.id)
if (!target) {
continue
}
const targetY = target.getBoundingClientRect().top + window.scrollY
if (targetY <= currentY) {
activeId = item.id
} else {
break
}
}
return activeId
}
/**
* 활성 TOC 링크가 내부 스크롤 영역 안에 보이도록 보정한다.
* @param {string} id - 활성 제목 ID
* @returns {void}
*/
const scrollActiveTocIntoView = (id) => {
if (!import.meta.client || !id || !(tocNavRef.value instanceof HTMLElement)) {
return
}
const nav = tocNavRef.value
const link = nav.querySelector(`[data-toc-id="${id}"]`)
if (!(link instanceof HTMLElement)) {
return
}
const navTop = nav.scrollTop
const navBottom = navTop + nav.clientHeight
const linkTop = link.offsetTop
const linkBottom = linkTop + link.offsetHeight
const buffer = 24
if (linkTop < navTop + buffer) {
nav.scrollTo({
top: Math.max(0, linkTop - buffer),
behavior: 'smooth'
})
return
}
if (linkBottom > navBottom - buffer) {
nav.scrollTo({
top: linkBottom - nav.clientHeight + buffer,
behavior: 'smooth'
})
}
}
/**
* 스크롤 이벤트에서 TOC 활성 항목을 갱신한다.
* @returns {void}
*/
const updateActiveToc = () => {
if (!import.meta.client || tocScrollFrame) {
return
}
tocScrollFrame = window.requestAnimationFrame(() => {
tocScrollFrame = 0
const nextActiveId = findActiveTocId()
if (!nextActiveId || nextActiveId === activeTocId.value) {
return
}
activeTocId.value = nextActiveId
scrollActiveTocIntoView(nextActiveId)
})
}
/**
* 새 탭으로 열 외부 URL인지
* @param {string} url - 링크
@@ -89,6 +199,8 @@ const scrollToTocItem = (event, id) => {
}
event.preventDefault()
activeTocId.value = id
scrollActiveTocIntoView(id)
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
@@ -98,6 +210,36 @@ const scrollToTocItem = (event, id) => {
/** 소개 영역 공개 여부 */
const showAboutSection = false
onMounted(() => {
if (!import.meta.client) {
return
}
window.addEventListener('scroll', updateActiveToc, { passive: true })
window.addEventListener('resize', updateActiveToc)
nextTick(updateActiveToc)
})
onBeforeUnmount(() => {
if (!import.meta.client) {
return
}
window.removeEventListener('scroll', updateActiveToc)
window.removeEventListener('resize', updateActiveToc)
if (tocScrollFrame) {
window.cancelAnimationFrame(tocScrollFrame)
tocScrollFrame = 0
}
})
watch([postTocItems, () => route.fullPath], async () => {
activeTocId.value = ''
await nextTick()
updateActiveToc()
})
</script>
<template>
@@ -240,17 +382,22 @@ const showAboutSection = false
TOC
</p>
</div>
<nav class="right-sidebar__toc-nav mt-4" aria-label="게시글 목차">
<nav ref="tocNavRef" class="right-sidebar__toc-nav mt-4 max-h-[min(28rem,calc(100vh-18rem))] overflow-y-auto pr-2" aria-label="게시글 목차">
<ul v-if="postTocItems.length" class="right-sidebar__toc-list list-none space-y-2 p-0">
<li v-for="item in postTocItems" :key="item.id">
<a
class="right-sidebar__toc-link site-interactive block rounded-md py-1.5 pr-3 text-sm leading-snug text-[var(--site-text)] hover:text-[var(--site-accent)]"
class="right-sidebar__toc-link site-interactive block rounded-md border-l-2 py-1.5 pr-3 text-sm leading-snug transition-colors hover:text-[var(--site-accent)]"
:class="{
'pl-0 font-semibold': item.level === 1,
'pl-3': item.level === 2,
'pl-6 text-xs site-muted': item.level === 3
'border-[var(--site-accent)] bg-[var(--site-panel)] text-[var(--site-accent)] font-semibold': activeTocId === item.id,
'border-transparent text-[var(--site-text)]': activeTocId !== item.id,
'pl-2 font-semibold': item.level === 1,
'pl-5': item.level === 2,
'pl-8 text-xs': item.level === 3,
'site-muted': item.level === 3 && activeTocId !== item.id
}"
:href="`#${item.id}`"
:aria-current="activeTocId === item.id ? 'location' : undefined"
:data-toc-id="item.id"
@click="scrollToTocItem($event, item.id)"
>
{{ item.text }}