Files
sori.studio/components/site/RightSidebar.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>