추천 사이트 위치 조정

This commit is contained in:
2026-06-17 10:36:57 +09:00
parent 54d32af617
commit d98114003d
10 changed files with 107 additions and 94 deletions

View File

@@ -1,4 +1,6 @@
<script setup>
import { getExternalFaviconUrl } from '~/lib/external-favicon-url.js'
defineProps({
menuOpen: {
type: Boolean,
@@ -25,6 +27,44 @@ 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()
}
/**
* 추천 사이트 이미지 URL을 반환한다.
* @param {Object} item - 추천 사이트 항목
* @returns {string} 이미지 URL
*/
const getRecommendedImageUrl = (item) => {
const thumbnailUrl = String(item?.thumbnailUrl || '').trim()
return thumbnailUrl || getExternalFaviconUrl(item?.url, 64)
}
/**
* 트리에서 하위가 있는 노드 id를 모은다.
* @param {Array<Object>} list - 노드 목록
@@ -177,6 +217,45 @@ onMounted(() => {
</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"
>
<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>

View File

@@ -1,5 +1,4 @@
<script setup>
import { getExternalFaviconUrl } from '~/lib/external-favicon-url.js'
import { getVisibleSocialLinks } from '~/lib/social-links.js'
const route = useRoute()
@@ -21,25 +20,6 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
})
})
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 sidebarAdCode = computed(() => isPostDetailRoute.value ? siteSettings.value?.adPostSidebarCode : siteSettings.value?.adSidebarCode)
const postTocItems = computed(() => Array.isArray(postToc.value) ? postToc.value : [])
@@ -155,32 +135,6 @@ const updateActiveToc = () => {
})
}
/**
* 새 탭으로 열 외부 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 - 클릭 이벤트
@@ -449,46 +403,6 @@ watch([postTocItems, () => route.fullPath], async () => {
</nav>
</div>
<div
v-else-if="recommendedSites.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">
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 }}

View File

@@ -1,5 +1,10 @@
# 업데이트 요약
## v1.5.109
- 추천 사이트 목록을 오른쪽 사이드바에서 왼쪽 사이드바의 태그 아래로 옮겼다.
- 오른쪽 사이드바는 광고와 게시물 목차 공간을 더 안정적으로 쓰도록 정리했다.
## v1.5.108
- 미디어 관리의 전체/미사용 필터를 모든 미디어 탭에서 바로 보이게 정리했다.

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.5.108에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.5.109에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형
@@ -16,6 +16,12 @@
## 로컬 개발
### v1.5.109 참고
- 추가 DB 마이그레이션은 없다.
- 공개 메인 화면에서 추천 사이트 Recommended 목록이 왼쪽 사이드바의 Categories 아래에 표시되는지 확인한다.
- 공개 일반 화면의 오른쪽 사이드바에서 Recommended 영역이 사라지고 오른쪽 사이드 광고 슬롯이 바로 이어지는지 확인한다.
### v1.5.108 참고
- 추가 DB 마이그레이션은 없다.

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-06-17 v1.5.109 — 추천 사이트는 태그 아래 탐색 영역에 둔다
추천 사이트 목록은 시간이 지날수록 늘어나는 탐색 보조 정보이므로 오른쪽 사이드바에 두면 광고 슬롯 높이와 게시물 상세 목차 흐름을 예측하기 어려워진다. 추천 사이트를 왼쪽 사이드바의 Categories 아래로 옮겨 태그와 같은 탐색 맥락에 묶고, 오른쪽 사이드바는 일반 화면 광고와 게시물 상세 TOC·광고에 집중하도록 분리한다.
## 2026-06-17 v1.5.107 — 업로드 파일명은 Unicode 문자 범위로 보존한다
이미 한글 파일명을 보존하고 있으므로 일본어 파일명만 `image.jpg`로 떨어지는 것은 사용자 기대와 맞지 않는다. 파일명 정리는 영문·한글 고정 목록이 아니라 Unicode 문자·숫자 범위를 보존하고, 경로 구분자와 특수문자만 하이픈으로 치환하는 방식으로 확장한다. 이렇게 하면 일본어와 한자를 포함한 파일명도 유지하면서 경로 안전성은 기존처럼 확보할 수 있다.

View File

@@ -88,9 +88,9 @@
| 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`), 태그 Categories 아래 공개 `GET /api/navigation`의 `recommended` 카드 목록(대체 텍스트·썸네일 우선, 외부 URL은 Google 파비콘 fallback), 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·Recommended 대신 최상단 H1~H3 TOC와 TOC 아래 게시물 사이드 광고(모바일 숨김, 스크롤 위치 기반 활성 표시·내부 자동 스크롤, 세로 기준선), 설정 기반 사이트 로고 48px 정사각형 고정, SNS Follow(프리셋·사용자 SVG, 16px 아이콘 중앙 정렬)·구독 폼, About 영역은 비공개, `lg+` 스티키 |
| components/site/RightSidebar.vue | 오른쪽 사이드바, 일반 화면은 설정 기반 사이트 로고 48px 정사각형 고정·SNS Follow(프리셋·사용자 SVG, 16px 아이콘 중앙 정렬)·오른쪽 사이드 광고를 표시, 게시글 상세 데스크톱은 블로그 소개·Follow 대신 최상단 H1~H3 TOC와 TOC 아래 게시물 사이드 광고(모바일 숨김, 스크롤 위치 기반 활성 표시·내부 자동 스크롤, 세로 기준선), About 영역은 비공개, `lg+` 스티키 |
| components/site/SiteAdSlot.vue | 사이트 설정 Ads HTML 코드 렌더링, 메인 피드·메인 인피드·오른쪽 사이드·게시물 오른쪽 사이드·게시물 본문 상단·인아티클·하단 광고 슬롯 |
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/HomeHero.vue | 홈 상단 720px 커버 배너, 라이트·다크 테마별 이미지 교체, 왼쪽 하단 오버레이 제목·본문 |

View File

@@ -84,7 +84,7 @@
- 표준 마크다운 표(`| 헤더 | ... |`, `| --- | ... |`)는 본문에서 가로 스크롤 가능한 HTML table로 렌더링한다. 정렬 구분선(`:---`, `:---:`, `---:`)은 각각 좌/중앙/우 정렬로 반영한다.
- 인용문(`>`)은 왼쪽 세로 막대형 기본 스타일로 표시한다. 기본 인용 텍스트는 라이트·다크 모드 모두 사이트 본문 텍스트 색상(`--site-text`)을 따른다. 첫 줄 옵션 `> [!bg=blue]` 또는 `> {bg=blue}`는 인용 막대 색상으로 반영하며, 지원 값은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이다.
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정과 선택 텍스트 색상 설정을 지원한다. 콜아웃은 제목·아이콘 표시 여부·아이콘·배경색, 코드 블록은 언어·줄번호 표시 여부, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다. 일반 텍스트 범위를 선택하면 텍스트 색상 패널이 열리고 회색·파랑·초록·노랑·빨강·보라 인라인 색상을 적용한다. 콜아웃 아이콘은 라이브·공개 화면 모두 왼쪽 상단에 배치하고, 아이콘·제목 헤더 아래에 본문을 줄바꿈해 표시한다. 아이콘 미사용 시 자리 표시자를 남기지 않는다. 라이브 문단에서는 `>` 입력으로 인용, ``` Enter로 코드 블록, `!!!` Enter로 콜아웃을 만들 수 있고, `/표` 또는 `/table` 슬래시 명령으로 기본 3열 표 마크다운을 삽입한다. `/html` 또는 `/iframe` 슬래시 명령은 외부 URL 또는 iframe HTML을 넣는 HTML 블록을 찾는다. 소스·라이브 모드 모두 `Cmd/Ctrl+K`로 링크 마크다운을 삽입한다. 에디터 되돌리기·다시 실행은 본문 값과 함께 선택 위치를 복원해 줄 앞 이동을 줄인다. 라이브 코드·인용·콜아웃·토글 블록은 맨 위/맨 아래 방향키로 외부 기본 문단을 만들며 빠져나올 수 있고, 인용 첫 글자 앞 Backspace는 일반 문단으로 되돌린다. 한글 등 IME 조합 입력 중에는 줄바꿈 직후 블록 판별이 일시적으로 비어도 마지막 블록 컨텍스트를 유지해 설정 패널이 닫히지 않게 한다.
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 블로그 소개·Follow·추천 사이트 대신 본문 H1~H3 제목 기반 목차를 최상단에 표시한다. 목차는 별도 최대 높이와 내부 스크롤을 두지 않고 전체 목록이 먼저 펼쳐지며, 왼쪽 기준선과 브랜드 컬러 활성 항목 표시선을 둔다. 목차 항목은 최소 24px 높이와 세로 중앙 정렬을 사용하며, 2줄 이상 제목에서는 항목 높이에 맞춰 활성 표시선도 함께 늘어난다. 활성 표시선은 항목의 가상 요소 배경 막대로 그려 텍스트 시작점이 밀리지 않게 한다. 목차 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 스크롤 중에는 현재 제목에 해당하는 목차 항목을 강조하고, 목차 항목이 많으면 오른쪽 사이드바 스크롤이 활성 항목을 따라간다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 목차를 숨긴다. 게시물 상세에서는 일반 오른쪽 사이드 광고 대신 게시물 사이드 광고 코드를 목차 아래에 표시하고, 왼쪽 사이드바에는 게시물 광고를 표시하지 않는다. 게시물 상세 목차와 게시물 사이드 광고 슬롯은 하단 구분선을 표시하지 않는다.
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 블로그 소개·Follow 대신 본문 H1~H3 제목 기반 목차를 최상단에 표시한다. 목차는 별도 최대 높이와 내부 스크롤을 두지 않고 전체 목록이 먼저 펼쳐지며, 왼쪽 기준선과 브랜드 컬러 활성 항목 표시선을 둔다. 목차 항목은 최소 24px 높이와 세로 중앙 정렬을 사용하며, 2줄 이상 제목에서는 항목 높이에 맞춰 활성 표시선도 함께 늘어난다. 활성 표시선은 항목의 가상 요소 배경 막대로 그려 텍스트 시작점이 밀리지 않게 한다. 목차 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 스크롤 중에는 현재 제목에 해당하는 목차 항목을 강조하고, 목차 항목이 많으면 오른쪽 사이드바 스크롤이 활성 항목을 따라간다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 목차를 숨긴다. 게시물 상세에서는 일반 오른쪽 사이드 광고 대신 게시물 사이드 광고 코드를 목차 아래에 표시하고, 왼쪽 사이드바에는 게시물 광고를 표시하지 않는다. 게시물 상세 목차와 게시물 사이드 광고 슬롯은 하단 구분선을 표시하지 않는다.
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
- 로그인 회원 ID가 게시물 `author_id`와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시 `/admin/posts/:id` 편집 화면을 새 탭으로 연다.
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
@@ -785,7 +785,7 @@ components/content/
- `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**. **상단은 루트 직속 자식만** 트리에 붙이며(부모의 부모가 있는 행은 자식으로 무시), `footer`·`recommended`는 **평면** 배열(`parent_id` 없음)이다. `recommended`는 `id`, `label`, `url`, `descriptionText`, `thumbnailUrl`, `isVisible`을 내려준다.
- `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 `primary`·`footer`·`recommended` 각각 위치별 트리(또는 평면 루트) DFS 순으로 다시 부여한다. `parent_id`는 `primary`에서만 허용되며 **상단은 한 단계(루트→자식)만** 허용한다. `footer`·`recommended` 항목은 항상 루트다. `is_visible`·`is_folder`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다.
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다.
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션**·**추천 사이트** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 상단은 `buildNavigationEditorTree` 결과를 `flattenNavigationEditorWrappers`로 한 테이블에 평면 표시하고, **같은 행의 위 1/3·가운데·아래 1/3**에 드롭하면 각각 대상 **앞**(동일 부모)·**하위**(대상의 자식, **루트 행에만**)·**뒤**(동일 부모)로 이동한다. **상단은 루트→자식 한 단계만** 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 **형제 앞·뒤**를 파란 가로 끝선·연한 파란 배경, **하위 편입**을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에 `앞에 끼움` / `뒤에 끼움` / `하위로 넣기` 짧은 문구를 덧붙인다. 개요 열 표기는 `1`, `2.1`, `2.2`, `3`처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼 `padding-left`로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 하단·추천 사이트는 평면 목록만 드래그 정렬한다. 추천 사이트는 공개 홈 **오른쪽 사이드바** Recommended 영역에 카드로 노출되며, 대체 텍스트가 있으면 URL 대신 대체 텍스트를 표시하고 썸네일 URL이 있으면 Google Favicon 프록시 대신 썸네일을 표시한다. 썸네일이 없고 `https://` URL이면 클라이언트에서 호스트를 뽑아 Google Favicon 프록시(`https://www.google.com/s2/favicons?domain=…`) 이미지로 표시할 수 있다(내부 경로만 있으면 아이콘 생략).
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션**·**추천 사이트** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 상단은 `buildNavigationEditorTree` 결과를 `flattenNavigationEditorWrappers`로 한 테이블에 평면 표시하고, **같은 행의 위 1/3·가운데·아래 1/3**에 드롭하면 각각 대상 **앞**(동일 부모)·**하위**(대상의 자식, **루트 행에만**)·**뒤**(동일 부모)로 이동한다. **상단은 루트→자식 한 단계만** 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 **형제 앞·뒤**를 파란 가로 끝선·연한 파란 배경, **하위 편입**을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에 `앞에 끼움` / `뒤에 끼움` / `하위로 넣기` 짧은 문구를 덧붙인다. 개요 열 표기는 `1`, `2.1`, `2.2`, `3`처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼 `padding-left`로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 하단·추천 사이트는 평면 목록만 드래그 정렬한다. 추천 사이트는 공개 화면 **왼쪽 사이드바** Categories 아래 Recommended 영역에 카드로 노출되며, 대체 텍스트가 있으면 URL 대신 대체 텍스트를 표시하고 썸네일 URL이 있으면 Google Favicon 프록시 대신 썸네일을 표시한다. 썸네일이 없고 `https://` URL이면 클라이언트에서 호스트를 뽑아 Google Favicon 프록시(`https://www.google.com/s2/favicons?domain=…`) 이미지로 표시할 수 있다(내부 경로만 있으면 아이콘 생략). 오른쪽 사이드바는 추천 사이트를 렌더하지 않고 광고와 게시물 목차 영역에 집중한다.
- `parent_id` / `is_folder` 컬럼이 DB에 없을 때 저장은 실패한다. `npm run db:migrate:dev`로 `017_navigation_hierarchy.sql`을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다).
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 세로 데코·chevron) 클릭**으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 **기본 세로 막대(`--site-line`)**, **호버 시에만** 리프와 동일하게 **작은 원형**으로 전환한다. **내부 경로**(`/`로 시작, `//`·`http(s)` 제외)이고 현재 `route.path`와 정규화한 경로가 같으면 장식 색을 **`--site-accent`(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 `localStorage` 키 `sori-primary-nav-expanded`에 저장된다. 상단·리프 링크·부모 버튼 행은 **`w-full`**로 `site-sidebar-nav-row` 호버 배경이 가로 전체를 쓴다(라이트 `#F7F4EF`, 다크는 `site-panel-hover`와 동일한 `color-mix` 패턴).
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.

View File

@@ -1,5 +1,10 @@
# 업데이트 이력
## v1.5.109
- 공개 사이드바: 추천 사이트 Recommended 영역을 오른쪽 사이드바에서 왼쪽 사이드바 태그 아래로 이동.
- 공개 오른쪽 사이드바: 일반 화면에서 추천 사이트 목록 조회와 렌더링을 제거해 광고 슬롯 공간을 더 안정적으로 확보하도록 정리.
## v1.5.108
- 미디어 관리: 전체/미사용 필터를 미디어 라이브러리·카드 썸네일·프로필 이미지 탭 공통 배지형 필터로 표시하도록 수정.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.5.106",
"version": "1.5.109",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.5.106",
"version": "1.5.109",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.5.108",
"version": "1.5.109",
"private": true,
"type": "module",
"imports": {