게시글 상세 목차 사이드바 추가 v1.5.12
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
import { buildCodeBlockLines, parseCodeFenceLine } from '../../lib/markdown-code-block.js'
|
||||
import { buildToggleBlockLines } from '../../lib/markdown-toggle.js'
|
||||
import { CALLOUT_BACKGROUND_OPTIONS, parseCalloutOptions } from '../../lib/markdown-callout.js'
|
||||
import { createHeadingIdFactory } from '../../lib/markdown-toc.js'
|
||||
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
||||
import ProseCodeBlock from './ProseCodeBlock.vue'
|
||||
import ContentMarkdownCalloutEditor from './ContentMarkdownCalloutEditor.vue'
|
||||
@@ -731,6 +732,18 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
}
|
||||
|
||||
const blocks = computed(() => parseMarkdownBlocks(props.content))
|
||||
const headingIdsByBlock = computed(() => {
|
||||
const createHeadingId = createHeadingIdFactory()
|
||||
const ids = {}
|
||||
|
||||
for (const block of blocks.value) {
|
||||
if (block.type === 'heading' && block.level >= 1 && block.level <= 3) {
|
||||
ids[block.id] = createHeadingId(block.text)
|
||||
}
|
||||
}
|
||||
|
||||
return ids
|
||||
})
|
||||
|
||||
/** @type {import('vue').ComputedRef<number>} 문서 맨 아래 단일 이미지 삽입 줄 */
|
||||
const tailInsertBeforeLine = computed(() => {
|
||||
@@ -2323,7 +2336,7 @@ onBeforeUnmount(() => {
|
||||
@delete-line="onDeleteLine"
|
||||
@merge-with-previous="onMergeWithPreviousLine(block.meta.startLine, $event)"
|
||||
/>
|
||||
<ProseHeading v-else-if="block.type === 'heading'" :level="block.level">
|
||||
<ProseHeading v-else-if="block.type === 'heading'" :id="headingIdsByBlock[block.id]" :level="block.level">
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-heading-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
|
||||
@@ -3,6 +3,10 @@ const props = defineProps({
|
||||
level: {
|
||||
type: Number,
|
||||
default: 2
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,7 +16,8 @@ const tagName = computed(() => `h${Math.min(Math.max(props.level, 1), 6)}`)
|
||||
<template>
|
||||
<component
|
||||
:is="tagName"
|
||||
class="prose-heading mb-2.5 font-semibold leading-[1.25] tracking-normal first:mt-0"
|
||||
:id="id || undefined"
|
||||
class="prose-heading mb-2.5 scroll-mt-20 font-semibold leading-[1.25] tracking-normal first:mt-0"
|
||||
:class="{
|
||||
'text-[clamp(1.35rem,1.25rem+0.35vw,1.6rem)] leading-[1.15]': level === 1,
|
||||
'text-[clamp(1.2rem,1.15rem+0.3vw,1.4rem)]': level === 2,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup>
|
||||
import { getExternalFaviconUrl } from '~/lib/external-favicon-url.js'
|
||||
|
||||
const route = useRoute()
|
||||
const postToc = useState('post-detail-toc', () => [])
|
||||
|
||||
const followLinks = [
|
||||
{ id: 'facebook', label: 'Facebook', href: 'https://facebook.com', icon: 'facebook' },
|
||||
{ id: 'x', label: 'X', href: 'https://x.com', icon: 'x' },
|
||||
@@ -39,6 +42,8 @@ const recommendedSites = computed(() => {
|
||||
}
|
||||
return list.filter((x) => x?.isVisible !== false)
|
||||
})
|
||||
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
|
||||
const postTocItems = computed(() => Array.isArray(postToc.value) ? postToc.value : [])
|
||||
|
||||
/**
|
||||
* 새 탭으로 열 외부 URL인지
|
||||
@@ -202,7 +207,38 @@ const showAboutSection = false
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="recommendedSites.length"
|
||||
v-if="isPostDetailRoute"
|
||||
class="right-sidebar__block right-sidebar__toc site-sidebar-section py-5 pl-5 pr-0"
|
||||
>
|
||||
<div class="right-sidebar__row flex items-center justify-between">
|
||||
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
|
||||
TOC
|
||||
</p>
|
||||
</div>
|
||||
<nav class="right-sidebar__toc-nav mt-4" 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="{
|
||||
'pl-0 font-semibold': item.level === 1,
|
||||
'pl-3': item.level === 2,
|
||||
'pl-6 text-xs site-muted': item.level === 3
|
||||
}"
|
||||
:href="`#${item.id}`"
|
||||
>
|
||||
{{ item.text }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="right-sidebar__toc-empty text-sm site-muted">
|
||||
목차로 표시할 제목이 없습니다.
|
||||
</p>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="recommendedSites.length"
|
||||
class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0"
|
||||
>
|
||||
<div class="right-sidebar__row flex items-center justify-between">
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.5.12
|
||||
|
||||
- 게시글 상세 오른쪽 사이드바에서 추천 사이트 대신 본문 목차를 보여주도록 바꿨다.
|
||||
- 본문 H1~H3 제목에 목차 이동용 앵커를 자동으로 부여했다.
|
||||
- 로컬 개발 DB의 소유자 계정을 `zenn`으로 보정했다.
|
||||
|
||||
## v1.5.11
|
||||
|
||||
- 멤버 상세 화면을 보기 모드와 수정 모드로 분리했다.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-27 v1.5.12 — 게시글 상세 오른쪽 사이드바 TOC 전환
|
||||
|
||||
추천 사이트는 탐색 보조 영역이지만, 게시글을 읽는 순간에는 외부 이동보다 본문 안에서 빠르게 이동하는 편의가 더 중요하다. 따라서 게시글 상세에서는 오른쪽 사이드바의 Recommended 영역을 숨기고, 본문 H1~H3 제목에서 추출한 TOC를 같은 위치에 표시한다. 본문 렌더러와 TOC가 같은 앵커 ID 생성 규칙을 공유하도록 공통 유틸을 두어 제목 중복이나 한글 제목에서도 링크가 안정적으로 맞게 했다.
|
||||
|
||||
## 2026-05-27 v1.5.11 — 멤버 상세 보기/수정 모드 분리
|
||||
|
||||
멤버 상세 화면은 운영자가 상태를 확인하는 시간이 더 많고, 권한·이메일·관리자 노트 같은 값은 실수로 바뀌면 영향이 크다. 따라서 기존 회원 상세는 먼저 읽기 전용 상태만 보여주고, 명시적으로 `수정하기`를 누른 뒤에만 편집 컨트롤을 노출한다. 저장 버튼도 실제 변경이 생긴 뒤에만 활성화해 “저장할 내용이 있는지”를 버튼 상태로 알 수 있게 했고, 저장 결과는 다른 관리자 화면과 같은 우측 상단 토스트로 통일한다.
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
|
||||
| lib/markdown-block-context.js | 관리자 Markdown textarea 커서 위치 기준 이미지·갤러리·임베드 블록 설정 패널 대상 판별 |
|
||||
| lib/markdown-image.js | 이미지 마크다운 직렬화·파싱, 단독 이미지 URL 판별 |
|
||||
| lib/markdown-toc.js | 공개 게시글 TOC용 H1~H3 제목 추출과 앵커 ID 생성 |
|
||||
| lib/markdown-slash-commands.js | 관리자 Markdown-first 에디터 슬래시 명령 목록과 삽입 줄 정의 |
|
||||
| lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) |
|
||||
| lib/analytics.js | 서버 전용 visitor/session hash(`node:crypto`) |
|
||||
@@ -65,7 +66,7 @@
|
||||
| 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), 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 인터랙션, 태그는 있을 때만 메타에 표시 |
|
||||
@@ -152,7 +153,7 @@
|
||||
| pages/index.vue | 홈, `site_settings` 커버가 있을 때만 라이트·다크 이미지 지원 `HomeHero`, Featured/Latest, Latest 피드 Compact 기본값·List·Cards 보기, Latest 메타는 태그가 있을 때만 태그 배지 표시, Featured는 추천 글이 있을 때만 표시하며 이미지 없는 추천 글은 제목 placeholder 썸네일과 모바일 터치 가로 스크롤·스냅, 끝에서 화살표 비활성 |
|
||||
| pages/posts/index.vue | 게시물 전체 목록, 태그는 있을 때만 카드 메타에 표시 |
|
||||
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
|
||||
| pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, 로그인 회원이 글쓴이(`author_id`)이면 공유 버튼 옆 새 탭 편집 링크 표시, 게시물 SEO/OG 메타 출력(요약 없으면 본문 fallback), 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
|
||||
| pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, H1~H3 본문 제목을 오른쪽 TOC 상태로 제공, 로그인 회원이 글쓴이(`author_id`)이면 공유 버튼 옆 새 탭 편집 링크 표시, 게시물 SEO/OG 메타 출력(요약 없으면 본문 fallback), 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
|
||||
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
|
||||
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
||||
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드 |
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
- 댓글 정렬은 `Best`(좋아요 우선), `Latest`, `Oldest`를 제공한다.
|
||||
- 댓글 아바타 이미지 로드 실패 시 이니셜 아바타로 즉시 대체한다.
|
||||
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
|
||||
- 게시물 상세의 오른쪽 사이드바는 추천 사이트 대신 본문 H1~H3 제목 기반 TOC를 표시한다. TOC 링크는 본문 제목에 부여된 앵커 ID로 이동하며, 본문 제목이 없으면 목차 없음 문구를 표시한다.
|
||||
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
|
||||
- 로그인 회원 ID가 게시물 `author_id`와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시 `/admin/posts/:id` 편집 화면을 새 탭으로 연다.
|
||||
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.5.12
|
||||
|
||||
- 공개 게시글 상세: 오른쪽 사이드바의 Recommended 영역을 숨기고, 같은 위치에 H1~H3 기반 TOC를 표시하도록 수정.
|
||||
- 공개 게시글 본문: H1~H3 제목에 TOC 링크용 앵커 ID를 부여하도록 수정.
|
||||
- DB: 로컬 개발 DB에서 `zenn` 계정을 소유자로 보정하고 기존 비활성 소유자 계정을 일반 멤버로 정리.
|
||||
|
||||
## v1.5.11
|
||||
|
||||
- 관리자 멤버 상세: 기존 회원 진입 시 읽기 전용 보기 모드로 표시하고 상단 버튼을 `수정하기`로 변경.
|
||||
|
||||
89
lib/markdown-toc.js
Normal file
89
lib/markdown-toc.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 제목 텍스트에서 인라인 마크다운 기호를 제거한다.
|
||||
* @param {string} value - 원본 제목 텍스트
|
||||
* @returns {string} 정리된 제목 텍스트
|
||||
*/
|
||||
export const stripMarkdownHeadingText = (value) => String(value || '')
|
||||
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/[`*_~]/g, '')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
/**
|
||||
* 제목 텍스트를 앵커 ID 기본값으로 변환한다.
|
||||
* @param {string} value - 제목 텍스트
|
||||
* @returns {string} 앵커 기본값
|
||||
*/
|
||||
export const createHeadingSlugBase = (value) => {
|
||||
const cleaned = stripMarkdownHeadingText(value)
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{Letter}\p{Number}\s-]/gu, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
return cleaned || 'section'
|
||||
}
|
||||
|
||||
/**
|
||||
* 중복 제목을 보정하는 제목 ID 생성기를 만든다.
|
||||
* @returns {(value: string) => string} 제목 ID 생성 함수
|
||||
*/
|
||||
export const createHeadingIdFactory = () => {
|
||||
const counts = new Map()
|
||||
|
||||
return (value) => {
|
||||
const base = createHeadingSlugBase(value)
|
||||
const nextCount = (counts.get(base) || 0) + 1
|
||||
counts.set(base, nextCount)
|
||||
|
||||
return nextCount === 1 ? base : `${base}-${nextCount}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마크다운 본문에서 H1~H3 목차 항목을 추출한다.
|
||||
* @param {string} markdown - 마크다운 본문
|
||||
* @returns {Array<{ id: string, level: number, text: string }>} 목차 항목
|
||||
*/
|
||||
export const extractMarkdownToc = (markdown) => {
|
||||
const createHeadingId = createHeadingIdFactory()
|
||||
const toc = []
|
||||
const lines = String(markdown || '').split('\n')
|
||||
let inCodeFence = false
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim()
|
||||
|
||||
if (trimmedLine.startsWith('```')) {
|
||||
inCodeFence = !inCodeFence
|
||||
continue
|
||||
}
|
||||
|
||||
if (inCodeFence) {
|
||||
continue
|
||||
}
|
||||
|
||||
const headingMatch = trimmedLine.match(/^(#{1,3})\s+(.+)$/)
|
||||
|
||||
if (!headingMatch) {
|
||||
continue
|
||||
}
|
||||
|
||||
const text = stripMarkdownHeadingText(headingMatch[2])
|
||||
|
||||
if (!text) {
|
||||
continue
|
||||
}
|
||||
|
||||
toc.push({
|
||||
id: createHeadingId(headingMatch[2]),
|
||||
level: headingMatch[1].length,
|
||||
text
|
||||
})
|
||||
}
|
||||
|
||||
return toc
|
||||
}
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.5.11",
|
||||
"version": "1.5.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.5.11",
|
||||
"version": "1.5.12",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.5.11",
|
||||
"version": "1.5.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { extractMarkdownToc } from '~/lib/markdown-toc.js'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'post'
|
||||
})
|
||||
@@ -13,6 +15,7 @@ const { data: tags } = await useFetch('/api/tags', {
|
||||
const { data: posts } = await useFetch('/api/posts', {
|
||||
default: () => []
|
||||
})
|
||||
const postToc = useState('post-detail-toc', () => [])
|
||||
|
||||
if (!post.value) {
|
||||
throw createError({
|
||||
@@ -88,6 +91,7 @@ const shareMetadata = computed(() => ({
|
||||
image: post.value.featuredImage || '',
|
||||
url: pageUrl.value
|
||||
}))
|
||||
const tableOfContents = computed(() => extractMarkdownToc(post.value?.content || ''))
|
||||
|
||||
const encodedShareText = computed(() => encodeURIComponent(shareMetadata.value.title))
|
||||
const encodedShareUrl = computed(() => encodeURIComponent(shareMetadata.value.url))
|
||||
@@ -143,6 +147,14 @@ onMounted(() => {
|
||||
fetchCurrentViewer()
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
postToc.value = tableOfContents.value
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
postToc.value = []
|
||||
})
|
||||
|
||||
/**
|
||||
* 절대 URL 생성
|
||||
* @param {string} value - 원본 URL
|
||||
|
||||
Reference in New Issue
Block a user