세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
362 lines
12 KiB
Vue
362 lines
12 KiB
Vue
<script setup>
|
|
import { getExternalFaviconUrl } from '~/lib/external-favicon-url.js'
|
|
|
|
defineProps({
|
|
menuOpen: {
|
|
type: Boolean,
|
|
required: true
|
|
},
|
|
menuMounted: {
|
|
type: Boolean,
|
|
default: false
|
|
}
|
|
})
|
|
|
|
const { isDarkMode, toggleTheme } = useThemeMode()
|
|
|
|
const { data: tags } = await useFetch('/api/tags', {
|
|
default: () => []
|
|
})
|
|
|
|
const { data: navigation } = await useFetch('/api/navigation', {
|
|
default: () => ({
|
|
primary: [],
|
|
footer: [],
|
|
recommended: []
|
|
})
|
|
})
|
|
|
|
/** 저자 영역 공개 여부 */
|
|
const showAuthorSection = false
|
|
|
|
const STORAGE_KEY = 'sori-primary-nav-expanded'
|
|
|
|
/**
|
|
* 공개 추천 사이트 목록을 반환한다.
|
|
* @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((item) => item?.isVisible !== false)
|
|
})
|
|
|
|
/**
|
|
* 새 탭으로 열 외부 URL인지 확인한다.
|
|
* @param {string} url - 링크
|
|
* @returns {boolean} 외부 URL 여부
|
|
*/
|
|
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()
|
|
}
|
|
|
|
/** 추천 사이트 이미지 로드 실패 단계 (`thumbnail` → `favicon` → `letter`) */
|
|
const recommendedImageStage = ref({})
|
|
|
|
/**
|
|
* 추천 사이트 파비콘 URL을 반환한다.
|
|
* @param {Object} item - 추천 사이트 항목
|
|
* @returns {string} 파비콘 URL
|
|
*/
|
|
const getRecommendedFaviconUrl = (item) => {
|
|
const cachedFaviconUrl = String(item?.cachedFaviconUrl || '').trim()
|
|
if (cachedFaviconUrl) {
|
|
return cachedFaviconUrl
|
|
}
|
|
return getExternalFaviconUrl(item?.url, 64)
|
|
}
|
|
|
|
/**
|
|
* 추천 사이트 이미지 URL을 반환한다.
|
|
* @param {Object} item - 추천 사이트 항목
|
|
* @returns {string} 이미지 URL
|
|
*/
|
|
const getRecommendedImageUrl = (item) => {
|
|
const id = String(item?.id || '')
|
|
const stage = recommendedImageStage.value[id] || 'thumbnail'
|
|
const thumbnailUrl = String(item?.thumbnailUrl || '').trim()
|
|
const faviconUrl = getRecommendedFaviconUrl(item)
|
|
|
|
if (stage === 'letter') {
|
|
return ''
|
|
}
|
|
if (stage === 'favicon') {
|
|
return faviconUrl
|
|
}
|
|
return thumbnailUrl || faviconUrl
|
|
}
|
|
|
|
/**
|
|
* 추천 사이트 이미지 로드 실패 시 다음 단계로 넘긴다.
|
|
* @param {Object} item - 추천 사이트 항목
|
|
* @returns {void}
|
|
*/
|
|
const onRecommendedImageError = (item) => {
|
|
const id = String(item?.id || '')
|
|
if (!id) {
|
|
return
|
|
}
|
|
const thumbnailUrl = String(item?.thumbnailUrl || '').trim()
|
|
const faviconUrl = getRecommendedFaviconUrl(item)
|
|
const stage = recommendedImageStage.value[id] || 'thumbnail'
|
|
|
|
if (stage === 'thumbnail' && thumbnailUrl && faviconUrl) {
|
|
recommendedImageStage.value = { ...recommendedImageStage.value, [id]: 'favicon' }
|
|
return
|
|
}
|
|
recommendedImageStage.value = { ...recommendedImageStage.value, [id]: 'letter' }
|
|
}
|
|
|
|
/**
|
|
* 트리에서 하위가 있는 노드 id를 모은다.
|
|
* @param {Array<Object>} list - 노드 목록
|
|
* @returns {string[]} id 목록
|
|
*/
|
|
const collectBranchIds = (list) => {
|
|
const out = []
|
|
for (const node of list || []) {
|
|
if (node.children?.length) {
|
|
out.push(String(node.id))
|
|
out.push(...collectBranchIds(node.children))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* localStorage에서 펼침 상태를 읽는다.
|
|
* @returns {string[]|null} id 배열
|
|
*/
|
|
const readStoredExpanded = () => {
|
|
if (typeof window === 'undefined') {
|
|
return null
|
|
}
|
|
try {
|
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
|
if (!raw) {
|
|
return null
|
|
}
|
|
const parsed = JSON.parse(raw)
|
|
return Array.isArray(parsed) ? parsed.map(String) : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 펼침 상태를 localStorage에 저장한다.
|
|
* @param {Set<string>} set - id 집합
|
|
* @returns {void}
|
|
*/
|
|
const persistExpanded = (set) => {
|
|
if (typeof window === 'undefined') {
|
|
return
|
|
}
|
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...set]))
|
|
}
|
|
|
|
const primaryNavExpandedSet = ref(new Set())
|
|
|
|
/**
|
|
* 트리 구조에 맞게 펼침 집합을 맞춘다.
|
|
* @param {Array<Object>} nodes - primary 트리
|
|
* @param {boolean} useStorage - 최초·복원 시 저장값 반영
|
|
* @returns {void}
|
|
*/
|
|
const syncPrimaryNavExpanded = (nodes, useStorage = false) => {
|
|
const allBranch = new Set(collectBranchIds(nodes))
|
|
if (useStorage) {
|
|
const stored = readStoredExpanded()
|
|
if (stored && stored.length) {
|
|
const next = new Set()
|
|
for (const id of stored) {
|
|
if (allBranch.has(id)) {
|
|
next.add(id)
|
|
}
|
|
}
|
|
primaryNavExpandedSet.value = next.size ? next : allBranch
|
|
return
|
|
}
|
|
}
|
|
const next = new Set()
|
|
for (const id of primaryNavExpandedSet.value) {
|
|
if (allBranch.has(id)) {
|
|
next.add(id)
|
|
}
|
|
}
|
|
primaryNavExpandedSet.value = next.size ? next : allBranch
|
|
}
|
|
|
|
/**
|
|
* 상단 네비 폴더 펼침 토글
|
|
* @param {string} id - 노드 id
|
|
* @returns {void}
|
|
*/
|
|
const togglePrimaryNavBranch = (id) => {
|
|
const key = String(id)
|
|
const next = new Set(primaryNavExpandedSet.value)
|
|
if (next.has(key)) {
|
|
next.delete(key)
|
|
} else {
|
|
next.add(key)
|
|
}
|
|
primaryNavExpandedSet.value = next
|
|
persistExpanded(next)
|
|
}
|
|
|
|
provide('sidebarPrimaryNavExpandedSet', primaryNavExpandedSet)
|
|
provide('sidebarPrimaryNavToggle', togglePrimaryNavBranch)
|
|
|
|
watch(
|
|
() => navigation.value?.primary,
|
|
(nodes) => {
|
|
syncPrimaryNavExpanded(nodes || [], false)
|
|
},
|
|
{ deep: true }
|
|
)
|
|
|
|
onMounted(() => {
|
|
syncPrimaryNavExpanded(navigation.value?.primary || [], true)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<aside
|
|
id="menu"
|
|
class="left-sidebar site-sidebar flex flex-col overflow-hidden border-r border-[var(--site-line)] transition-[width,opacity,transform,border-color] duration-300 ease-out max-lg:fixed max-lg:left-0 max-lg:z-[60] max-lg:w-[min(287px,calc(100vw-24px))] max-lg:shadow-[0_16px_48px_rgba(0,0,0,0.18)] lg:sticky lg:z-10 lg:self-start"
|
|
style="top: var(--site-top-chrome-height, 57px); height: calc(100dvh - var(--site-top-chrome-height, 57px)); max-height: calc(100dvh - var(--site-top-chrome-height, 57px))"
|
|
:class="[
|
|
menuOpen
|
|
? 'lg:w-[287px] lg:opacity-100'
|
|
: 'lg:w-0 lg:opacity-0 lg:border-transparent',
|
|
menuOpen && menuMounted
|
|
? 'max-lg:translate-x-0 max-lg:pointer-events-auto'
|
|
: 'max-lg:-translate-x-full max-lg:pointer-events-none'
|
|
]"
|
|
>
|
|
<div class="left-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
|
|
<div class="left-sidebar__block site-sidebar-section py-3 pl-4 pr-3 sm:pl-5 xl:pl-0">
|
|
<nav class="left-sidebar__nav" data-nav="menu">
|
|
<SidebarPrimaryNavList :nodes="navigation.primary" />
|
|
</nav>
|
|
</div>
|
|
|
|
<div class="left-sidebar__block site-sidebar-section px-5 py-4 pr-3 xl:pl-0">
|
|
<div class="left-sidebar__section-title flex items-center justify-between pr-2 text-xs font-semibold uppercase tracking-[0.01em] site-muted">
|
|
<span>Categories</span>
|
|
<span class="text-sm">⌃</span>
|
|
</div>
|
|
<div class="left-sidebar__category-grid mt-1.5 grid grid-cols-2 gap-x-2 gap-y-[2px] text-[0.8rem] font-medium">
|
|
<NuxtLink
|
|
v-for="tag in tags"
|
|
:key="tag.id"
|
|
class="left-sidebar__category site-sidebar-nav-row group flex items-center gap-2 rounded-[10px] py-2 pr-3 pl-0 leading-tight transition-[padding,background-color,color] duration-200 hover:px-3"
|
|
:to="`/tag/${tag.slug}`"
|
|
>
|
|
<span class="left-sidebar__category-color h-4 w-1 rounded-sm rounded-l-none transition-all duration-200 group-hover:h-2 group-hover:w-2 group-hover:rounded-full" :style="{ backgroundColor: tag.color }" />
|
|
<span class="left-sidebar__category-name flex-1 truncate transition-transform duration-200 group-hover:translate-x-[3px]">{{ tag.name }}</span>
|
|
<span
|
|
v-if="tag.postCount"
|
|
class="left-sidebar__category-count invisible text-xs font-medium opacity-0 transition-opacity duration-200 group-hover:visible group-hover:opacity-100"
|
|
>
|
|
{{ tag.postCount }}
|
|
</span>
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="recommendedSites.length"
|
|
class="left-sidebar__block site-sidebar-section px-5 py-4 pr-3 xl:pl-0"
|
|
>
|
|
<div class="left-sidebar__section-title flex items-center justify-between pr-2 text-xs font-semibold uppercase tracking-[0.01em] site-muted">
|
|
<span>Recommended</span>
|
|
<span class="text-sm">⌃</span>
|
|
</div>
|
|
<ul class="left-sidebar__recommended-list mt-3 flex list-none flex-col gap-2 p-0">
|
|
<li v-for="item in recommendedSites" :key="item.id">
|
|
<a
|
|
class="left-sidebar__recommended-card site-interactive flex items-center gap-2.5 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] px-2.5 py-2 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="left-sidebar__recommended-icon grid h-8 w-8 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="32"
|
|
height="32"
|
|
:alt="item.thumbnailUrl ? item.label : ''"
|
|
loading="lazy"
|
|
referrerpolicy="no-referrer"
|
|
@error="onRecommendedImageError(item)"
|
|
>
|
|
<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="showAuthorSection" class="left-sidebar__block site-sidebar-section px-5 py-5 pr-3 xl:pl-0">
|
|
<div class="left-sidebar__section-title flex items-center justify-between text-xs font-semibold uppercase site-muted">
|
|
<span>Authors</span>
|
|
<span>⌃</span>
|
|
</div>
|
|
<div class="left-sidebar__authors mt-4 grid gap-4 text-sm">
|
|
<div class="left-sidebar__author flex items-center gap-3">
|
|
<span class="h-8 w-8 rounded-full bg-[#e7c49d]" />
|
|
<span><strong class="block">sori</strong><span class="site-soft">Editor</span></span>
|
|
</div>
|
|
<div class="left-sidebar__author flex items-center gap-3">
|
|
<span class="h-8 w-8 rounded-full bg-[#98b7d5]" />
|
|
<span><strong class="block">zenn</strong><span class="site-soft">Writer</span></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<footer class="left-sidebar__footer flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 px-4 py-4 text-xs sm:px-5">
|
|
<nav
|
|
class="left-sidebar__footer-nav flex min-w-0 flex-1 flex-wrap items-center gap-x-4 gap-y-1"
|
|
aria-label="하단 링크"
|
|
>
|
|
<NuxtLink
|
|
v-for="item in navigation.footer"
|
|
:key="item.id"
|
|
class="left-sidebar__footer-link site-interactive shrink-0"
|
|
:to="item.url"
|
|
>
|
|
{{ item.label }}
|
|
</NuxtLink>
|
|
</nav>
|
|
<button
|
|
class="left-sidebar__theme-dot site-sidebar-nav-row site-interactive grid h-7 w-7 shrink-0 place-items-center rounded-full"
|
|
type="button"
|
|
:aria-label="isDarkMode ? '라이트 모드로 전환' : '다크 모드로 전환'"
|
|
:title="isDarkMode ? '라이트 모드' : '다크 모드'"
|
|
@click="toggleTheme"
|
|
>
|
|
<span v-if="isDarkMode">☀</span>
|
|
<span v-else>☾</span>
|
|
</button>
|
|
</footer>
|
|
</aside>
|
|
</template>
|