게시글 목차와 댓글 등록 상태 정리 v1.5.16
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.5.16
|
||||
|
||||
- 게시글을 직접 스크롤해도 오른쪽 TOC에서 현재 읽는 제목 위치가 강조되도록 개선했다.
|
||||
- TOC 항목이 많을 때 활성 항목을 따라 TOC 영역이 내부 스크롤되도록 정리했다.
|
||||
- 댓글과 답글 등록 버튼은 내용을 입력했을 때만 활성화되도록 정리했다.
|
||||
- 댓글 정렬 라벨을 한글 기준으로 정리하고 답글 입력 영역 스타일을 가볍게 맞췄다.
|
||||
|
||||
## v1.5.15
|
||||
|
||||
- 로그아웃 상태의 사용자 메뉴 버튼도 `?` 대신 사람 아이콘으로 보이도록 수정했다.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-06-01 v1.5.16 — TOC를 읽기 위치 표시 장치로 확장
|
||||
|
||||
게시글 TOC는 클릭 이동만 지원하면 긴 글에서 현재 위치를 다시 파악하기 어렵다. 본문 스크롤 위치를 기준으로 현재 H1~H3 제목을 계산해 TOC 항목에 강조 상태를 주고, 항목이 많아 TOC 영역 안에서 넘칠 때는 활성 항목이 보이도록 내부 스크롤을 보정한다. 모바일에서는 TOC 자체를 숨기는 기존 결정을 유지해 본문 아래에 불필요한 긴 목차가 붙지 않게 한다.
|
||||
|
||||
댓글 작성 버튼은 빈 입력 상태에서 활성화되어도 서버 요청 전에 차단되기만 하므로 사용자에게 실행 가능한 동작처럼 보인다. trim 기준 입력값이 있을 때만 댓글·답글 등록 버튼을 활성화해 화면 상태와 실제 저장 조건을 맞춘다.
|
||||
|
||||
## 2026-05-27 v1.5.15 — 기본 사용자 아이콘 표시 범위 통일
|
||||
|
||||
헤더의 사용자 메뉴 버튼은 로그인 여부를 여는 진입점이기도 하므로 비로그인 상태에서 `?`를 표시하면 오류나 누락처럼 보인다. 아바타가 없는 로그인 회원과 비로그인 방문자 모두 같은 사람 아이콘을 쓰도록 통일해 버튼 의미를 명확하게 유지한다.
|
||||
|
||||
@@ -66,13 +66,13 @@
|
||||
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
|
||||
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), Authors 영역은 비공개, 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0`, 태그 카테고리·테마 점은 `site-sidebar-nav-row` 호버 |
|
||||
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 |
|
||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, 일반 화면은 공개 `GET /api/navigation`의 `recommended` 카드 목록(대체 텍스트·썸네일 우선, 외부 URL은 Google 파비콘 fallback), 게시글 상세 데스크톱은 Recommended 대신 H1~H3 TOC(모바일 숨김), Follow·구독 폼, About 영역은 비공개, `lg+` 스티키 |
|
||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, 일반 화면은 공개 `GET /api/navigation`의 `recommended` 카드 목록(대체 텍스트·썸네일 우선, 외부 URL은 Google 파비콘 fallback), 게시글 상세 데스크톱은 Recommended 대신 H1~H3 TOC(모바일 숨김, 스크롤 위치 기반 활성 표시·내부 자동 스크롤), Follow·구독 폼, About 영역은 비공개, `lg+` 스티키 |
|
||||
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
||||
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
|
||||
| components/site/PostCardMedia.vue | 게시물 카드 썸네일(대표 이미지 없으면 제목 텍스트 플레이스홀더) |
|
||||
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
||||
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 작성자 썸네일/좋아요/상대시간 표시 |
|
||||
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 입력값 기반 등록 버튼 활성화, 작성자 썸네일/좋아요/상대시간 표시 |
|
||||
|
||||
## 관리자 컴포넌트
|
||||
|
||||
|
||||
@@ -68,12 +68,13 @@
|
||||
- 댓글 시작 섹션의 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
|
||||
- 본문 끝과 댓글 섹션 시작 사이 간격은 `48px`(`mt-12`)로 유지한다.
|
||||
- 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
|
||||
- 댓글·답글 등록 버튼은 입력값을 trim한 뒤 내용이 있을 때만 활성화한다.
|
||||
- 댓글은 작성자 썸네일(없으면 이니셜)과 좋아요 수를 표시한다.
|
||||
- 댓글 시간은 24시간 이내일 때 상대 시간(분/시간 전), 이후에는 날짜로 표시한다.
|
||||
- 댓글 정렬은 `Best`(좋아요 우선), `Latest`, `Oldest`를 제공한다.
|
||||
- 댓글 정렬은 `인기순`(좋아요 우선), `최신순`, `오래된순`을 제공한다.
|
||||
- 댓글 아바타 이미지 로드 실패 시 이니셜 아바타로 즉시 대체한다.
|
||||
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
|
||||
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 추천 사이트 대신 본문 H1~H3 제목 기반 TOC를 표시한다. TOC 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 TOC를 숨긴다.
|
||||
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 추천 사이트 대신 본문 H1~H3 제목 기반 TOC를 표시한다. TOC 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 스크롤 중에는 현재 제목에 해당하는 TOC 항목을 강조하고, 목차 항목이 많으면 TOC 내부 스크롤이 활성 항목을 따라간다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 TOC를 숨긴다.
|
||||
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
|
||||
- 로그인 회원 ID가 게시물 `author_id`와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시 `/admin/posts/:id` 편집 화면을 새 탭으로 연다.
|
||||
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.5.16
|
||||
|
||||
- 공개 게시글 TOC: 본문 스크롤 위치에 맞춰 현재 제목 항목을 시각적으로 강조하도록 수정.
|
||||
- 공개 게시글 TOC: 목차 항목이 많을 때 TOC 영역이 자체 스크롤되며 활성 항목을 자동으로 따라가도록 수정.
|
||||
- 공개 댓글: 댓글·답글 등록 버튼이 입력값이 있을 때만 활성화되도록 수정.
|
||||
- 공개 댓글: 정렬 라벨 한글화와 답글 입력 영역 스타일 정리 사용자 편집분 반영.
|
||||
|
||||
## v1.5.15
|
||||
|
||||
- 공개 헤더: 비로그인 상태의 사용자 메뉴 기본 아바타도 `?` 텍스트 대신 사람 아이콘으로 표시하도록 수정.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.5.15",
|
||||
"version": "1.5.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.5.15",
|
||||
"version": "1.5.16",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.5.15",
|
||||
"version": "1.5.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
Reference in New Issue
Block a user