게시물 상세 사이드바 목차·광고 재배치와 세션 확인 개선
게시물 상세에서는 오른쪽 사이드에 목차와 광고를 배치하고, 비로그인 세션 확인 시 콘솔 401 로그가 나지 않도록 정리했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -20,8 +20,8 @@ const adSlots = [
|
||||
},
|
||||
{
|
||||
key: 'adPostSidebarCode',
|
||||
label: '게시물 왼쪽 사이드',
|
||||
description: '게시물 상세 화면의 왼쪽 사이드바 하단에 표시됩니다.',
|
||||
label: '게시물 오른쪽 사이드',
|
||||
description: '게시물 상세 화면의 오른쪽 사이드바 TOC 아래에 표시됩니다.',
|
||||
placeholder: '<ins class="adsbygoogle" ...></ins>'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -113,7 +113,7 @@ const markAvatarBroken = (commentId) => {
|
||||
*/
|
||||
const fetchMember = async () => {
|
||||
try {
|
||||
member.value = await $fetch('/api/auth/me')
|
||||
member.value = await $fetch('/api/auth/me?optional=1')
|
||||
} catch {
|
||||
member.value = null
|
||||
}
|
||||
|
||||
@@ -7,13 +7,6 @@ defineProps({
|
||||
})
|
||||
|
||||
const { isDarkMode, toggleTheme } = useThemeMode()
|
||||
const route = useRoute()
|
||||
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
adPostSidebarCode: ''
|
||||
})
|
||||
})
|
||||
|
||||
const { data: tags } = await useFetch('/api/tags', {
|
||||
default: () => []
|
||||
@@ -29,7 +22,6 @@ const { data: navigation } = await useFetch('/api/navigation', {
|
||||
|
||||
/** 저자 영역 공개 여부 */
|
||||
const showAuthorSection = false
|
||||
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
|
||||
|
||||
const STORAGE_KEY = 'sori-primary-nav-expanded'
|
||||
|
||||
@@ -201,13 +193,6 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SiteAdSlot
|
||||
v-if="isPostDetailRoute"
|
||||
class="left-sidebar__post-ad-slot site-sidebar-section px-5 py-5 pr-3 max-lg:hidden xl:pl-0"
|
||||
:code="siteSettings?.adPostSidebarCode"
|
||||
location="post-sidebar-left"
|
||||
/>
|
||||
</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">
|
||||
|
||||
@@ -15,6 +15,8 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
logoText: '井',
|
||||
logoUrl: '',
|
||||
socialLinks: [],
|
||||
adSidebarCode: '',
|
||||
adPostSidebarCode: '',
|
||||
copyrightText: '©2026 sori.studio'
|
||||
})
|
||||
})
|
||||
@@ -39,7 +41,7 @@ const recommendedSites = computed(() => {
|
||||
return list.filter((x) => x?.isVisible !== false)
|
||||
})
|
||||
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
|
||||
const sidebarAdCode = computed(() => isPostDetailRoute.value ? '' : siteSettings.value?.adSidebarCode)
|
||||
const sidebarAdCode = computed(() => isPostDetailRoute.value ? siteSettings.value?.adPostSidebarCode : siteSettings.value?.adSidebarCode)
|
||||
const postTocItems = computed(() => Array.isArray(postToc.value) ? postToc.value : [])
|
||||
const followLinks = computed(() => getVisibleSocialLinks(siteSettings.value?.socialLinks || []))
|
||||
|
||||
@@ -100,20 +102,23 @@ const scrollActiveTocIntoView = (id) => {
|
||||
}
|
||||
|
||||
const nav = tocNavRef.value
|
||||
const scrollContainer = nav.closest('.site-sidebar-scroll')
|
||||
const link = nav.querySelector(`[data-toc-id="${id}"]`)
|
||||
|
||||
if (!(link instanceof HTMLElement)) {
|
||||
if (!(link instanceof HTMLElement) || !(scrollContainer instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
const navTop = nav.scrollTop
|
||||
const navBottom = navTop + nav.clientHeight
|
||||
const linkTop = link.offsetTop
|
||||
const containerRect = scrollContainer.getBoundingClientRect()
|
||||
const linkRect = link.getBoundingClientRect()
|
||||
const navTop = scrollContainer.scrollTop
|
||||
const navBottom = navTop + scrollContainer.clientHeight
|
||||
const linkTop = navTop + linkRect.top - containerRect.top
|
||||
const linkBottom = linkTop + link.offsetHeight
|
||||
const buffer = 24
|
||||
|
||||
if (linkTop < navTop + buffer) {
|
||||
nav.scrollTo({
|
||||
scrollContainer.scrollTo({
|
||||
top: Math.max(0, linkTop - buffer),
|
||||
behavior: 'smooth'
|
||||
})
|
||||
@@ -121,8 +126,8 @@ const scrollActiveTocIntoView = (id) => {
|
||||
}
|
||||
|
||||
if (linkBottom > navBottom - buffer) {
|
||||
nav.scrollTo({
|
||||
top: linkBottom - nav.clientHeight + buffer,
|
||||
scrollContainer.scrollTo({
|
||||
top: linkBottom - scrollContainer.clientHeight + buffer,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
@@ -240,7 +245,7 @@ watch([postTocItems, () => route.fullPath], async () => {
|
||||
<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 flex min-h-0 flex-1 flex-col">
|
||||
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-5 sm:pr-0 max-lg:px-0">
|
||||
<div v-if="!isPostDetailRoute" class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-5 sm: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 shrink-0 place-items-center overflow-hidden rounded-2xl text-2xl font-bold text-[var(--site-invert-text)]">
|
||||
<img
|
||||
@@ -262,7 +267,7 @@ watch([postTocItems, () => route.fullPath], async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="followLinks.length" class="right-sidebar__block site-sidebar-section py-5 sm:pl-5 pr-0">
|
||||
<div v-if="!isPostDetailRoute && followLinks.length" class="right-sidebar__block site-sidebar-section py-5 sm: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
|
||||
@@ -404,24 +409,29 @@ watch([postTocItems, () => route.fullPath], async () => {
|
||||
|
||||
<div
|
||||
v-if="isPostDetailRoute"
|
||||
class="right-sidebar__block right-sidebar__toc flex min-h-0 flex-1 flex-col py-5 pl-5 pr-0 max-lg:hidden"
|
||||
class="right-sidebar__block right-sidebar__toc py-5 pl-5 pr-0 max-lg:hidden"
|
||||
>
|
||||
<div class="right-sidebar__row flex shrink-0 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 min-h-0 flex-1 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">
|
||||
<nav ref="tocNavRef" class="right-sidebar__toc-nav mt-4 pr-2" aria-label="게시글 목차">
|
||||
<ul v-if="postTocItems.length" class="right-sidebar__toc-list flex list-none flex-col gap-2 border-l border-[var(--site-line)] p-0">
|
||||
<li
|
||||
v-for="item in postTocItems"
|
||||
:key="item.id"
|
||||
class="right-sidebar__toc-item relative flex h-6 items-center transition-colors"
|
||||
:class="activeTocId === item.id ? 'right-sidebar__toc-item--active' : ''"
|
||||
>
|
||||
<a
|
||||
class="right-sidebar__toc-link site-interactive block rounded-md py-1.5 pr-3 text-sm leading-snug transition-colors hover:text-[var(--site-accent)]"
|
||||
class="right-sidebar__toc-link site-interactive flex h-full items-center rounded-md 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,
|
||||
'bg-[var(--site-panel)] text-[var(--site-accent)] font-semibold': activeTocId === item.id,
|
||||
'text-[var(--site-text)]': activeTocId !== item.id,
|
||||
'pl-4 font-semibold': item.level === 1,
|
||||
'pl-7': item.level === 2,
|
||||
'pl-10': item.level === 3,
|
||||
'site-muted': item.level === 3 && activeTocId !== item.id
|
||||
}"
|
||||
:href="`#${item.id}`"
|
||||
@@ -491,7 +501,7 @@ watch([postTocItems, () => route.fullPath], async () => {
|
||||
<SiteAdSlot
|
||||
class="right-sidebar__ad-slot py-5 pl-5 pr-0 max-lg:px-0"
|
||||
:code="sidebarAdCode"
|
||||
location="sidebar"
|
||||
:location="isPostDetailRoute ? 'post-sidebar-right' : 'sidebar'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -511,4 +521,15 @@ watch([postTocItems, () => route.fullPath], async () => {
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.right-sidebar__toc-item--active::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
top: 50%;
|
||||
width: 3px;
|
||||
height: 24px;
|
||||
background: var(--site-accent);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -65,7 +65,7 @@ const toggleUserMenu = () => {
|
||||
*/
|
||||
const fetchMember = async () => {
|
||||
try {
|
||||
member.value = await $fetch('/api/auth/me')
|
||||
member.value = await $fetch('/api/auth/me?optional=1')
|
||||
} catch {
|
||||
member.value = null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.5.103
|
||||
|
||||
- 게시물 상세 목차의 활성 왼쪽 라인을 scoped CSS로 직접 그려 표시 안정성을 높였다.
|
||||
|
||||
## v1.5.102
|
||||
|
||||
- 게시물 상세 목차 항목의 높이와 세로 정렬을 맞추고, 활성 표시선이 브랜드 컬러로 다시 보이도록 조정했다.
|
||||
|
||||
## v1.5.101
|
||||
|
||||
- 게시물 상세 목차의 활성 표시선이 생겨도 항목 텍스트 시작점이 흔들리지 않게 조정했다.
|
||||
|
||||
## v1.5.100
|
||||
|
||||
- 게시물 상세 목차의 왼쪽 라인을 항목별 보더로 바꿔 활성 위치가 브랜드 컬러로 더 명확하게 보이게 했다.
|
||||
|
||||
## v1.5.99
|
||||
|
||||
- 게시물 상세 목차 라벨을 한글로 바꾸고, 활성 목차 항목을 브랜드 컬러로 더 또렷하게 표시했다.
|
||||
|
||||
## v1.5.98
|
||||
|
||||
- 게시물 상세 TOC가 별도 높이 제한 없이 전체 목차를 먼저 보여 주고, 게시물 사이드 광고는 그 아래에 이어지도록 바꿨다.
|
||||
|
||||
## v1.5.97
|
||||
|
||||
- 게시물 상세 오른쪽 사이드바에서 소개·Follow를 숨기고 TOC를 최상단으로 올렸다.
|
||||
- 게시물 사이드 광고는 왼쪽 사이드바에서 오른쪽 TOC 아래로 이동했다.
|
||||
- TOC에 세로 기준선과 활성 항목 표시선을 추가하고, 광고 영역을 위해 높이를 제한했다.
|
||||
|
||||
## v1.5.96
|
||||
|
||||
- 비로그인 상태로 공개 사이트를 볼 때 회원/관리자 세션 확인 요청의 401 콘솔 로그가 반복 표시되지 않게 했다.
|
||||
|
||||
## v1.5.95
|
||||
|
||||
- 게시물 상세의 왼쪽 사이드 광고와 오른쪽 TOC 영역에서 내용 없이 구분선만 보이는 상황을 줄였다.
|
||||
|
||||
## v1.5.94
|
||||
|
||||
- 게시물 상세 오른쪽 사이드바 TOC가 아래 빈 공간까지 사용해 긴 목차를 더 많이 볼 수 있다.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 v1.5.93에서 `npm run lint` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
> 로컬 기준 v1.5.103에서 `npm run lint` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
@@ -16,6 +16,47 @@
|
||||
|
||||
## 로컬 개발
|
||||
|
||||
### v1.5.103 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 목차 활성 항목의 왼쪽 브랜드 컬러 막대가 실제 화면에서 표시되는지 확인한다.
|
||||
|
||||
### v1.5.102 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 목차 항목이 24px 높이에서 세로 중앙 정렬되는지 확인한다.
|
||||
- 활성 목차 항목의 왼쪽 표시선이 브랜드 컬러로 정상 표시되는지 확인한다.
|
||||
|
||||
### v1.5.101 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 목차 활성 항목이 바뀌어도 항목 텍스트 시작점이 좌우로 흔들리지 않는지 확인한다.
|
||||
|
||||
### v1.5.100 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 목차에서 각 항목 왼쪽 라인이 이어져 보이고, 활성 항목 라인이 브랜드 컬러와 더 두꺼운 보더로 표시되는지 확인한다.
|
||||
|
||||
### v1.5.99 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 오른쪽 사이드바 목차 라벨이 `목차`로 표시되는지 확인한다.
|
||||
- 활성 목차 항목의 텍스트와 왼쪽 표시선에 브랜드 컬러가 적용되는지 확인한다.
|
||||
|
||||
### v1.5.98 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 오른쪽 사이드바에서 긴 TOC가 별도 내부 스크롤 없이 전체 목록으로 펼쳐지는지 확인한다.
|
||||
- 게시물 사이드 광고가 긴 TOC 아래에 이어서 표시되는지 확인한다.
|
||||
- 본문 스크롤 중 활성 TOC 항목이 오른쪽 사이드바 전체 스크롤 기준으로 따라오는지 확인한다.
|
||||
|
||||
### v1.5.97 참고
|
||||
|
||||
- 추가 DB 마이그레이션은 없다.
|
||||
- 게시물 상세 데스크톱 오른쪽 사이드바에서 블로그 소개·Follow가 숨겨지고 TOC가 최상단에 표시되는지 확인한다.
|
||||
- 게시물 사이드 광고 코드가 있으면 왼쪽 사이드바가 아니라 오른쪽 TOC 아래에 표시되는지 확인한다.
|
||||
- 긴 목차에서 세로 기준선과 활성 항목 표시선이 정상 표시되는지 확인한다.
|
||||
|
||||
### v1.5.93 참고
|
||||
|
||||
- DB 마이그레이션 `056_site_settings_post_tag_limit.sql` 적용이 필요하다.
|
||||
@@ -134,8 +175,8 @@
|
||||
### v1.5.74 참고
|
||||
|
||||
- DB 마이그레이션 `053_site_settings_post_sidebar_ad.sql` 적용 필요.
|
||||
- 사이트 설정 Ads에서 게시물 왼쪽 사이드 광고 코드를 저장한 뒤 게시물 상세 데스크톱 왼쪽 사이드바 하단에만 표시되는지 확인한다.
|
||||
- 게시물 상세 오른쪽 사이드바에서는 공통 오른쪽 사이드 광고가 표시되지 않는지 확인한다.
|
||||
- 사이트 설정 Ads에서 게시물 사이드 광고 코드를 저장한 뒤 게시물 상세 데스크톱 오른쪽 사이드바 TOC 아래에 표시되는지 확인한다.
|
||||
- 게시물 상세 오른쪽 사이드바에서는 일반 오른쪽 사이드 광고가 아니라 게시물 사이드 광고가 표시되는지 확인한다.
|
||||
- 긴 게시물에서 인아티클 광고가 본문 길이에 따라 0~2회로 제한되는지 확인한다.
|
||||
|
||||
### 필수 조건
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-06-12 v1.5.98 — 게시물 TOC는 내부 스크롤보다 자연스러운 흐름을 우선한다
|
||||
|
||||
오른쪽 사이드바의 TOC에 별도 최대 높이를 두면 광고 영역은 항상 보장되지만, 긴 목차가 작은 박스 안에서 따로 스크롤되어 문서 탐색과 광고 배치가 분리되어 보인다. 게시물 상세에서는 Tailwind 문서처럼 TOC 전체가 먼저 펼쳐지고 광고가 그 아래에 이어지는 흐름이 더 자연스러우므로, TOC 자체 높이 제한을 제거하고 활성 항목 보정은 오른쪽 사이드바 전체 스크롤 기준으로 처리한다.
|
||||
|
||||
## 2026-06-12 v1.5.97 — 게시물 상세 사이드바는 TOC와 광고를 오른쪽에 함께 둔다
|
||||
|
||||
게시물 상세에서는 오른쪽 사이드의 블로그 소개와 Follow보다 본문 탐색과 광고가 우선된다. TOC를 최상단으로 올리고 높이를 제한해 아래 광고 영역을 확보하면 목차와 광고가 같은 문맥 안에서 보이며, 왼쪽 사이드바는 기존 네비게이션 역할에 집중할 수 있다. TOC에는 세로 기준선을 추가해 제목 레벨 들여쓰기와 활성 위치가 더 명확하게 보이도록 한다.
|
||||
|
||||
## 2026-06-12 v1.5.96 — 공개 화면 세션 확인은 선택적 조회를 사용한다
|
||||
|
||||
공개 화면의 헤더, 댓글, 게시물 상세 편집 버튼은 로그인 여부를 확인해야 하지만 비로그인 방문도 정상 사용 흐름이다. 기존처럼 `/api/auth/me`와 `/admin/api/auth/me`가 401을 반환하면 UI는 정상이어도 브라우저 콘솔에 오류가 반복 표시된다. 따라서 `optional=1` 쿼리에서는 비로그인 상태를 `null`로 반환하고, 관리자 라우트 가드와 보호 API가 사용하는 기본 호출은 401을 유지한다.
|
||||
|
||||
## 2026-06-09 v1.5.93 — 태그 제한은 사이트 설정으로 관리하고 표는 표준 마크다운부터 지원한다
|
||||
|
||||
게시물 태그는 목록 UI와 공개 카드에서 스캔성을 좌우하므로 기본 최대 5개로 제한한다. 운영자가 블로그 성격에 맞게 조절할 수 있도록 `site_settings.post_tag_limit`으로 저장하되, 과도한 태그 입력을 막기 위해 허용 범위는 1~10개로 제한하고 글쓰기 UI와 저장 API 양쪽에서 검증한다. 표 기능은 셀 단위 라이브 편집까지 한 번에 확장하면 에디터 안정성에 영향이 크므로, 먼저 표준 마크다운 표 삽입과 렌더링을 지원해 콘텐츠 작성에 필요한 기본 기능을 제공한다.
|
||||
|
||||
@@ -86,10 +86,10 @@
|
||||
| components/site/SiteAnnouncementBar.vue | 공개 사이트 상단 어나운스 배너(문구·선택 링크·hex 배경색·텍스트 정렬·닫기) |
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 사이트 이름 텍스트 브랜드, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운(아바타 없거나 비로그인 시 사람 아이콘), `/`·`SiteSearchModal` |
|
||||
| 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/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(모바일 숨김, 스크롤 위치 기반 활성 표시·내부 자동 스크롤), 설정 기반 사이트 로고 48px 정사각형 고정, SNS Follow(프리셋·사용자 SVG, 16px 아이콘 중앙 정렬)·구독 폼, About 영역은 비공개, `lg+` 스티키 |
|
||||
| components/site/SiteAdSlot.vue | 사이트 설정 Ads HTML 코드 렌더링, 메인 피드·메인 인피드·오른쪽 사이드·게시물 왼쪽 사이드·게시물 본문 상단·인아티클·하단 광고 슬롯 |
|
||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, 일반 화면은 공개 `GET /api/navigation`의 `recommended` 카드 목록(대체 텍스트·썸네일 우선, 외부 URL은 Google 파비콘 fallback), 게시글 상세 데스크톱은 블로그 소개·Follow·Recommended 대신 최상단 H1~H3 TOC와 TOC 아래 게시물 사이드 광고(모바일 숨김, 스크롤 위치 기반 활성 표시·내부 자동 스크롤, 세로 기준선), 설정 기반 사이트 로고 48px 정사각형 고정, SNS Follow(프리셋·사용자 SVG, 16px 아이콘 중앙 정렬)·구독 폼, About 영역은 비공개, `lg+` 스티키 |
|
||||
| components/site/SiteAdSlot.vue | 사이트 설정 Ads HTML 코드 렌더링, 메인 피드·메인 인피드·오른쪽 사이드·게시물 오른쪽 사이드·게시물 본문 상단·인아티클·하단 광고 슬롯 |
|
||||
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
||||
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
|
||||
@@ -103,7 +103,7 @@
|
||||
|------|-----------|
|
||||
| components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG: 브랜드·사이트 정보·POST·사이트 코드·Ads·SNS·게시물보내기·가져오기 등·미구현 placeholder) |
|
||||
| components/admin/AdminSiteCodeSettingsCard.vue | 관리자 사이트 설정의 ads.txt·공통 헤더 코드·공통 푸터 코드 카드 |
|
||||
| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 왼쪽 사이드·게시물 본문 상단·인아티클·하단) |
|
||||
| components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 오른쪽 사이드·게시물 본문 상단·인아티클·하단) |
|
||||
| components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 |
|
||||
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
- 표준 마크다운 표(`| 헤더 | ... |`, `| --- | ... |`)는 본문에서 가로 스크롤 가능한 HTML table로 렌더링한다. 정렬 구분선(`:---`, `:---:`, `---:`)은 각각 좌/중앙/우 정렬로 반영한다.
|
||||
- 인용문(`>`)은 왼쪽 세로 막대형 기본 스타일로 표시한다. 기본 인용 텍스트는 라이트·다크 모드 모두 사이트 본문 텍스트 색상(`--site-text`)을 따른다. 첫 줄 옵션 `> [!bg=blue]` 또는 `> {bg=blue}`는 인용 막대 색상으로 반영하며, 지원 값은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이다.
|
||||
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정을 지원한다. 콜아웃은 제목·아이콘 표시 여부·아이콘·배경색, 코드 블록은 언어·줄번호 표시 여부, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다. 콜아웃 아이콘은 라이브·공개 화면 모두 왼쪽 상단에 배치하고, 아이콘·제목 헤더 아래에 본문을 줄바꿈해 표시한다. 아이콘 미사용 시 자리 표시자를 남기지 않는다. 라이브 문단에서는 `>` 입력으로 인용, ``` Enter로 코드 블록, `!!!` Enter로 콜아웃을 만들 수 있고, `/표` 또는 `/table` 슬래시 명령으로 기본 3열 표 마크다운을 삽입한다. 소스·라이브 모드 모두 `Cmd/Ctrl+K`로 링크 마크다운을 삽입한다. 라이브 코드·인용·콜아웃·토글 블록은 맨 위/맨 아래 방향키로 외부 기본 문단을 만들며 빠져나올 수 있고, 인용 첫 글자 앞 Backspace는 일반 문단으로 되돌린다. 한글 등 IME 조합 입력 중에는 줄바꿈 직후 블록 판별이 일시적으로 비어도 마지막 블록 컨텍스트를 유지해 설정 패널이 닫히지 않게 한다.
|
||||
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 추천 사이트 대신 본문 H1~H3 제목 기반 TOC를 표시한다. TOC 영역은 프로필·Follow 아래부터 저작권 푸터 위까지 남는 높이를 사용하며, 목차가 길면 TOC 내부에서만 스크롤한다. TOC 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 스크롤 중에는 현재 제목에 해당하는 TOC 항목을 강조하고, 목차 항목이 많으면 TOC 내부 스크롤이 활성 항목을 따라간다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 TOC를 숨긴다. 게시물 상세에서는 오른쪽 사이드바의 공통 광고를 숨기고, 게시물 왼쪽 사이드 광고 코드가 있을 때 데스크톱 왼쪽 사이드바 하단에 광고 슬롯을 표시한다.
|
||||
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 블로그 소개·Follow·추천 사이트 대신 본문 H1~H3 제목 기반 목차를 최상단에 표시한다. 목차는 별도 최대 높이와 내부 스크롤을 두지 않고 전체 목록이 먼저 펼쳐지며, 왼쪽 기준선과 브랜드 컬러 활성 항목 표시선을 둔다. 목차 항목은 24px 높이와 세로 중앙 정렬을 사용하며, 활성 표시선은 항목의 가상 요소 배경 막대로 그려 텍스트 시작점이 밀리지 않게 한다. 목차 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 스크롤 중에는 현재 제목에 해당하는 목차 항목을 강조하고, 목차 항목이 많으면 오른쪽 사이드바 스크롤이 활성 항목을 따라간다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 목차를 숨긴다. 게시물 상세에서는 일반 오른쪽 사이드 광고 대신 게시물 사이드 광고 코드를 목차 아래에 표시하고, 왼쪽 사이드바에는 게시물 광고를 표시하지 않는다. 게시물 상세 목차와 게시물 사이드 광고 슬롯은 하단 구분선을 표시하지 않는다.
|
||||
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
|
||||
- 로그인 회원 ID가 게시물 `author_id`와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시 `/admin/posts/:id` 편집 화면을 새 탭으로 연다.
|
||||
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
|
||||
@@ -133,7 +133,7 @@
|
||||
|
||||
### 사이트 광고 슬롯
|
||||
|
||||
- 관리자 사이트 설정의 Ads 카드는 `adHomeFeedCode`, `adHomeInfeedCode`, `adSidebarCode`, `adPostSidebarCode`, `adPostTopCode`, `adPostInArticleCode`, `adPostBottomCode` 일곱 위치의 HTML 코드를 저장한다.
|
||||
- 관리자 사이트 설정의 Ads 카드는 `adHomeFeedCode`, `adHomeInfeedCode`, `adSidebarCode`, `adPostSidebarCode`, `adPostTopCode`, `adPostInArticleCode`, `adPostBottomCode` 일곱 위치의 HTML 코드를 저장한다. `adPostSidebarCode`는 게시물 상세 오른쪽 사이드바의 TOC 아래 광고로 사용한다.
|
||||
- 각 값은 관리자만 입력하는 신뢰 콘텐츠를 전제로 하며, 애드센스에서 제공하는 반응형 또는 고정 크기 HTML 코드를 그대로 붙여 넣는다.
|
||||
- 공개 화면은 `SiteAdSlot` 컴포넌트로 광고 슬롯을 렌더링한다. 값이 비어 있으면 DOM을 만들지 않고, 값이 있으면 클라이언트에서 HTML을 삽입한 뒤 내부 script를 실행 가능한 노드로 교체해 Nuxt 클라이언트 라우팅 후에도 광고 코드가 동작하게 한다.
|
||||
- 공통 헤더·푸터 코드와 ads.txt는 기존 사이트 코드 카드에서 관리하고, 화면 위치가 필요한 광고 단위는 Ads 카드에서 관리한다.
|
||||
@@ -541,7 +541,7 @@ components/content/
|
||||
- `POST /api/auth/email-otp/request` - 본문: `email`, `purpose`(`"signup"` | `"password_reset"`). Resend로 6자리 OTP 메일을 보낸다. 미설정 시 503. `signup`은 이미 가입된 이메일이면 409, 최초 관리자 단계에서는 400. `password_reset`은 존재하지 않는 이메일도 동일한 성공 메시지를 반환하며(이메일 미발송), 요청 빈도 제한을 위해 DB에 더미 챌린지를 남길 수 있다. 55초 쿨다운·시간당 5회 한도. 실제 메일 발송이 실패하면 방금 생성한 챌린지는 즉시 삭제한다. 발송 성공 후 같은 이메일·용도의 이전 pending 챌린지는 무효화한다. DB 테이블 `email_otp_challenges`(마이그레이션 `018_email_otp_challenges.sql`) 필요.
|
||||
- `POST /api/auth/password-reset/confirm` - 본문: `email`, `code`(6자리), `newPassword`(8~32자). `password_reset` OTP 검증·소진 후 해당 이메일 회원 비밀번호를 갱신한다.
|
||||
- `POST /api/auth/login` - 회원 로그인
|
||||
- `GET /api/auth/me` - 현재 회원 세션 조회(`id`, `username`, `email`, `avatarUrl`, `isAdmin`, `role`)
|
||||
- `GET /api/auth/me` - 현재 회원 세션 조회(`id`, `username`, `email`, `avatarUrl`, `isAdmin`, `role`). `optional=1` 쿼리를 붙이면 비로그인 상태에서 401 대신 `null`을 반환한다.
|
||||
- `POST /api/auth/logout` - 회원 로그아웃
|
||||
- `GET /api/auth/profile` - 회원 설정 조회
|
||||
- `PUT /api/auth/profile` - 회원 프로필 수정(닉네임, `avatarUrl`). 이전 값이 `/uploads/members/avatars/` URL이고 새 값과 달라지면 `removeManagedAvatarAsset`으로 **메타만** 끊고 디스크 파일은 유지한다(`DELETE /api/auth/avatar`와 동일한 자산 정리 규칙).
|
||||
@@ -565,7 +565,7 @@ components/content/
|
||||
|
||||
- `POST /admin/api/auth/login` - 로그인
|
||||
- `POST /admin/api/auth/logout` - 로그아웃
|
||||
- `GET /admin/api/auth/me` - 현재 관리자 세션 조회
|
||||
- `GET /admin/api/auth/me` - 현재 관리자 세션 조회. `optional=1` 쿼리를 붙이면 비로그인 상태에서 401 대신 `null`을 반환하며, 관리자 라우트 보호용 기본 호출은 401을 유지한다.
|
||||
- `GET /admin/api/analytics/summary?days=30` - 통계 요약(오늘/7일 방문, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, 일자별 `trends`). `days`는 대시보드에서 7/30/90/180/365로 전환한다.
|
||||
- `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·최근 30일 월간 조회·작성일·읽음·평균 체류·스크롤 구간)
|
||||
- `GET /admin/api/analytics/pages?days=30&limit=5` - 기간 내 인기 페이지(조회·방문자·평균 체류·스크롤 구간)
|
||||
|
||||
@@ -1,5 +1,49 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.5.103
|
||||
|
||||
- 게시물 상세 목차: 활성 표시선을 Tailwind 가상 요소 유틸 대신 scoped CSS `::before`로 직접 그리도록 수정.
|
||||
|
||||
## v1.5.102
|
||||
|
||||
- 게시물 상세 목차: 활성 표시선을 `border` 대신 가상 요소 배경 막대로 변경해 브랜드 컬러 반응을 복구.
|
||||
- 게시물 상세 목차: 각 항목 높이를 24px로 고정하고 텍스트를 세로 중앙 정렬하도록 수정.
|
||||
|
||||
## v1.5.101
|
||||
|
||||
- 게시물 상세 목차: 활성 항목 표시선을 가상 요소로 분리해 브랜드 컬러 라인이 생겨도 텍스트 시작점이 밀리지 않도록 수정.
|
||||
|
||||
## v1.5.100
|
||||
|
||||
- 게시물 상세 목차: 전체 `nav` 보더 대신 항목별 `li` 보더로 변경해 활성 항목 왼쪽 라인이 브랜드 컬러로 직접 강조되도록 수정.
|
||||
|
||||
## v1.5.99
|
||||
|
||||
- 게시물 상세 TOC: 섹션 라벨을 `목차`로 변경.
|
||||
- 게시물 상세 TOC: 활성 항목의 왼쪽 표시선과 텍스트에 브랜드 컬러를 적용하고 표시선을 더 눈에 띄게 조정.
|
||||
|
||||
## v1.5.98
|
||||
|
||||
- 게시물 상세 TOC: 목차 자체 최대 높이와 내부 스크롤을 제거해 목차 전체가 먼저 펼쳐지고 광고가 그 아래에 이어지도록 수정.
|
||||
- 게시물 상세 TOC: 활성 항목 자동 보정을 오른쪽 사이드바 전체 스크롤 기준으로 변경.
|
||||
|
||||
## v1.5.97
|
||||
|
||||
- 게시물 상세 오른쪽 사이드바: 블로그 소개·Follow 섹션을 숨기고 TOC를 최상단에 배치하도록 수정.
|
||||
- 게시물 상세 광고: 왼쪽 사이드바에서 제거하고 오른쪽 TOC 아래에 표시하도록 이동.
|
||||
- 게시물 상세 TOC: 광고 영역을 확보하도록 최대 높이를 제한하고, 목차 왼쪽 세로 기준선과 활성 항목 표시선을 추가.
|
||||
- 관리자 Ads 설정: 게시물 사이드 광고 라벨과 설명을 오른쪽 사이드 기준으로 수정.
|
||||
|
||||
## v1.5.96
|
||||
|
||||
- 공개 화면 회원 세션 확인: 비로그인 방문 시 콘솔에 401 오류가 반복 표시되지 않도록 선택적 조회로 수정.
|
||||
- 게시물 상세 관리자 세션 확인: 편집 버튼 표시용 확인은 선택적 조회를 사용하되 관리자 라우트 보호용 401 동작은 유지.
|
||||
|
||||
## v1.5.95
|
||||
|
||||
- 게시물 상세 왼쪽 사이드 광고 슬롯: 광고 미노출 시 하단 구분선만 남지 않도록 정리.
|
||||
- 게시물 상세 오른쪽 TOC: 하단 구분선 클래스가 없는 상태임을 재확인.
|
||||
|
||||
## v1.5.94
|
||||
|
||||
- 게시물 상세 오른쪽 사이드바: TOC 영역이 고정 최대 높이 대신 프로필·Follow 아래 남는 높이를 사용하도록 수정.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.5.94",
|
||||
"version": "1.5.103",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -134,13 +134,13 @@ const shareLinks = computed(() => [
|
||||
*/
|
||||
const fetchCurrentViewer = async () => {
|
||||
try {
|
||||
currentMember.value = await $fetch('/api/auth/me')
|
||||
currentMember.value = await $fetch('/api/auth/me?optional=1')
|
||||
} catch {
|
||||
currentMember.value = null
|
||||
}
|
||||
|
||||
try {
|
||||
currentAdmin.value = await $fetch('/admin/api/auth/me')
|
||||
currentAdmin.value = await $fetch('/admin/api/auth/me?optional=1')
|
||||
} catch {
|
||||
currentAdmin.value = null
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { getUserById } from '../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../utils/member-auth'
|
||||
import { getMemberSession, requireMemberSession } from '../../utils/member-auth'
|
||||
|
||||
/**
|
||||
* 회원 세션 조회 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ id: string, username: string, email: string, avatarUrl: string, isAdmin: boolean, role: string }>} 회원 정보
|
||||
* @returns {Promise<{ id: string, username: string, email: string, avatarUrl: string, isAdmin: boolean, role: string } | null>} 회원 정보
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const isOptional = getQuery(event).optional === '1'
|
||||
const session = isOptional ? getMemberSession(event) : requireMemberSession(event)
|
||||
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
|
||||
const user = await getUserById(session.userId)
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { getAdminSession, requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { getMemberForAdmin } from '../../../../repositories/member-repository'
|
||||
|
||||
/**
|
||||
* 관리자 세션 조회 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ userId: string, email: string, role: 'admin', roleCode: string, roleLabel: string }>} 관리자 세션 정보
|
||||
* @returns {Promise<{ userId: string, email: string, role: 'admin', roleCode: string, roleLabel: string } | null>} 관리자 세션 정보
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireAdminSession(event)
|
||||
const isOptional = getQuery(event).optional === '1'
|
||||
const session = isOptional ? getAdminSession(event) : requireAdminSession(event)
|
||||
|
||||
if (!session) {
|
||||
return null
|
||||
}
|
||||
|
||||
const member = await getMemberForAdmin(session.userId)
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user