468 lines
16 KiB
Vue
468 lines
16 KiB
Vue
<script setup>
|
|
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' },
|
|
{ id: 'x', label: 'X', href: 'https://x.com', icon: 'x' },
|
|
{ id: 'github', label: 'Github', href: 'https://github.com', icon: 'github' },
|
|
{ id: 'instagram', label: 'Instagram', href: 'https://instagram.com', icon: 'instagram' },
|
|
{ id: 'linkedin', label: 'Linkedin', href: 'https://linkedin.com', icon: 'linkedin' },
|
|
{ id: 'rss', label: 'RSS', href: '/rss/', icon: 'rss' }
|
|
]
|
|
|
|
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
|
default: () => ({
|
|
title: 'sori.studio',
|
|
description: 'sori.studio 개인 블로그',
|
|
logoText: '井',
|
|
logoUrl: '',
|
|
copyrightText: '©2026 sori.studio'
|
|
})
|
|
})
|
|
|
|
const { data: navigation } = await useFetch('/api/navigation', {
|
|
default: () => ({
|
|
primary: [],
|
|
footer: [],
|
|
recommended: []
|
|
})
|
|
})
|
|
|
|
/**
|
|
* 공개 추천 사이트 목록(비가시 제외)
|
|
* @returns {Array<{ id: string, label: string, url: string, descriptionText?: string, thumbnailUrl?: string }>}
|
|
*/
|
|
const recommendedSites = computed(() => {
|
|
const list = navigation.value?.recommended
|
|
if (!Array.isArray(list)) {
|
|
return []
|
|
}
|
|
return list.filter((x) => x?.isVisible !== false)
|
|
})
|
|
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 - 링크
|
|
* @returns {boolean}
|
|
*/
|
|
const isExternalNavUrl = (url) => /^https?:\/\//i.test(String(url || '').trim())
|
|
|
|
/**
|
|
* 추천 사이트 보조 문구를 반환한다.
|
|
* @param {Object} item - 추천 사이트 항목
|
|
* @returns {string} 표시 문구
|
|
*/
|
|
const getRecommendedDisplayText = (item) => {
|
|
return String(item?.descriptionText || '').trim() || String(item?.url || '').trim()
|
|
}
|
|
|
|
/**
|
|
* 추천 사이트 이미지 URL을 반환한다.
|
|
* @param {Object} item - 추천 사이트 항목
|
|
* @returns {string} 이미지 URL
|
|
*/
|
|
const getRecommendedImageUrl = (item) => {
|
|
const thumbnailUrl = String(item?.thumbnailUrl || '').trim()
|
|
return thumbnailUrl || getExternalFaviconUrl(item?.url, 64)
|
|
}
|
|
|
|
/**
|
|
* 게시글 목차 링크를 부드럽게 이동한다.
|
|
* @param {MouseEvent} event - 클릭 이벤트
|
|
* @param {string} id - 이동할 제목 ID
|
|
* @returns {void}
|
|
*/
|
|
const scrollToTocItem = (event, id) => {
|
|
if (!import.meta.client) {
|
|
return
|
|
}
|
|
|
|
const target = document.getElementById(id)
|
|
|
|
if (!target) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
activeTocId.value = id
|
|
scrollActiveTocIntoView(id)
|
|
target.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'start'
|
|
})
|
|
window.history.replaceState(null, '', `${route.path}#${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>
|
|
<aside class="right-sidebar site-sidebar flex w-full flex-col overflow-hidden border-[var(--site-line)] max-lg:border-l-0 max-lg:border-t max-lg:px-4 max-lg:pb-10 lg:sticky lg:top-[57px] lg:z-10 lg:h-[calc(100vh-57px)] lg:max-h-[calc(100vh-57px)] lg:w-[287px] lg:self-start lg:border-l lg:border-t-0 lg:px-0">
|
|
<div class="right-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
|
|
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0 max-lg:px-0">
|
|
<div class="right-sidebar__profile flex items-center gap-3">
|
|
<div class="right-sidebar__logo grid h-12 w-12 place-items-center overflow-hidden rounded-2xl text-2xl font-bold text-[var(--site-invert-text)]">
|
|
<img
|
|
v-if="siteSettings.logoUrl"
|
|
class="h-full w-full object-cover"
|
|
:src="siteSettings.logoUrl"
|
|
:alt="siteSettings.title"
|
|
>
|
|
<span v-else>{{ siteSettings.logoText }}</span>
|
|
</div>
|
|
<div>
|
|
<p class="right-sidebar__title font-semibold">
|
|
{{ siteSettings.title }}
|
|
</p>
|
|
<p class="right-sidebar__description text-sm site-muted">
|
|
{{ siteSettings.description }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="right-sidebar__block 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">
|
|
Follow
|
|
</p>
|
|
<nav class="right-sidebar__social relative z-10 flex flex-wrap items-center gap-1 text-sm text-[var(--site-text)]">
|
|
<a
|
|
v-for="item in followLinks"
|
|
:key="item.id"
|
|
class="site-interactive p-0.5 hover:opacity-75"
|
|
:href="item.href"
|
|
:aria-label="item.label"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
<svg
|
|
v-if="item.icon === 'facebook'"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
class="h-4 w-4"
|
|
>
|
|
<path d="M7 10v4h3v7h4v-7h3l1-4h-4V8a1 1 0 0 1 1-1h3V3h-3a5 5 0 0 0-5 5v2z" />
|
|
</svg>
|
|
<svg
|
|
v-else-if="item.icon === 'x'"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
class="h-4 w-4"
|
|
>
|
|
<path d="M4 4l11.733 16H20L8.267 4z" />
|
|
<path d="M4 20l6.768-6.768m2.46-2.46L20 4" />
|
|
</svg>
|
|
<svg
|
|
v-else-if="item.icon === 'github'"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
class="h-4 w-4"
|
|
>
|
|
<path d="M9 19c-4.3 1.4-4.3-2.5-6-3m12 5v-3.5c0-1 .1-1.4-.5-2c2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.5 1.3a12.3 12.3 0 0 0-6.2 0c-2.4-1.6-3.5-1.3-3.5-1.3a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 4 9.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21" />
|
|
</svg>
|
|
<svg
|
|
v-else-if="item.icon === 'instagram'"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
class="h-4 w-4"
|
|
>
|
|
<rect x="4" y="4" width="16" height="16" rx="4" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
<line x1="16.5" y1="7.5" x2="16.5" y2="7.501" />
|
|
</svg>
|
|
<svg
|
|
v-else-if="item.icon === 'linkedin'"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
class="h-4 w-4"
|
|
>
|
|
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z" />
|
|
<rect x="2" y="9" width="4" height="12" />
|
|
<circle cx="4" cy="4" r="2" />
|
|
</svg>
|
|
<svg
|
|
v-else
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
class="h-4 w-4"
|
|
>
|
|
<circle cx="5" cy="19" r="1" />
|
|
<path d="M4 4a16 16 0 0 1 16 16" />
|
|
<path d="M4 11a9 9 0 0 1 9 9" />
|
|
</svg>
|
|
<span class="sr-only">{{ item.label }}</span>
|
|
</a>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="isPostDetailRoute"
|
|
class="right-sidebar__block right-sidebar__toc site-sidebar-section py-5 pl-5 pr-0 max-lg:hidden"
|
|
>
|
|
<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 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 border-l-2 py-1.5 pr-3 text-sm leading-snug transition-colors hover:text-[var(--site-accent)]"
|
|
:class="{
|
|
'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 }}
|
|
</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">
|
|
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
|
|
Recommended
|
|
</p>
|
|
</div>
|
|
<ul class="right-sidebar__recommended-list mt-4 list-none flex flex-col gap-2.5 p-0">
|
|
<li v-for="item in recommendedSites" :key="item.id">
|
|
<a
|
|
class="right-sidebar__recommended-card site-interactive flex items-center gap-3 rounded-xl border border-[var(--site-line)] bg-[var(--site-panel)] px-3 py-2.5 transition-colors hover:border-[var(--site-accent)]"
|
|
:href="item.url"
|
|
:target="isExternalNavUrl(item.url) ? '_blank' : undefined"
|
|
:rel="isExternalNavUrl(item.url) ? 'nofollow noopener noreferrer' : undefined"
|
|
>
|
|
<span class="right-sidebar__recommended-icon grid h-9 w-9 shrink-0 place-items-center overflow-hidden rounded-lg bg-[var(--site-paper)] text-xs font-bold text-[var(--site-text)] ring-1 ring-[var(--site-line)]">
|
|
<img
|
|
v-if="getRecommendedImageUrl(item)"
|
|
class="h-full w-full object-cover"
|
|
:src="getRecommendedImageUrl(item)"
|
|
width="36"
|
|
height="36"
|
|
:alt="item.thumbnailUrl ? item.label : ''"
|
|
loading="lazy"
|
|
referrerpolicy="no-referrer"
|
|
>
|
|
<span v-else class="px-1 text-center leading-none">{{ (item.label || '?').slice(0, 1) }}</span>
|
|
</span>
|
|
<span class="min-w-0 flex-1">
|
|
<span class="block truncate text-sm font-semibold text-[var(--site-text)]">{{ item.label }}</span>
|
|
<span class="mt-0.5 block truncate text-[11px] site-muted" :class="item.descriptionText ? '' : 'font-mono'">{{ getRecommendedDisplayText(item) }}</span>
|
|
</span>
|
|
<span class="shrink-0 text-xs site-muted" aria-hidden="true">↗</span>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div v-if="showAboutSection" class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
|
|
<p class="right-sidebar__about text-sm leading-6 site-muted">
|
|
{{ siteSettings.description }}
|
|
</p>
|
|
<NuxtLink class="right-sidebar__about-button mt-4 inline-flex rounded-lg px-4 py-2 text-sm font-semibold site-accent-button" to="/pages/about">
|
|
About {{ siteSettings.title }}
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
|
|
<footer class="right-sidebar__footer shrink-0 py-4 pl-5 pr-3 text-xs site-muted max-lg:px-0">
|
|
{{ siteSettings.copyrightText }}
|
|
</footer>
|
|
</aside>
|
|
</template>
|