메뉴 관리 개편, 추천 사이트·1뎁스 네비, 우측 Recommended 연동(v1.1.13)

상단 네비는 평면 테이블·드래그로 편집하고 한 단계 하위만 허용한다.
추천 사이트 탭·location recommended·공개 API와 우측 사이드 카드·파비콘 URL 유틸을 추가한다.
문서·배포 마이그레이션 안내·관리자 레이아웃·설정 화면 등 누적 변경을 반영한다.
This commit is contained in:
2026-05-15 14:20:27 +09:00
parent 2768975752
commit ca1e17890b
24 changed files with 1509 additions and 499 deletions

View File

@@ -76,6 +76,13 @@
overflow: hidden;
background: #ffffff;
}
html.admin-settings-document,
body.admin-settings-document {
height: 100%;
overflow: hidden;
background: #f7f8fa;
}
}
@layer components {
@@ -257,3 +264,14 @@
}
}
@layer utilities {
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
}

View File

@@ -1,214 +0,0 @@
<script setup>
import AdminNavPrimaryBranch from './AdminNavPrimaryBranch.vue'
const props = defineProps({
/** buildNavigationEditorTree 결과 */
wraps: {
type: Array,
required: true
},
/** 들여쓰기 단계 */
depth: {
type: Number,
default: 0
},
/** 루트면 `'root'`, 아니면 부모 항목 id */
parentKey: {
type: String,
default: 'root'
},
/** 드래그 중인 항목 id */
draggingId: {
type: String,
default: ''
},
/** 드롭 대상 위에 올린 항목 id */
dragOverId: {
type: String,
default: ''
}
})
const emit = defineEmits([
'drag-start',
'drag-over',
'drag-end',
'drop',
'add-child',
'remove'
])
/**
* 입력·버튼 위에서는 행 드래그를 시작하지 않는다.
* @param {DragEvent} event - 이벤트
* @returns {boolean} true면 드래그를 막는다
*/
const shouldBlockRowDrag = (event) => {
const el = event.target
if (!el || typeof el.closest !== 'function') {
return false
}
return Boolean(el.closest('input, button, textarea, select, a'))
}
/**
* 드래그 시작
* @param {DragEvent} event - 이벤트
* @param {string} itemId - 항목 id
* @returns {void}
*/
const onDragStart = (event, itemId) => {
if (shouldBlockRowDrag(event)) {
event.preventDefault()
return
}
if (!event.dataTransfer) {
return
}
emit('drag-start', { parentKey: props.parentKey, itemId })
event.dataTransfer.effectAllowed = 'move'
}
/**
* 드래그 오버
* @param {DragEvent} event - 이벤트
* @param {string} itemId - 항목 id
* @returns {void}
*/
const onDragOver = (event, itemId) => {
event.preventDefault()
emit('drag-over', itemId)
}
/**
* 드롭
* @param {DragEvent} event - 이벤트
* @param {string} itemId - 대상 id
* @returns {void}
*/
const onDrop = (event, itemId) => {
event.preventDefault()
emit('drop', { parentKey: props.parentKey, targetId: itemId })
}
/**
* 드래그 종료
* @returns {void}
*/
const onDragEnd = () => {
emit('drag-end')
}
/**
* 행 하이라이트 클래스(태그 관리 메인 태그 테이블과 동일 톤)
* @param {string} itemId - 항목 id
* @returns {string}
*/
const rowStateClass = (itemId) => {
const id = String(itemId)
if (props.dragOverId === id) {
return 'bg-[#f9f9f7]'
}
if (props.draggingId === id) {
return 'opacity-50'
}
return ''
}
</script>
<template>
<div class="admin-nav-primary-branch" :class="depth ? 'mt-2 border-l border-line pl-3' : ''">
<div class="admin-nav-primary-branch__shell overflow-hidden rounded border border-line">
<table class="admin-nav-primary-branch__table w-full border-collapse text-left text-sm">
<thead v-if="depth === 0" class="admin-nav-primary-branch__head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-nav-primary-branch__cell px-4 py-3">
#
</th>
<th class="admin-nav-primary-branch__cell px-4 py-3">
라벨
</th>
<th class="admin-nav-primary-branch__cell px-4 py-3">
URL
</th>
<th class="admin-nav-primary-branch__cell px-4 py-3">
관리
</th>
</tr>
</thead>
<tbody class="admin-nav-primary-branch__body divide-y divide-line bg-white">
<template v-for="(wrap, index) in wraps" :key="wrap.item.id">
<tr
class="admin-nav-primary-branch__row cursor-move"
:class="rowStateClass(wrap.item.id)"
draggable="true"
@dragstart="onDragStart($event, wrap.item.id)"
@dragover="onDragOver($event, wrap.item.id)"
@drop="onDrop($event, wrap.item.id)"
@dragend="onDragEnd"
>
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle text-muted">
{{ index + 1 }}
</td>
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
<input
v-model="wrap.item.label"
class="admin-nav-primary-branch__label w-full min-w-[8rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="라벨"
required
>
</td>
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
<input
v-model="wrap.item.url"
class="admin-nav-primary-branch__url w-full min-w-[10rem] rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="URL (# 또는 /경로)"
required
>
</td>
<td class="admin-nav-primary-branch__cell px-4 py-3 align-middle">
<div class="admin-nav-primary-branch__actions flex flex-wrap gap-2">
<button
class="admin-nav-primary-branch__add-child rounded border border-line px-3 py-1.5 text-xs font-semibold"
type="button"
@click="emit('add-child', wrap.item.id)"
>
하위
</button>
<button
class="admin-nav-primary-branch__remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700"
type="button"
@click="emit('remove', wrap.item.id)"
>
삭제
</button>
</div>
</td>
</tr>
<tr v-if="wrap.children.length" class="admin-nav-primary-branch__nest bg-white">
<td class="p-0" colspan="4">
<div class="admin-nav-primary-branch__nest-inner border-t border-line bg-[#fafaf8] px-2 py-3">
<AdminNavPrimaryBranch
:wraps="wrap.children"
:depth="depth + 1"
:parent-key="String(wrap.item.id)"
:dragging-id="draggingId"
:drag-over-id="dragOverId"
@drag-start="emit('drag-start', $event)"
@drag-over="emit('drag-over', $event)"
@drag-end="emit('drag-end')"
@drop="emit('drop', $event)"
@add-child="emit('add-child', $event)"
@remove="emit('remove', $event)"
/>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>

View File

@@ -15,7 +15,8 @@ const { data: tags } = await useFetch('/api/tags', {
const { data: navigation } = await useFetch('/api/navigation', {
default: () => ({
primary: [],
footer: []
footer: [],
recommended: []
})
})

View File

@@ -1,4 +1,6 @@
<script setup>
import { getExternalFaviconUrl } from '~/lib/external-favicon-url.js'
const followLinks = [
{ id: 'facebook', label: 'Facebook', href: 'https://facebook.com', icon: 'facebook' },
{ id: 'x', label: 'X', href: 'https://x.com', icon: 'x' },
@@ -18,6 +20,33 @@ 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 }>}
*/
const recommendedSites = computed(() => {
const list = navigation.value?.recommended
if (!Array.isArray(list)) {
return []
}
return list.filter((x) => x?.isVisible !== false)
})
/**
* 새 탭으로 열 외부 URL인지
* @param {string} url - 링크
* @returns {boolean}
*/
const isExternalNavUrl = (url) => /^https?:\/\//i.test(String(url || '').trim())
/** 소개 영역 공개 여부 */
const showAboutSection = false
</script>
@@ -159,24 +188,44 @@ const showAboutSection = false
</div>
</div>
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<div
v-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>
<span></span>
</div>
<div class="right-sidebar__links mt-4 grid gap-3 text-sm">
<NuxtLink class="right-sidebar__link site-interactive font-semibold" to="/post/hello-sori-studio">
sori.studio 글과 방향
</NuxtLink>
<NuxtLink class="right-sidebar__link site-interactive font-semibold" to="/pages/projects">
Projects and services
</NuxtLink>
<NuxtLink class="right-sidebar__link site-interactive font-semibold" to="/pages/links">
Links and portal
</NuxtLink>
</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="getExternalFaviconUrl(item.url)"
class="h-full w-full object-cover"
:src="getExternalFaviconUrl(item.url, 64)"
width="36"
height="36"
alt=""
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 font-mono text-[11px] site-muted">{{ item.url }}</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">

View File

@@ -15,9 +15,9 @@ const toggleBranch = inject('sidebarPrimaryNavToggle')
const navBarBeforeBase =
"before:pointer-events-none before:content-[''] before:h-4 before:w-1 before:flex-none before:rounded-sm before:rounded-l-none before:transition-[width,height,border-radius,background-color] before:duration-200 hover:px-3 hover:before:h-2 hover:before:w-2 hover:before:rounded-full"
/** 비활성 경로: 기본 회색 막대, 호버 시 원형·믹스 색 */
/** 비활성 경로: 테두리 톤에 가깝게 밝게 섞인 막대, 호버 시 원형·믹스 색 */
const navBarBeforeInactive =
`${navBarBeforeBase} before:bg-[var(--site-line)] hover:before:bg-[color:color-mix(in_srgb,var(--site-text)_28%,var(--site-line))]`
`${navBarBeforeBase} before:bg-[color:color-mix(in_srgb,var(--site-line)_88%,var(--site-panel)_12%)] hover:before:bg-[color:color-mix(in_srgb,var(--site-text)_28%,var(--site-line))]`
/** 현재 페이지와 일치하는 내부 링크: 브랜드(`--site-accent`) 막대·호버 원형 */
const navBarBeforeActive =
@@ -122,7 +122,7 @@ const navLinkClass = (url) => {
:class="isExpanded(node.id) ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
>
<div class="sidebar-primary-nav-list__sub-clip min-h-0 overflow-hidden">
<ul class="sidebar-primary-nav-list__sub ml-0 border-l border-[var(--site-line)] pl-2 pt-0">
<ul class="sidebar-primary-nav-list__sub ml-0 mt-2 pl-3 pt-0">
<SidebarPrimaryNavList :nodes="node.children" />
</ul>
</div>

View File

@@ -0,0 +1,7 @@
-- 추천 사이트용 location 값 추가(우측 사이드 Recommended, 관리자 메뉴 탭과 동일 저장소)
ALTER TABLE navigation_items
DROP CONSTRAINT IF EXISTS navigation_items_location_check;
ALTER TABLE navigation_items
ADD CONSTRAINT navigation_items_location_check
CHECK (location IN ('primary', 'footer', 'recommended'));

View File

@@ -1,5 +1,21 @@
# 업데이트 요약
## v1.1.13
- 상단 메뉴 깊이를 한 단계로 제한하고, 추천 사이트를 DB·관리자 탭·우측 Recommended 카드(외부 파비콘 프록시)로 연결했다.
## v1.1.12
- 관리자 상단 메뉴에서 드래그 시 형제 끼움과 하위 편입을 색·문구로 구분하고, 왼쪽 번호를 계층형 개요(`2.1` 등)로 바꿨다.
## v1.1.11
- 공개 사이드바 1차 네비 비활성 표시·하위 간격을 정리하고, 관리자 상단 메뉴는 추가 후 드래그만으로 형제 순서·하위 편입을 바꾸도록 단순화했다.
## v1.1.10
- 관리자 사이트 설정 화면을 Ghost형 전체 화면(좌측 내비·스크롤 스파이·ESC 닫기)으로 바꾸고, 블로그 제목·설명은 읽기 전용 + 편집 시에만 입력하도록 정리. 상단 헤더 없이 우측 상단 고정 닫기, 사이드·본문 중앙 정렬 레이아웃을 적용한다.
## v1.1.9
- 관리자 글 목록에 상태·태그·정렬 필터와 댓글 수 표시를 추가.

View File

@@ -137,8 +137,9 @@ cp .env.example .env.production
# Docker 빌드 및 실행
docker compose --env-file .env.production up -d --build
# 기존 운영 DB를 유지한 채 새 버전을 올릴 때 추천 글 컬럼 마이그레이션 적용
# 기존 운영 DB를 유지한 채 새 버전을 올릴 때 추천 글·네비 location 마이그레이션 적용
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/023_add_post_featured.sql
docker compose --env-file .env.production exec sori-studio-db psql -U sori_studio -d sori_studio -f /docker-entrypoint-initdb.d/024_navigation_recommended_location.sql
```
### Docker 네트워크 충돌 대응

View File

@@ -1,5 +1,29 @@
# 의사결정 이력
## 2026-05-15 v1.1.13
### 상단 메뉴 1뎁스·추천 사이트·파비콘 프록시
사이드바 상단 네비는 운영상 루트와 그 직속 자식만이면 충분하고, 그보다 깊은 트리는 편집·표시 모두 부담이 된다. 저장·공개 트리 조립·관리자 드래그에서 한 단계로 막는다. 우측 Recommended는 하드코딩 대신 `location=recommended` 평면 행으로 두어 메뉴 관리 한 화면에서 다루게 하고, 외부 링크는 호스트만 추출해 Google Favicon CDN URL을 쓰면 별도 스크래핑 없이 아이콘을 얻을 수 있다(내부 경로는 생략).
## 2026-05-15 v1.1.12
### 상단 메뉴 편집: 드롭 구역 시각 구분과 계층형 개요 번호
행 위·중·아래만으로는 형제 순서 이동과 부모 변경(하위 편입)이 한눈에 구분되기 어렵고, 평면 행 번호(2,3,4…)는 부모·자식 관계와 맞지 않아 혼란스럽다. 드래그 중에는 파란 끝선·앰버 링과 짧은 한글 캡션으로 의미를 고정하고, 개요 열은 `1`, `2.1`, `2.2`처럼 트리 깊이에 맞춘 표기로 바꾸며 라벨 들여쓰기를 키워 구조를 읽기 쉽게 한다.
## 2026-05-15 v1.1.11
### 관리자 상단 네비를 평면 드래그 편집으로 통일
`하위` 전용 추가 버튼과 중첩 테이블은 트리 깊이가 늘수록 조작이 어색하고 공개 사이드바와 다른 시각 계층을 준다. 항목은 모두 `상단 메뉴 추가`로 만든 뒤, 한 테이블에서 들여쓰기만으로 깊이를 보여 주고, 행의 위·가운데·아래 드롭 구역으로 형제 사이 끼움과 하위 편입을 나누면 Ghost류 아웃라이너와 비슷한 자유도를 유지하면서 UI는 단순해진다.
## 2026-05-15 v1.1.10
### 관리자 사이트 설정을 전용 전체 화면으로 분리
공개 블로그 설정은 항목이 늘어날 예정이라 목록형 관리자 레이아웃 안에 두면 세로 공간과 시선 분산이 커진다. Ghost Admin처럼 설정만 별도 전체 화면으로 열고 좌측 앵커 내비와 우측 긴 스크롤을 두면 확장(타임존·공지·가져오기/보내기·스팸)을 같은 패턴으로 쌓을 수 있다. 따라서 `/admin/settings`에서는 기본 관리자 사이드바를 숨기고, 닫기·ESC와 문서 스크롤 잠금으로 집중도를 맞춘다.
## 2026-05-15 v1.1.9
### 추천 글을 저장 필드로 분리

View File

@@ -8,7 +8,7 @@
|------|------|
| layouts/default.vue | 메인·목록·태그 — 3열 `gap`+중앙 `1fr`, `site-main` `max-w-[720px]`, 모바일 슬라이드 메뉴 |
| layouts/post.vue | 개별 게시물 — `default`와 동일 |
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바, 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, `내 프로필` 멤버 편집 이동, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 |
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바(대시보드 비활성 행·사이트 보기 새 창·콘텐츠 구간 여백), 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, `내 프로필` 멤버 편집 이동, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금, **`/admin/settings`에서는 사이드바 숨김·본문 전체 화면·설정용 문서 스크롤 잠금** |
| layouts/page.vue | 고정 페이지 전체 화면 |
| app.vue | 공개 사이트 설정 기반 파비콘 head 링크와 기본 title template |
@@ -23,7 +23,8 @@
| 파일 | 용도 |
|------|------|
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree` |
| lib/external-favicon-url.js | 외부 URL 호스트 기준 Google `s2/favicons` 썸네일 URL 생성(내부 경로는 빈 문자열) |
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` |
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
## Nuxt 모듈
@@ -54,7 +55,7 @@
| 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/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`+`site-sidebar-nav-row` 호버(`#F7F4EF` 라이트), `inject`·`localStorage` 펼침 |
| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 사이트 이미지 로고 fallback, About 영역은 비공개, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
| components/site/RightSidebar.vue | 오른쪽 사이드바, 공개 `GET /api/navigation``recommended` 카드 목록(외부 URL은 Google 파비콘 프록시 썸네일), Follow·구독 폼, About 영역은 비공개, `lg+` 스티키 |
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션, 태그는 있을 때만 메타에 표시 |
| components/site/TagHeader.vue | 태그 페이지 헤더 |
@@ -68,7 +69,6 @@
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, textarea 기반 범위 선택·복사/붙여넣기, HTML 클립보드 마크다운 변환, Enter 새 문단·Shift+Enter 백슬래시 hard break 줄바꿈 입력, 작성 모드 왼쪽 바깥 absolute 논리 줄 번호 거터·거터 스크롤 동기화·스크롤바 숨김, 작성/미리보기 전환(`Cmd/Ctrl+E`)과 커서 복원, 작성 모드 툴바 마크다운 삽입, 미리보기 모드 툴바 숨김, 이미지·갤러리 업로드 및 미디어 라이브러리 삽입, 현재 이미지·갤러리 편집 패널 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) |
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
@@ -115,11 +115,11 @@
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물/페이지 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 현재 사이트 설정 로고·파비콘은 사용 중 파일로 잠금, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단 탭, 테이블+행 드래그(태그 메인과 동일 톤), `useAdminToast` |
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 사이트 탭, 상단은 1뎁스 제한·평면 테이블+계층형 개요·행 드래그(위/중/아래), 하단·추천은 평면 드래그, `useAdminToast` |
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·일반 강등, 일반 태그 검색/메인 전환/삭제, 일반 태그 헤더의 태그 추가 버튼), 액션 피드백 토스트 |
| pages/admin/tags/new.vue | 태그 생성 |
| pages/admin/tags/[id].vue | 태그 수정 |
| pages/admin/settings/index.vue | 사이트 설정(이름, 설명, URL, 1:1 로고 업로드·고유 URL 파비콘 생성, 저작권 문구) |
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면: 좌측 검색·내비와 우측 본문을 **중앙 `max-w` 래퍼**에 묶고 본문은 **약 760px** 상한, 우측 상단 **고정 닫기**, 밝은 회색 배경·본문 열 흰색, 블로그 제목·설명은 읽기 전용·`편집` 시 입력·저장/취소, 기타(로고·URL·저작권) 저장, 타임존·어나운스·Import/Export·스팸은 플레이스홀더 |
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
@@ -238,6 +238,7 @@
| db/migrations/002_seed_development.sql | 개발용 샘플 데이터 |
| db/migrations/003_add_tag_display_fields.sql | 태그 표시 순서와 색상 필드 추가 |
| db/migrations/004_add_site_settings.sql | 사이트 설정 테이블 추가 |
| db/migrations/024_navigation_recommended_location.sql | `navigation_items.location``recommended` 값 허용 |
| db/migrations/017_navigation_hierarchy.sql | `navigation_items``parent_id`·`is_folder`, `(location,label,url)` 유니크 제거 |
| db/migrations/019_dedupe_navigation_items.sql | 반복 마이그레이션으로 생긴 네비게이션 중복 행 정리 및 중복 방지 인덱스 |
| db/migrations/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 |

View File

@@ -318,7 +318,7 @@ components/content/
| id | UUID | Primary Key |
| label | String | 메뉴 표시 이름 |
| url | String | 내부 경로 또는 외부 URL |
| location | Enum | primary/footer |
| location | Enum | primary / footer / recommended |
| sort_order | Integer | 표시 순서 |
| is_visible | Boolean | 공개 화면 표시 여부 |
| created_at | DateTime | 생성일 |
@@ -377,7 +377,7 @@ components/content/
- `GET /api/tags` - 태그 목록
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
- `GET /api/site-settings` - 공개 사이트 설정
- `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`는 평면, 상세는 위 메뉴/네비게이션 절)
- `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`·`recommended`는 평면, 상세는 위 메뉴/네비게이션 절)
- `POST /api/auth/signup` - 회원 가입. 본문: `username`, `email`, `password`, 선택 `emailOtp`(6자리 숫자). **`GET /api/auth/bootstrap-status``emailOtpConfigured`가 true이고 `needsAdminSetup`이 false일 때**(서버에 Resend·pepper 설정됨) `emailOtp`가 필수이며, 직전에 `POST /api/auth/email-otp/request`로 같은 이메일·`purpose: "signup"` 발송분과 일치해야 한다. 최초 관리자 부트스트랩(`needsAdminSetup: true`)에서는 `emailOtp` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다.
- `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부(`hasUsers`, `needsAdminSetup`) 및 **이메일 OTP 사용 가능 여부** `emailOtpConfigured`(서버 `RESEND_API_KEY`·`RESEND_FROM_EMAIL` 및 pepper: 비어 있지 않은 `EMAIL_OTP_PEPPER` **또는** `MEMBER_SESSION_SECRET`)를 반환한다. pepper는 OTP 6자리를 DB에 해시해 둘 때만 쓰이는 서버 비밀(긴 난문자열 권장)이다.
- `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`) 필요.
@@ -552,6 +552,7 @@ components/content/
### 사이트 설정
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 타임존·어나운스 바·게시물 Import/Export·스팸 필터는 현재 **메뉴·안내 카드만** 제공하고 저장 API는 연결하지 않는다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다.
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
- 로고 이미지는 1:1 비율로 저장하며 `/admin/api/settings/logo` 업로드 시 `/uploads/system/logo-YYYYMM-random.webp``/uploads/system/favicon-YYYYMM-random.png`를 고유 파일명으로 함께 생성한다. 같은 URL 덮어쓰기로 인한 브라우저·운영 정적 캐시 문제를 피하기 위해 로고 교체마다 새 URL을 저장한다.
@@ -562,12 +563,12 @@ components/content/
### 메뉴/네비게이션
- 네비게이션은 `navigation_items` 테이블로 관리한다.
- 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, **자식이 있는 상단 항목이면 저장 시 서버가 true로 설정**), `location`(`primary`|`footer`), `sort_order`, `label`, `url`, `is_visible`(저장 시 항상 true로 정리). `(location, label, url)` 유니크 제약은 제거되었다.
- 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, **자식이 있는 상단 항목이면 저장 시 서버가 true로 설정**), `location`(`primary`|`footer`|`recommended`), `sort_order`, `label`, `url`, `is_visible`(저장 시 항상 true로 정리). `(location, label, url)` 유니크 제약은 제거되었다.
- 동일 `location`·`parent_id`·`label`·`url` 조합은 중복 저장하지 않는다. 반복 마이그레이션으로 생긴 중복은 `019_dedupe_navigation_items.sql`이 정리하고, 표현식 유니크 인덱스로 재발을 막는다.
- `GET /api/navigation` 응답: `primary`**트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**(레거시·중복 행으로 인한 사이드바 이중 표시 방지). `footer`**평면** 배열(`parent_id` 없음)이며 `id`, `label`, `url`, `isVisible`만 내려간다.
- `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`서버가 위치별 트리 DFS 순으로 다시 부여한다. `parent_id``primary`에서만 허용되며 `footer` 항목은 항상 루트다. `is_visible`·`is_folder`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다.
- `GET /api/navigation` 응답: `primary`**트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**. **상단은 루트 직속 자식만** 트리에 붙이며(부모의 부모가 있는 행은 자식으로 무시), `footer`·`recommended`**평면** 배열(`parent_id` 없음)이며 `id`, `label`, `url`, `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` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·번호 열에서 시작한다. 상단은 `AdminNavPrimaryBranch`로 트리·동일 부모 내 순서 변경·하위 추가를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다.
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션**·**추천 사이트** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 상단은 `buildNavigationEditorTree` 결과를 `flattenNavigationEditorWrappers`로 한 테이블에 평면 표시하고, **같은 행의 위 1/3·가운데·아래 1/3**에 드롭하면 각각 대상 **앞**(동일 부모)·**하위**(대상의 자식, **루트 행에만**)·**뒤**(동일 부모)로 이동한다. **상단은 루트→자식 한 단계만** 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 **형제 앞·뒤**를 파란 가로 끝선·연한 파란 배경, **하위 편입**을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에 `앞에 끼움` / `뒤에 끼움` / `하위로 넣기` 짧은 문구를 덧붙인다. 개요 열 표기는 `1`, `2.1`, `2.2`, `3`처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼 `padding-left`로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 하단·추천 사이트는 평면 목록만 드래그 정렬한다. 추천 사이트는 공개 홈 **오른쪽 사이드바** Recommended 영역에 카드로 노출되며, `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,7 +1,32 @@
# 업데이트 이력
## v1.1.13
- 상단 네비: 하위 1뎁스만 허용(서버 검증·공개 트리 조립·관리자 드래그·이미 하위가 있는 항목의 하위 편입 금지).
- `navigation_items.location``recommended` 추가(마이그레이션 `024_navigation_recommended_location.sql`), 관리자 메뉴에 추천 사이트 탭·공개 API `recommended`·우측 사이드 카드 목록.
- 외부 링크 파비콘 표시용 `lib/external-favicon-url.js`(Google `s2/favicons` 프록시 URL).
- 패키지 버전 `1.1.13`로 갱신.
## v1.1.12
- 관리자 상단 메뉴: 드롭 구역을 파란 끝선(형제 앞·뒤)·앰버 링(하위)과 개요 열 캡션으로 구분, 개요 번호를 `2.1`형 계층 표기로 변경·라벨 들여쓰기 확대.
- 패키지 버전 `1.1.12`로 갱신.
## v1.1.11
- 공개 사이드바 1차 네비: 부모·하위 사이 `mt-2` 간격, 비활성 세로 표시를 `color-mix(in srgb, var(--site-line) 88%, var(--site-panel) 12%)` 톤으로 정리.
- 관리자 상단 메뉴: `하위` 버튼 제거, `flattenNavigationEditorWrappers` 단일 테이블+행 위·중·아래 드롭으로 순서·부모 자유 변경, `AdminNavPrimaryBranch.vue` 제거.
- 패키지 버전 `1.1.11`로 갱신.
## v1.1.10
- 관리자 `/admin/settings`를 Ghost형 전체 화면으로 재구성(좌측 검색·앵커 내비·우측 스크롤 스파이, X·ESC 닫기, 타임존·어나운스·Import/Export·스팸 섹션은 플레이스홀더).
- 설정 경로에서 관리자 기본 사이드바를 숨기고 문서 스크롤 잠금(`admin-settings-document`)을 적용.
- 관리자 `/admin/settings`에서 상단 헤더를 제거하고 우측 상단 고정 닫기만 두며, 사이드·본문 열을 `max-w-[1120px]` 래퍼로 중앙 정렬·본문 카드 폭은 `max-w-[760px]`로 Ghost에 가깝게 맞춤.
## v1.1.9
- 관리자 사이드바 상단에 대시보드(비활성 표시)·사이트 보기(`NUXT_PUBLIC_SITE_URL` 기준 새 창)·콘텐츠 메뉴 구분 여백 추가.
- 관리자 글 목록에 상태·태그·최신순/오래된순 필터 추가.
- 관리자 글 목록 상태 표시를 배지에서 단순 텍스트 색상 기준으로 정리하고 제목 옆 댓글 수 표시 추가.
- 게시물 추천 여부(`is_featured`) 저장 필드와 글쓰기 사이드바 추천 토글 추가.

View File

@@ -1,12 +1,26 @@
<script setup>
const route = useRoute()
const runtimeConfig = useRuntimeConfig()
/**
* 공개 블로그 베이스 URL (후행 슬래시 제거, 새 창 링크용)
* @returns {string} 절대 URL
*/
const publicBlogBaseUrl = computed(() => {
const raw = String(runtimeConfig.public?.siteUrl || '').trim()
return raw.replace(/\/+$/, '') || 'https://sori.studio'
})
const isPostEditorRoute = computed(() => route.path === '/admin/posts/new'
|| (route.path.startsWith('/admin/posts/') && route.path !== '/admin/posts/preview'))
const editorDocumentClass = 'admin-post-editor-document'
const settingsDocumentClass = 'admin-settings-document'
const adminUserMenuOpen = ref(false)
const isAdminSettingsRoute = computed(() => route.path === '/admin/settings'
|| route.path.startsWith('/admin/settings/'))
const { data: adminMember } = await useFetch('/api/auth/me', {
default: () => ({
username: '',
@@ -67,19 +81,23 @@ const onAdminDocumentPointerDown = (event) => {
}
/**
* 글쓰기 전체 화면 문서 스크롤 잠금 적용
* 글쓰기·설정 전체 화면 문서 스크롤 잠금 적용
* @returns {void}
*/
const syncPostEditorDocumentClass = () => {
const syncAdminShellDocumentClass = () => {
if (!import.meta.client) {
return
}
document.documentElement.classList.toggle(editorDocumentClass, isPostEditorRoute.value)
document.body.classList.toggle(editorDocumentClass, isPostEditorRoute.value)
const editorOn = isPostEditorRoute.value
const settingsOn = isAdminSettingsRoute.value && !editorOn
document.documentElement.classList.toggle(editorDocumentClass, editorOn)
document.body.classList.toggle(editorDocumentClass, editorOn)
document.documentElement.classList.toggle(settingsDocumentClass, settingsOn)
document.body.classList.toggle(settingsDocumentClass, settingsOn)
}
watchEffect(syncPostEditorDocumentClass)
watchEffect(syncAdminShellDocumentClass)
onMounted(() => {
document.addEventListener('pointerdown', onAdminDocumentPointerDown)
@@ -92,6 +110,8 @@ onBeforeUnmount(() => {
document.documentElement.classList.remove(editorDocumentClass)
document.body.classList.remove(editorDocumentClass)
document.documentElement.classList.remove(settingsDocumentClass)
document.body.classList.remove(settingsDocumentClass)
document.removeEventListener('pointerdown', onAdminDocumentPointerDown)
})
@@ -111,10 +131,10 @@ const logoutAdmin = async () => {
<template>
<div
class="admin-layout bg-[#f7f8fa] text-ink"
:class="isPostEditorRoute ? 'h-screen overflow-hidden bg-white' : 'min-h-screen'"
:class="(isPostEditorRoute || isAdminSettingsRoute) ? 'h-screen overflow-hidden bg-white' : 'min-h-screen'"
>
<aside
v-if="!isPostEditorRoute"
v-if="!isPostEditorRoute && !isAdminSettingsRoute"
class="admin-layout__sidebar fixed inset-y-0 left-0 hidden w-80 flex-col border-r border-[#e6e8eb] bg-[#f7f8fa] px-5 py-6 text-[#15171a] lg:flex"
>
<NuxtLink class="admin-layout__brand flex items-center gap-3 px-2 text-[0.95rem] font-semibold tracking-[-0.01em]" to="/admin">
@@ -124,6 +144,29 @@ const logoutAdmin = async () => {
<span>sori.studio</span>
</NuxtLink>
<nav class="admin-layout__nav mt-10 grid gap-1.5 text-sm font-medium text-[#5d6673]">
<div
class="admin-layout__nav-link admin-layout__nav-link--disabled flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-[#9aa3ad] select-none"
aria-disabled="true"
title="준비 중"
>
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0 opacity-60" viewBox="0 0 24 24" aria-hidden="true">
<path d="M22.272 23.247a.981.981 0 00.978-.978V9.747a1.181 1.181 0 00-.377-.8L12 .747l-10.873 8.2a1.181 1.181 0 00-.377.8v12.522a.981.981 0 00.978.978z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
<span>대시보드</span>
</div>
<a
class="admin-layout__nav-link admin-layout__nav-link--external flex items-center gap-3 rounded-md px-3 py-2 text-[#5d6673] transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
:href="publicBlogBaseUrl"
target="_blank"
rel="noopener noreferrer"
>
<svg class="admin-layout__nav-icon h-4 w-4 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
<rect x="1.5" y="1.497" width="21" height="21" rx="1.5" ry="1.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<path d="M1.5 7.497h21m-13.5 15v-15" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
<span>사이트 보기</span>
</a>
<div class="admin-layout__nav-divider h-6 shrink-0" aria-hidden="true" />
<div
class="admin-layout__nav-item group flex items-center rounded-md transition-colors"
:class="isAdminNavActive('/admin/posts') ? 'bg-[#e9ecef] text-[#15171a]' : 'hover:bg-[#eceff2] hover:text-[#15171a]'"
@@ -260,10 +303,12 @@ const logoutAdmin = async () => {
</div>
</aside>
<main
class="admin-layout__main bg-paper"
class="admin-layout__main"
:class="[
isPostEditorRoute ? 'h-screen overflow-hidden' : 'min-h-screen px-8 py-8 xl:px-12 xl:py-10',
{ 'lg:ml-80': !isPostEditorRoute }
isPostEditorRoute || isAdminSettingsRoute
? 'h-screen overflow-hidden bg-white'
: 'min-h-screen bg-paper px-8 py-8 xl:px-12 xl:py-10',
{ 'lg:ml-80': !isPostEditorRoute && !isAdminSettingsRoute }
]"
>
<slot />

View File

@@ -0,0 +1,32 @@
/**
* 외부 사이트 파비콘을 브라우저에 표시하기 위한 프록시 URL을 만든다.
* 호스트만 추출해 Google Favicon 서비스(`https://www.google.com/s2/favicons`) URL을 반환한다.
* 내부 경로(`/…`)·`#`·파싱 실패 시 빈 문자열을 반환한다(이미지 생략).
* @param {string} rawUrl - 링크 URL
* @param {number} [sizePx] - 한 변(px), 기본 32, 최대 128
* @returns {string} `https://www.google.com/s2/favicons?...` 또는 `''`
*/
export const getExternalFaviconUrl = (rawUrl, sizePx = 32) => {
const trimmed = String(rawUrl || '').trim()
if (!trimmed || trimmed === '#') {
return ''
}
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) {
return ''
}
try {
const withProto = trimmed.startsWith('//') ? `https:${trimmed}` : trimmed
const u = new URL(withProto)
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
return ''
}
const host = u.hostname
if (!host) {
return ''
}
const sz = Number.isFinite(sizePx) && sizePx > 0 ? Math.min(128, Math.round(sizePx)) : 32
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=${sz}`
} catch {
return ''
}
}

View File

@@ -42,3 +42,26 @@ export const buildNavigationEditorTree = (flat, location) => {
sortRec(roots)
return roots
}
/**
* 관리자 상단 네비 트리 래퍼를 표시·드래그용 평면 행 배열로 만든다.
* @param {Array<{ item: Object, children: any[] }>} wraps - `buildNavigationEditorTree` 결과
* @param {number} [depth] - 들여쓰기 단계(0=루트)
* @returns {Array<{ item: Object, depth: number }>}
*/
export const flattenNavigationEditorWrappers = (wraps, depth = 0) => {
const out = []
if (!Array.isArray(wraps)) {
return out
}
for (const w of wraps) {
if (!w?.item) {
continue
}
out.push({ item: w.item, depth })
if (w.children?.length) {
out.push(...flattenNavigationEditorWrappers(w.children, depth + 1))
}
}
return out
}

4
package-lock.json generated
View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<script setup>
import { buildNavigationEditorTree } from '~/lib/navigation-editor-tree.js'
import { buildNavigationEditorTree, flattenNavigationEditorWrappers } from '~/lib/navigation-editor-tree.js'
definePageMeta({
layout: 'admin'
@@ -21,12 +21,16 @@ const items = ref(navigationItems.value.map((item) => ({
})))
const navDraggingId = ref('')
const navDragParentKey = ref('')
const navDragOverId = ref('')
/** 'before' | 'into' | 'after' — 행 위·중·아래 드롭 구역 */
const navDragOverZone = ref('')
const footerDraggingId = ref('')
const footerDragOverId = ref('')
const recommendedDraggingId = ref('')
const recommendedDragOverId = ref('')
/**
* 네비게이션 항목을 저장 API와 동일한 형태로 직렬화한다.
* @param {Array<Object>} list - 항목 목록
@@ -61,12 +65,38 @@ const isNavigationDirty = computed(() => serializeNavigationItems(items.value) !
const primaryTree = computed(() => buildNavigationEditorTree(items.value, 'primary'))
const primaryRows = computed(() => flattenNavigationEditorWrappers(primaryTree.value))
/**
* 상단 메뉴 평면 행에 표시할 개요 번호(예: 1, 2, 2.1, 2.2, 3)
* @returns {string[]}
*/
const primaryOutlineLabels = computed(() => {
const rows = primaryRows.value
const labels = []
/** @type {number[]} */
const counters = []
for (const row of rows) {
const d = row.depth
counters.length = d + 1
counters[d] = (counters[d] || 0) + 1
labels.push(counters.slice(0, d + 1).join('.'))
}
return labels
})
const footerItemsSorted = computed(() =>
items.value
.filter((item) => item.location === 'footer' && !item.parentId)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
)
const recommendedItemsSorted = computed(() =>
items.value
.filter((item) => item.location === 'recommended' && !item.parentId)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
)
/**
* 하위 항목 id를 모두 모은 뒤 삭제한다.
* @param {string} rootId - 루트 id
@@ -89,31 +119,166 @@ const removeItemCascade = (rootId) => {
}
/**
* 상단 메뉴 동일 부모 형제 사이에서 순서만 바꾼다.
* @param {string|null} parentId - 부모 id
* @param {string} sourceId - 이동할 id
* @param {string} targetId - 놓인 위치 기준 id
* 항목 id로 레코드를 찾는다.
* @param {string} id - 항목 id
* @returns {Object|undefined}
*/
const getNavItem = (id) => items.value.find((i) => String(i.id) === String(id))
/**
* nodeId가 ancestorId의 하위(직·간접)인지
* @param {string} nodeId - 후손 후보
* @param {string} ancestorId - 조상 id
* @returns {boolean}
*/
const isUnderTree = (nodeId, ancestorId) => {
let cur = getNavItem(nodeId)
while (cur) {
const p = cur.parentId ? String(cur.parentId) : ''
if (!p) {
return false
}
if (p === String(ancestorId)) {
return true
}
cur = getNavItem(p)
}
return false
}
/**
* 동일 부모(primary) 형제들의 sortOrder를 10 간격으로 다시 부여한다.
* @param {string|null|undefined} parentId - 부모 id, 루트면 null
* @returns {void}
*/
const reorderPrimarySiblings = (parentId, sourceId, targetId) => {
const pid = parentId || null
const siblings = items.value
.filter((i) => i.location === 'primary' && (pid ? i.parentId === pid : !i.parentId))
const normalizeOrdersForParent = (parentId) => {
const pid = parentId == null || parentId === '' ? null : String(parentId)
const sibs = items.value
.filter((i) => {
if (i.location !== 'primary') {
return false
}
const p = i.parentId ? String(i.parentId) : ''
if (pid === null) {
return !p
}
return p === pid
})
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
const ids = siblings.map((s) => String(s.id))
const si = ids.indexOf(String(sourceId))
const ti = ids.indexOf(String(targetId))
if (si < 0 || ti < 0 || si === ti) {
return
}
const nextOrder = [...siblings]
const [mv] = nextOrder.splice(si, 1)
nextOrder.splice(ti, 0, mv)
nextOrder.forEach((row, idx) => {
sibs.forEach((row, idx) => {
row.sortOrder = (idx + 1) * 10
})
}
/**
* 상단 메뉴 한 항목을 드롭 구역에 맞춰 이동·부모 변경한다.
* @param {string} sourceId - 드래그한 항목 id
* @param {string} targetId - 드롭 대상 행 id
* @param {'before'|'into'|'after'} mode - 드롭 구역
* @returns {boolean} 성공 여부
*/
const movePrimaryItem = (sourceId, targetId, mode) => {
const src = getNavItem(sourceId)
const tgt = getNavItem(targetId)
if (!src || !tgt || src.location !== 'primary' || tgt.location !== 'primary') {
return false
}
if (String(sourceId) === String(targetId)) {
return false
}
if (mode === 'into') {
if (tgt.parentId != null && String(tgt.parentId).trim() !== '') {
showToast('error', '상단 메뉴는 하위 한 단계까지만 가능합니다. 루트 항목 가운데에만 하위로 넣을 수 있습니다.')
return false
}
const srcHasPrimaryChildren = items.value.some(
(i) => i.location === 'primary' && String(i.parentId || '') === String(sourceId)
)
if (srcHasPrimaryChildren) {
showToast('error', '이미 하위 메뉴가 있는 항목은 다른 항목의 하위로 넣을 수 없습니다.')
return false
}
if (isUnderTree(targetId, sourceId)) {
showToast('error', '자신의 하위 메뉴 안으로는 옮길 수 없습니다.')
return false
}
const oldPid = src.parentId ? String(src.parentId) : null
const tgtId = String(tgt.id)
src.parentId = tgtId
const children = items.value.filter((i) => i.location === 'primary' && String(i.parentId || '') === tgtId)
const maxOrder = Math.max(0, ...children.map((c) => c.sortOrder || 0))
src.sortOrder = maxOrder + 10
normalizeOrdersForParent(tgtId)
if (String(oldPid || '') !== tgtId) {
normalizeOrdersForParent(oldPid)
}
return true
}
if (isUnderTree(targetId, sourceId)) {
showToast('error', '하위 메뉴와 같은 줄에 끼울 수 없습니다.')
return false
}
if (String(tgt.parentId || '') === String(sourceId)) {
showToast('error', '자신의 바로 아래 항목 앞·뒤로는 옮길 수 없습니다.')
return false
}
const newParentId = tgt.parentId ?? null
const newParentStr = newParentId ? String(newParentId) : ''
if (newParentStr && isUnderTree(newParentStr, sourceId)) {
showToast('error', '이동할 수 없는 위치입니다.')
return false
}
const oldPid = src.parentId ? String(src.parentId) : null
src.parentId = tgt.parentId ?? null
const pKey = newParentStr
const without = items.value
.filter((i) => {
if (i.location !== 'primary') {
return false
}
const ip = i.parentId ? String(i.parentId) : ''
if (!pKey) {
return !ip
}
return ip === pKey
})
.filter((i) => String(i.id) !== String(sourceId))
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
const ti = without.findIndex((i) => String(i.id) === String(targetId))
if (ti < 0) {
return false
}
const insertIdx = mode === 'before' ? ti : ti + 1
const reordered = [...without.slice(0, insertIdx), src, ...without.slice(insertIdx)]
reordered.forEach((row, idx) => {
row.sortOrder = (idx + 1) * 10
})
normalizeOrdersForParent(oldPid)
if (String(oldPid || '') !== String(newParentStr || '')) {
normalizeOrdersForParent(newParentStr || null)
}
return true
}
/**
* 입력·버튼 위에서는 행 드래그를 시작하지 않는다.
* @param {DragEvent} event - 이벤트
* @returns {boolean}
*/
const shouldBlockNavRowDrag = (event) => {
const el = event.target
if (!el || typeof el.closest !== 'function') {
return false
}
return Boolean(el.closest('input, button, textarea, select, a'))
}
/**
* 하단 메뉴 순서 변경
* @param {string} sourceId - 이동할 id
@@ -136,22 +301,90 @@ const reorderFooter = (sourceId, targetId) => {
}
/**
* 상단 트리 드래그 시작
* @param {{ parentKey: string, itemId: string }} payload - 부모 키와 항목 id
* 추천 사이트 순서 변경
* @param {string} sourceId - 이동할 id
* @param {string} targetId - 기준 id
* @returns {void}
*/
const onPrimaryDragStart = ({ parentKey, itemId }) => {
navDragParentKey.value = parentKey
navDraggingId.value = itemId
const reorderRecommended = (sourceId, targetId) => {
const list = recommendedItemsSorted.value.map((i) => i)
const ids = list.map((s) => String(s.id))
const si = ids.indexOf(String(sourceId))
const ti = ids.indexOf(String(targetId))
if (si < 0 || ti < 0 || si === ti) {
return
}
const [mv] = list.splice(si, 1)
list.splice(ti, 0, mv)
list.forEach((row, idx) => {
row.sortOrder = (idx + 1) * 10
})
}
/**
* 상단 트리 드래그 오버
* 상단 드래그 시작
* @param {DragEvent} event - 이벤트
* @param {string} itemId - 항목 id
* @returns {void}
*/
const onPrimaryDragOver = (itemId) => {
const onPrimaryDragStart = (event, itemId) => {
if (shouldBlockNavRowDrag(event)) {
event.preventDefault()
return
}
if (!event.dataTransfer) {
return
}
navDraggingId.value = itemId
event.dataTransfer.effectAllowed = 'move'
}
/**
* 상단 행 드래그 오버(행 높이 기준 위·중·아래 구역)
* @param {DragEvent} event - 이벤트
* @param {string} itemId - 행 id
* @returns {void}
*/
const onPrimaryDragOverRow = (event, itemId) => {
event.preventDefault()
if (!navDraggingId.value) {
return
}
const tr = event.currentTarget
if (!(tr instanceof HTMLElement)) {
return
}
const rect = tr.getBoundingClientRect()
const y = event.clientY - rect.top
const ratio = rect.height > 0 ? y / rect.height : 0.5
let zone = 'into'
if (ratio < 0.33) {
zone = 'before'
} else if (ratio > 0.66) {
zone = 'after'
}
const overItem = getNavItem(itemId)
if (zone === 'into' && overItem?.parentId != null && String(overItem.parentId).trim() !== '') {
zone = ratio < 0.5 ? 'before' : 'after'
}
navDragOverId.value = itemId
navDragOverZone.value = zone
}
/**
* 상단 행 드롭
* @param {DragEvent} event - 이벤트
* @param {string} targetId - 대상 행 id
* @returns {void}
*/
const onPrimaryDropRow = (event, targetId) => {
event.preventDefault()
if (!navDraggingId.value) {
return
}
const mode = /** @type {'before'|'into'|'after'} */ (navDragOverZone.value || 'into')
movePrimaryItem(navDraggingId.value, targetId, mode)
onPrimaryDragEnd()
}
/**
@@ -160,37 +393,8 @@ const onPrimaryDragOver = (itemId) => {
*/
const onPrimaryDragEnd = () => {
navDraggingId.value = ''
navDragParentKey.value = ''
navDragOverId.value = ''
}
/**
* 상단 트리 드롭
* @param {{ parentKey: string, targetId: string }} payload - 부모 키와 대상 id
* @returns {void}
*/
const onPrimaryDrop = ({ parentKey, targetId }) => {
if (navDragParentKey.value !== parentKey) {
showToast('error', '같은 단계의 메뉴 안에서만 순서를 바꿀 수 있습니다.')
onPrimaryDragEnd()
return
}
const parentId = parentKey === 'root' ? null : parentKey
reorderPrimarySiblings(parentId, navDraggingId.value, targetId)
onPrimaryDragEnd()
}
/**
* 입력·버튼 위에서는 행 드래그를 시작하지 않는다.
* @param {DragEvent} event - 이벤트
* @returns {boolean}
*/
const shouldBlockFooterRowDrag = (event) => {
const el = event.target
if (!el || typeof el.closest !== 'function') {
return false
}
return Boolean(el.closest('input, button, textarea, select, a'))
navDragOverZone.value = ''
}
/**
@@ -200,7 +404,7 @@ const shouldBlockFooterRowDrag = (event) => {
* @returns {void}
*/
const onFooterDragStart = (event, id) => {
if (shouldBlockFooterRowDrag(event)) {
if (shouldBlockNavRowDrag(event)) {
event.preventDefault()
return
}
@@ -247,19 +451,131 @@ const onFooterDragEnd = () => {
footerDragOverId.value = ''
}
/**
* 추천 사이트 행 드래그 시작
* @param {DragEvent} event - 이벤트
* @param {string} id - 항목 id
* @returns {void}
*/
const onRecommendedDragStart = (event, id) => {
if (shouldBlockNavRowDrag(event)) {
event.preventDefault()
return
}
if (!event.dataTransfer) {
return
}
recommendedDraggingId.value = id
event.dataTransfer.effectAllowed = 'move'
}
/**
* 추천 사이트 행 드래그 오버
* @param {DragEvent} event - 이벤트
* @param {string} id - 항목 id
* @returns {void}
*/
const onRecommendedDragOver = (event, id) => {
event.preventDefault()
recommendedDragOverId.value = id
}
/**
* 추천 사이트 행 드롭
* @param {DragEvent} event - 이벤트
* @param {string} targetId - 대상 id
* @returns {void}
*/
const onRecommendedDrop = (event, targetId) => {
event.preventDefault()
if (!recommendedDraggingId.value) {
return
}
reorderRecommended(recommendedDraggingId.value, targetId)
recommendedDraggingId.value = ''
recommendedDragOverId.value = ''
}
/**
* 추천 사이트 드래그 종료
* @returns {void}
*/
const onRecommendedDragEnd = () => {
recommendedDraggingId.value = ''
recommendedDragOverId.value = ''
}
/**
* 추천 사이트 행 하이라이트
* @param {string} id - 항목 id
* @returns {string}
*/
const recommendedRowClass = (id) => {
const parts = []
if (recommendedDragOverId.value === id) {
parts.push('bg-[#f9f9f7]')
}
if (recommendedDraggingId.value === id) {
parts.push('opacity-50')
}
return parts.join(' ')
}
/**
* 하단 행 하이라이트(태그 관리와 동일)
* @param {string} id - 항목 id
* @returns {string}
*/
const footerRowClass = (id) => {
const parts = []
if (footerDragOverId.value === id) {
return 'bg-[#f9f9f7]'
parts.push('bg-[#f9f9f7]')
}
if (footerDraggingId.value === id) {
return 'opacity-50'
parts.push('opacity-50')
}
return ''
return parts.join(' ')
}
/**
* 드래그 중 대상 행에 표시할 드롭 구역 안내(형제 끼움 vs 하위 편입)
* @param {string} rowId - 행 항목 id
* @returns {string}
*/
const primaryDragZoneCaption = (rowId) => {
if (!navDraggingId.value || navDragOverId.value !== rowId || !navDragOverZone.value) {
return ''
}
const z = navDragOverZone.value
if (z === 'before') {
return '앞에 끼움'
}
if (z === 'after') {
return '뒤에 끼움'
}
return '하위로 넣기'
}
/**
* 상단 테이블 행 드래그·드롭 하이라이트(형제 구역=파랑 끝선, 하위=앰버 링)
* @param {string} id - 항목 id
* @returns {string}
*/
const primaryRowClass = (id) => {
const parts = ['cursor-move', 'relative']
if (navDragOverId.value === id) {
if (navDragOverZone.value === 'before') {
parts.push('border-t-[3px] border-blue-600 bg-blue-50/40')
} else if (navDragOverZone.value === 'after') {
parts.push('border-b-[3px] border-blue-600 bg-blue-50/40')
} else if (navDragOverZone.value === 'into') {
parts.push('bg-amber-50/50 ring-2 ring-inset ring-amber-500')
}
}
if (navDraggingId.value === id) {
parts.push('opacity-50')
}
return parts.join(' ')
}
/**
@@ -281,27 +597,6 @@ const addPrimaryRoot = () => {
})
}
/**
* 상단 특정 항목의 하위 추가
* @param {string} parentId - 부모 id
* @returns {void}
*/
const addPrimaryChild = (parentId) => {
const pid = String(parentId)
const siblings = items.value.filter((i) => i.location === 'primary' && String(i.parentId || '') === pid)
const maxOrder = Math.max(0, ...siblings.map((r) => r.sortOrder || 0))
items.value.push({
id: crypto.randomUUID(),
label: '새 하위 메뉴',
url: '#',
location: 'primary',
parentId: pid,
sortOrder: maxOrder + 10,
isVisible: true,
isFolder: false
})
}
/**
* 하단 항목 추가
* @returns {void}
@@ -321,6 +616,25 @@ const addFooterItem = () => {
})
}
/**
* 추천 사이트 항목 추가
* @returns {void}
*/
const addRecommendedItem = () => {
const list = items.value.filter((i) => i.location === 'recommended' && !i.parentId)
const maxOrder = Math.max(0, ...list.map((r) => r.sortOrder || 0))
items.value.push({
id: crypto.randomUUID(),
label: '',
url: 'https://',
location: 'recommended',
parentId: null,
sortOrder: maxOrder + 10,
isVisible: true,
isFolder: false
})
}
/**
* 네비게이션 항목 목록 저장
* @returns {Promise<void>} 저장 결과
@@ -379,7 +693,7 @@ const saveNavigation = async () => {
메뉴 관리
</h1>
<p class="admin-navigation__lead mt-2 max-w-xl text-sm text-muted">
상단·하단을 탭으로 나눕니다. 순서는 <span class="font-semibold text-ink">태그 관리의 메인 태그</span> 같이 행을 드래그합니다(번호·여백에서 잡고 이동). 입력란 위에서는 드래그가 시작되지 않습니다. 하위 메뉴가 있으면 공개 사이드바에서 접는 그룹으 보입니다.
상단 <span class="font-semibold text-ink">상단 메뉴 추가</span>로만 항목을 만들고, 행을 드래그해 순서·깊이를 정합니다. 상단 메뉴는 <span class="font-semibold text-ink">루트 아래 단계</span> 허용됩니다(하위 항목 행에는 가운데 드롭이 적용되지 않으며, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 없습니다). 같은 행의 <span class="font-semibold text-ink">위쪽 1/3</span> 놓으면 앞에 끼우고, <span class="font-semibold text-ink">아래쪽 1/3</span>이면 뒤에 끼우며, <span class="font-semibold text-ink">가운데</span> 놓으면 <span class="font-semibold text-ink">루트 항목</span> 하위로만 들어갑니다. 드래그 중에는 끝의 <span class="font-semibold text-blue-700">파란 </span> 형제 · 끼움, <span class="font-semibold text-amber-800">앰버 테두리</span> 하위 편입을 뜻하며, 개요 열에 짧은 안내가 함께 뜹니다. 왼쪽 개요 번호는 <span class="font-semibold text-ink">1 · 2.1 · 2.2</span>처럼 깊이별로 이어 붙입니다. 입력란 위에서는 드래그가 시작되지 않습니다. 하단·추천 사이트 탭은 평면 목록만 드래그로 순서 변경합니다. 추천 사이트는 공개 우측 사이드 Recommended에 카드 형태 노출되며, <span class="font-semibold text-ink">https://</span> 주소는 저장 후 브라우저에서 Google 파비콘 프록시 URL로 아이콘을 불러옵니다.
</p>
</div>
<div class="admin-navigation__header-actions flex flex-wrap gap-2">
@@ -411,6 +725,14 @@ const saveNavigation = async () => {
>
하단 네비게이션
</button>
<button
class="admin-navigation__tab -mb-px border-b-2 px-4 py-2 text-sm font-semibold transition-colors"
:class="activeTab === 'recommended' ? 'border-ink text-ink' : 'border-transparent text-muted hover:text-ink'"
type="button"
@click="activeTab = 'recommended'"
>
추천 사이트
</button>
</div>
<div v-show="activeTab === 'primary'" class="admin-navigation__panel-primary space-y-4">
@@ -428,19 +750,86 @@ const saveNavigation = async () => {
상단 메뉴가 없습니다. 버튼으로 항목을 추가하세요.
</div>
<AdminNavPrimaryBranch
v-else
:wraps="primaryTree"
parent-key="root"
:dragging-id="navDraggingId"
:drag-over-id="navDragOverId"
@drag-start="onPrimaryDragStart"
@drag-over="onPrimaryDragOver"
@drag-end="onPrimaryDragEnd"
@drop="onPrimaryDrop"
@add-child="addPrimaryChild"
@remove="removeItemCascade"
/>
<div v-else class="admin-navigation__primary-table overflow-hidden rounded border border-line bg-white">
<table class="admin-navigation__primary-table-inner w-full border-collapse text-left text-sm">
<thead class="admin-navigation__primary-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-navigation__primary-cell px-3 py-3">
개요
</th>
<th class="admin-navigation__primary-cell px-4 py-3">
라벨
</th>
<th class="admin-navigation__primary-cell px-4 py-3">
URL
</th>
<th class="admin-navigation__primary-cell px-4 py-3">
관리
</th>
</tr>
</thead>
<tbody class="admin-navigation__primary-body divide-y divide-line bg-white">
<tr
v-for="(row, index) in primaryRows"
:key="row.item.id"
class="admin-navigation__primary-row"
:class="primaryRowClass(row.item.id)"
draggable="true"
@dragstart="onPrimaryDragStart($event, row.item.id)"
@dragover="onPrimaryDragOverRow($event, row.item.id)"
@drop="onPrimaryDropRow($event, row.item.id)"
@dragend="onPrimaryDragEnd"
>
<td
class="admin-navigation__primary-cell w-[5.75rem] min-w-[5.75rem] px-3 py-3 align-middle"
>
<div class="admin-navigation__primary-outline flex flex-col items-end gap-0.5">
<span class="tabular-nums text-sm font-medium text-ink">
{{ primaryOutlineLabels[index] }}
</span>
<span
v-if="primaryDragZoneCaption(row.item.id)"
class="admin-navigation__primary-drop-hint max-w-[5.5rem] text-right text-[10px] font-semibold leading-tight"
:class="navDragOverZone === 'into' ? 'text-amber-800' : 'text-blue-700'"
>
{{ primaryDragZoneCaption(row.item.id) }}
</span>
</div>
</td>
<td
class="admin-navigation__primary-cell px-4 py-3 align-middle"
:style="{ paddingLeft: `${16 + row.depth * 28}px` }"
>
<input
v-model="row.item.label"
class="admin-navigation__primary-label w-full min-w-[8rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="라벨"
required
>
</td>
<td class="admin-navigation__primary-cell px-4 py-3 align-middle">
<input
v-model="row.item.url"
class="admin-navigation__primary-url w-full min-w-[10rem] rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="URL (# 또는 /경로)"
required
>
</td>
<td class="admin-navigation__primary-cell px-4 py-3 align-middle">
<button
class="admin-navigation__primary-remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700"
type="button"
@click="removeItemCascade(row.item.id)"
>
삭제
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-show="activeTab === 'footer'" class="admin-navigation__panel-footer space-y-4">
@@ -524,6 +913,90 @@ const saveNavigation = async () => {
</div>
</div>
<div v-show="activeTab === 'recommended'" class="admin-navigation__panel-recommended space-y-4">
<p class="admin-navigation__recommended-note max-w-xl text-sm text-muted">
공개 우측 사이드바 Recommended 영역에 표시됩니다. <code class="rounded bg-[#f0f1f3] px-1 py-0.5 text-xs">https://</code> 링크는 아이콘에 Google 파비콘 프록시를 사용합니다(내부 경로 <code class="rounded bg-[#f0f1f3] px-1 py-0.5 text-xs">/</code>만 있으면 아이콘은 생략).
</p>
<div class="flex flex-wrap gap-2">
<button
class="admin-navigation__add rounded border border-line bg-white px-4 py-2 text-sm font-semibold"
type="button"
@click="addRecommendedItem"
>
추천 사이트 추가
</button>
</div>
<div v-if="recommendedItemsSorted.length === 0" class="admin-navigation__empty rounded border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
추천 사이트가 없습니다.
</div>
<div v-else class="admin-navigation__recommended-table overflow-hidden rounded border border-line">
<table class="admin-navigation__recommended-table-inner w-full border-collapse text-left text-sm">
<thead class="admin-navigation__recommended-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-navigation__recommended-cell px-4 py-3">
#
</th>
<th class="admin-navigation__recommended-cell px-4 py-3">
제목
</th>
<th class="admin-navigation__recommended-cell px-4 py-3">
URL
</th>
<th class="admin-navigation__recommended-cell px-4 py-3">
관리
</th>
</tr>
</thead>
<tbody class="admin-navigation__recommended-body divide-y divide-line bg-white">
<tr
v-for="(item, index) in recommendedItemsSorted"
:key="item.id"
class="admin-navigation__recommended-row cursor-move"
:class="recommendedRowClass(item.id)"
draggable="true"
@dragstart="onRecommendedDragStart($event, item.id)"
@dragover="onRecommendedDragOver($event, item.id)"
@drop="onRecommendedDrop($event, item.id)"
@dragend="onRecommendedDragEnd"
>
<td class="admin-navigation__recommended-cell px-4 py-4 text-muted">
{{ index + 1 }}
</td>
<td class="admin-navigation__recommended-cell px-4 py-4">
<input
v-model="item.label"
class="w-full min-w-[8rem] rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="사이트 이름"
required
>
</td>
<td class="admin-navigation__recommended-cell px-4 py-4">
<input
v-model="item.url"
class="w-full min-w-[10rem] rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="https://…"
required
>
</td>
<td class="admin-navigation__recommended-cell px-4 py-4">
<button
class="rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700"
type="button"
@click="removeItemCascade(item.id)"
>
삭제
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
v-if="toast"
class="admin-navigation__toast fixed right-5 top-5 z-[100] max-w-sm rounded border px-4 py-3 text-sm font-semibold shadow-lg"

View File

@@ -3,12 +3,26 @@ definePageMeta({
layout: 'admin'
})
const router = useRouter()
const saving = ref(false)
const uploadingLogo = ref(false)
const errorMessage = ref('')
const toast = ref(null)
const logoInputRef = ref(null)
const mainScrollRef = ref(null)
const navSearchQuery = ref('')
const activeSectionId = ref('admin-settings-section-title')
const scrollSpySuspended = ref(false)
/** 블로그 제목·설명 카드 편집 모드 여부 */
const editTitleDesc = ref(false)
/** 편집 시작 시점의 제목·설명(취소 시 복원용) */
const titleDescSnapshot = reactive({
title: '',
description: ''
})
let toastTimer = null
let scrollSpyFrame = null
const { data: settings } = await useFetch('/admin/api/settings')
@@ -22,6 +36,142 @@ const form = reactive({
copyrightText: settings.value?.copyrightText || '©2026 sori.studio'
})
/**
* 설정 화면 좌측 내비 구역 정의
* @type {ReadonlyArray<{ heading: string, items: ReadonlyArray<{ id: string, label: string, keywords: string }> }>}
*/
const settingsNavGroups = [
{
heading: '일반',
items: [
{ id: 'admin-settings-section-title', label: '블로그 제목·설명', keywords: 'title description site name' },
{ id: 'admin-settings-section-timezone', label: '타임존', keywords: 'timezone seoul gmt' },
{ id: 'admin-settings-section-misc', label: '기타 설정', keywords: 'logo url copyright favicon' }
]
},
{
heading: '사이트',
items: [
{ id: 'admin-settings-section-announcement', label: '어나운스 바', keywords: 'announcement banner notice' }
]
},
{
heading: '콘텐츠·안전',
items: [
{ id: 'admin-settings-section-import-export', label: '게시물 Import/Export', keywords: 'import export backup' },
{ id: 'admin-settings-section-spam', label: '스팸 필터', keywords: 'spam moderation comments' }
]
}
]
/**
* 검색어에 맞춰 내비 그룹을 필터링한다.
* @returns {typeof settingsNavGroups} 표시할 그룹 목록
*/
const filteredSettingsNavGroups = computed(() => {
const q = navSearchQuery.value.trim().toLowerCase()
if (!q) {
return settingsNavGroups
}
return settingsNavGroups
.map((group) => {
const items = group.items.filter((item) => {
const hay = `${item.label} ${item.keywords}`.toLowerCase()
return hay.includes(q)
})
return { ...group, items }
})
.filter((group) => group.items.length > 0)
})
/**
* 스크롤 스파이: 본문 스크롤 위치에 맞춰 활성 섹션 id를 갱신한다.
* @returns {void}
*/
const updateActiveSectionFromScroll = () => {
const root = mainScrollRef.value
if (!root || scrollSpySuspended.value) {
return
}
const marker = root.getBoundingClientRect().top + 56
let nextId = settingsNavGroups[0].items[0].id
const flatIds = settingsNavGroups.flatMap((g) => g.items.map((i) => i.id))
for (const id of flatIds) {
const el = document.getElementById(id)
if (!el) {
continue
}
const top = el.getBoundingClientRect().top
if (top <= marker) {
nextId = id
}
}
activeSectionId.value = nextId
}
/**
* 스크롤 스파이 핸들러 (rAF 디바운스)
* @returns {void}
*/
const onMainScroll = () => {
if (scrollSpyFrame) {
cancelAnimationFrame(scrollSpyFrame)
}
scrollSpyFrame = requestAnimationFrame(() => {
scrollSpyFrame = null
updateActiveSectionFromScroll()
})
}
/**
* 해당 섹션으로 부드럽게 스크롤한다.
* @param {string} sectionId - 섹션 요소 id
* @returns {void}
*/
const scrollToSection = (sectionId) => {
const el = document.getElementById(sectionId)
if (!el) {
return
}
scrollSpySuspended.value = true
activeSectionId.value = sectionId
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
window.setTimeout(() => {
scrollSpySuspended.value = false
updateActiveSectionFromScroll()
}, 520)
}
/**
* 모바일 구역 선택 변경
* @param {Event} event - change 이벤트
* @returns {void}
*/
const onMobileNavChange = (event) => {
const target = event.target
if (!(target instanceof HTMLSelectElement) || !target.value) {
return
}
scrollToSection(target.value)
}
/**
* 설정 화면을 닫고 이전 관리 화면으로 돌아간다.
* @returns {Promise<void>}
*/
const closeSettings = async () => {
if (import.meta.client && window.history.length > 1) {
await router.back()
return
}
await navigateTo('/admin')
}
/**
* 저장 상태 토스트 표시
* @param {'success'|'error'|'info'} type - 토스트 타입
@@ -82,10 +232,26 @@ const uploadLogo = async (event) => {
}
/**
* 사이트 설정 저장
* @returns {Promise<void>} 저장 결과
* 사이트 설정 PUT 요청 본문을 구성한다.
* @returns {Object} API 본문
*/
const saveSettings = async () => {
const buildSiteSettingsPayload = () => ({
title: form.title,
description: form.description,
siteUrl: form.siteUrl,
logoText: form.logoText || '井',
logoUrl: form.logoUrl,
faviconUrl: form.faviconUrl,
copyrightText: form.copyrightText
})
/**
* 현재 폼 값으로 사이트 설정을 서버에 저장한다.
* @param {{ successToast?: string }} [options] - 성공 토스트 문구
* @returns {Promise<boolean>} 성공 여부
*/
const persistSiteSettings = async (options = {}) => {
const successToast = options.successToast || '사이트 설정이 저장되었습니다.'
saving.value = true
errorMessage.value = ''
showToast('info', '사이트 설정을 저장하는 중입니다.')
@@ -93,174 +259,487 @@ const saveSettings = async () => {
try {
const updatedSettings = await $fetch('/admin/api/settings', {
method: 'PUT',
body: {
title: form.title,
description: form.description,
siteUrl: form.siteUrl,
logoText: form.logoText || '井',
logoUrl: form.logoUrl,
faviconUrl: form.faviconUrl,
copyrightText: form.copyrightText
}
body: buildSiteSettingsPayload()
})
Object.assign(form, updatedSettings)
showToast('success', '사이트 설정이 저장되었습니다.')
showToast('success', successToast)
return true
} catch (error) {
errorMessage.value = error?.data?.message || '사이트 설정을 저장하지 못했습니다.'
showToast('error', errorMessage.value)
return false
} finally {
saving.value = false
}
}
/**
* 기타 설정 영역에서 전체 사이트 설정 저장
* @returns {Promise<void>}
*/
const saveSettings = async () => {
await persistSiteSettings()
}
/**
* 블로그 제목·설명 편집 모드 진입
* @returns {void}
*/
const beginEditTitleDesc = () => {
titleDescSnapshot.title = form.title
titleDescSnapshot.description = form.description
editTitleDesc.value = true
}
/**
* 블로그 제목·설명 편집 취소
* @returns {void}
*/
const cancelEditTitleDesc = () => {
form.title = titleDescSnapshot.title
form.description = titleDescSnapshot.description
editTitleDesc.value = false
}
/**
* 블로그 제목·설명만 저장하고 읽기 모드로 돌아간다.
* @returns {Promise<void>}
*/
const saveTitleDescSection = async () => {
const ok = await persistSiteSettings({ successToast: '블로그 제목·설명이 저장되었습니다.' })
if (ok) {
editTitleDesc.value = false
}
}
/**
* Escape 키: 제목·설명 편집 중이면 취소, 아니면 설정 화면 닫기
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const onGlobalKeydown = (event) => {
if (event.key !== 'Escape') {
return
}
if (editTitleDesc.value) {
event.preventDefault()
cancelEditTitleDesc()
return
}
closeSettings()
}
onMounted(() => {
if (import.meta.client) {
window.addEventListener('keydown', onGlobalKeydown)
nextTick(() => {
updateActiveSectionFromScroll()
})
}
})
onBeforeUnmount(() => {
window.clearTimeout(toastTimer)
if (scrollSpyFrame) {
cancelAnimationFrame(scrollSpyFrame)
}
if (import.meta.client) {
window.removeEventListener('keydown', onGlobalKeydown)
}
})
</script>
<template>
<section class="admin-settings bg-paper p-6">
<div class="admin-settings__header mb-8">
<p class="admin-settings__eyebrow text-xs font-semibold uppercase text-muted">
Settings
</p>
<h1 class="admin-settings__title mt-2 text-3xl font-semibold">
사이트 설정
</h1>
<div class="admin-settings-screen flex h-full min-h-0 flex-col bg-[#f7f8fa] text-[#15171a]">
<div
id="admin-settings-done-button-container"
class="pointer-events-none fixed right-0 top-2 z-50 flex justify-end bg-transparent p-8 md:top-0 md:px-8"
>
<button
id="admin-settings-done-button"
class="pointer-events-auto inline-flex cursor-pointer items-center justify-center rounded text-sm font-semibold whitespace-nowrap text-[#5d6673] transition hover:text-[#15171a]"
type="button"
title="닫기 (ESC)"
aria-label="설정 닫기"
@click="closeSettings"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="pointer-events-none size-5" fill="none" aria-hidden="true">
<line x1="0.75" y1="23.249" x2="23.25" y2="0.749" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<line x1="23.25" y1="23.249" x2="0.75" y2="0.749" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
</button>
</div>
<p v-if="errorMessage" class="admin-settings__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ errorMessage }}
</p>
<div class="admin-settings-screen__shell flex min-h-0 flex-1 justify-center overflow-hidden px-0 sm:px-4 lg:px-6">
<div class="admin-settings-screen__body flex min-h-0 w-full max-w-[1120px] flex-1 flex-col lg:flex-row">
<aside class="admin-settings-screen__nav-column w-full shrink-0 border-b border-[#e6e8eb] bg-[#f7f8fa] lg:w-72 lg:max-w-[320px] lg:flex-none lg:border-b-0 lg:border-r lg:border-[#e6e8eb]">
<div class="admin-settings-screen__nav-inner max-h-[40vh] overflow-y-auto p-4 lg:max-h-none lg:h-full lg:overflow-y-auto lg:p-5">
<label class="admin-settings-screen__search relative mb-4 block">
<span class="sr-only">설정 검색</span>
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#9aa3ad]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" stroke="currentColor" stroke-width="1.5" />
<path d="M16 16l4.5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
<input
v-model="navSearchQuery"
class="admin-settings-screen__search-input w-full rounded-md border border-[#dce0e5] bg-white py-2 pr-3 pl-9 text-sm text-[#15171a] placeholder:text-[#9aa3ad] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="search"
autocomplete="off"
placeholder="설정 검색"
>
</label>
<form class="admin-settings__form grid max-w-4xl gap-6" @submit.prevent="saveSettings">
<section class="admin-settings__logo rounded-xl border border-line bg-white p-5">
<div class="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
<div class="flex items-center gap-4">
<div class="admin-settings__logo-preview grid h-20 w-20 shrink-0 place-items-center overflow-hidden rounded-2xl border border-line bg-paper">
<img
v-if="form.logoUrl"
class="h-full w-full object-cover"
:src="form.logoUrl"
alt="사이트 로고"
>
<span v-else class="text-2xl font-semibold text-muted">
{{ form.logoText || '井' }}
</span>
<p
v-if="filteredSettingsNavGroups.length === 0"
class="admin-settings-screen__nav-empty rounded-md border border-dashed border-[#dce0e5] px-3 py-4 text-center text-xs text-[#657080]"
>
검색과 일치하는 설정 항목이 없습니다.
</p>
<nav v-else class="admin-settings-screen__nav grid gap-5 text-sm" aria-label="설정 구역">
<template v-for="(group, gi) in filteredSettingsNavGroups" :key="group.heading">
<div v-if="gi > 0" class="admin-settings-screen__nav-separator h-px bg-[#dce0e5]" aria-hidden="true" />
<div class="admin-settings-screen__nav-group">
<p class="admin-settings-screen__nav-heading mb-2 px-2 text-xs font-semibold tracking-wide text-[#9aa3ad] uppercase">
{{ group.heading }}
</p>
<ul class="admin-settings-screen__nav-list grid gap-0.5">
<li v-for="item in group.items" :key="item.id">
<button
class="admin-settings-screen__nav-item flex w-full items-center gap-2 rounded-md px-2 py-2 text-left font-medium text-[#5d6673] transition-colors hover:bg-[#eceff2] hover:text-[#15171a]"
:class="activeSectionId === item.id ? 'bg-[#e9ecef] text-[#15171a]' : ''"
type="button"
@click="scrollToSection(item.id)"
>
<span class="min-w-0 flex-1 truncate">{{ item.label }}</span>
</button>
</li>
</ul>
</div>
</template>
</nav>
<label class="admin-settings-screen__nav-jump mt-4 lg:hidden">
<span class="mb-1 block text-xs font-medium text-[#657080]">구역 이동</span>
<select
class="w-full rounded-md border border-[#dce0e5] bg-white px-2 py-2 text-sm"
:value="activeSectionId"
@change="onMobileNavChange"
>
<optgroup v-for="group in settingsNavGroups" :key="group.heading" :label="group.heading">
<option v-for="item in group.items" :key="item.id" :value="item.id">
{{ item.label }}
</option>
</optgroup>
</select>
</label>
</div>
</aside>
<main
ref="mainScrollRef"
class="admin-settings-screen__content min-h-0 min-w-0 flex-1 overflow-y-auto bg-white lg:border-l lg:border-[#e6e8eb]"
@scroll.passive="onMainScroll"
>
<div class="admin-settings-screen__content-inner mx-auto mb-[60vh] w-full max-w-[760px] px-8 pt-16 pb-24 md:px-14 md:pt-10">
<p v-if="errorMessage" class="admin-settings-screen__error mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ errorMessage }}
</p>
<form class="admin-settings-screen__form w-full space-y-8" @submit.prevent="saveSettings">
<h2 class="admin-settings-screen__section-heading z-20 -mt-[5px] mb-px pt-10 text-2xl font-bold tracking-tight text-[#15171a]">
일반 설정
</h2>
<section
id="admin-settings-section-title"
data-testid="title-and-description"
class="admin-settings-screen__card admin-settings-screen__card--title-desc relative flex flex-col gap-6 rounded-xl border border-[#d8dce1] bg-white p-5 transition-all hover:border-[#c5cbd3] hover:shadow-sm md:p-7"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<h5 class="text-base font-semibold text-[#15171a] md:text-lg">
블로그 제목·설명
</h5>
<p
v-if="!editTitleDesc"
class="mr-5 mt-1 text-pretty text-sm leading-relaxed text-[#657080] md:block"
>
공개 사이트에서 사이트 이름과 소개로 사용되는 값입니다.
</p>
</div>
<div class="-mr-1 mt-[-5px] flex shrink-0 items-center justify-start gap-2">
<template v-if="!editTitleDesc">
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black"
type="button"
@click="beginEditTitleDesc"
>
편집
</button>
</template>
<template v-else>
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#5d6673] transition hover:bg-[#eceff2] hover:text-[#15171a]"
type="button"
:disabled="saving"
@click="cancelEditTitleDesc"
>
취소
</button>
<button
class="inline-flex h-7 cursor-pointer items-center justify-center rounded px-3 text-sm font-semibold whitespace-nowrap text-[#15171a] transition hover:bg-[#eceff2] hover:text-black disabled:opacity-50"
type="button"
:disabled="saving"
@click="saveTitleDescSection"
>
{{ saving ? '저장 중' : '저장' }}
</button>
</template>
</div>
</div>
<div>
<h2 class="text-base font-semibold text-ink">로고</h2>
<p class="mt-1 max-w-md text-sm leading-6 text-muted">
1:1 비율 이미지로 등록합니다. 같은 이미지가 공개 로고와 파비콘으로 함께 사용됩니다.
<div
v-if="!editTitleDesc"
class="grid grid-cols-1 gap-x-8 gap-y-6 md:grid-cols-2 md:gap-y-7"
>
<div class="flex flex-col">
<h6 class="block text-sm font-medium tracking-normal text-[#3f4650]">
사이트 이름
</h6>
<div class="mt-1 flex min-h-[1.5rem] items-center text-[#15171a]">
{{ form.title?.trim() ? form.title : '—' }}
</div>
</div>
<div class="flex flex-col">
<h6 class="block text-sm font-medium tracking-normal text-[#3f4650]">
사이트 설명
</h6>
<div class="mt-1 flex min-h-[1.5rem] items-start whitespace-pre-wrap text-[#15171a]">
{{ form.description?.trim() ? form.description : '—' }}
</div>
</div>
</div>
<div v-else class="admin-settings-screen__card-body grid gap-5">
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">사이트 이름</span>
<input
v-model="form.title"
class="rounded-md border border-[#dce0e5] bg-white px-3 py-2 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="text"
required
>
</label>
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">사이트 설명</span>
<textarea
v-model="form.description"
class="min-h-28 resize-y rounded-md border border-[#dce0e5] bg-white px-3 py-2 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
required
/>
</label>
<div class="rounded-lg border border-[#eceff2] bg-[#f7f8fa] p-4">
<p class="text-xs font-semibold tracking-wide text-[#657080] uppercase">
공개 화면 미리보기
</p>
<div class="mt-3 flex items-center gap-3">
<div class="grid h-12 w-12 shrink-0 place-items-center overflow-hidden rounded-xl bg-[#15171a] text-lg font-bold text-white">
<img
v-if="form.logoUrl"
class="h-full w-full object-cover"
:src="form.logoUrl"
alt=""
>
<span v-else>{{ form.logoText || '' }}</span>
</div>
<div class="min-w-0">
<p class="truncate font-semibold text-[#15171a]">
{{ form.title || 'sori.studio' }}
</p>
<p class="truncate text-sm text-[#657080]">
{{ form.description || '사이트 설명이 공개 화면에 표시됩니다.' }}
</p>
</div>
</div>
</div>
</div>
</section>
<section
id="admin-settings-section-timezone"
class="admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
>
<div class="admin-settings-screen__card-head mb-2">
<h2 class="text-lg font-semibold text-[#15171a]">
타임존
</h2>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
게시 시각·예약 발행 등에 사용할 표준 시간대입니다. (준비 )
</p>
</div>
</div>
<button
class="admin-settings__logo-button h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#15171a] transition hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
:disabled="uploadingLogo"
@click="openLogoFilePicker"
<div class="admin-settings-screen__placeholder mt-4 rounded-lg border border-dashed border-[#dce0e5] bg-[#f7f8fa] px-4 py-8 text-center text-sm text-[#657080]">
이후 버전에서 타임존 선택과 현지 시각 미리보기를 제공합니다.
</div>
</section>
<section
id="admin-settings-section-misc"
class="admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
>
{{ uploadingLogo ? '업로드 중' : form.logoUrl ? '로고 변경' : '로고 등록' }}
</button>
<input
ref="logoInputRef"
class="hidden"
type="file"
accept="image/jpeg,image/png,image/webp"
:disabled="uploadingLogo"
@change="uploadLogo"
<div class="admin-settings-screen__card-head mb-6">
<h2 class="text-lg font-semibold text-[#15171a]">
기타 설정
</h2>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
로고, 공개 URL, 푸터 저작권 문구를 관리합니다.
</p>
</div>
<div class="admin-settings-screen__card-body grid gap-6">
<div class="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
<div class="flex items-center gap-4">
<div class="grid h-20 w-20 shrink-0 place-items-center overflow-hidden rounded-2xl border border-[#e6e8eb] bg-[#f7f8fa]">
<img
v-if="form.logoUrl"
class="h-full w-full object-cover"
:src="form.logoUrl"
alt="사이트 로고"
>
<span v-else class="text-2xl font-semibold text-[#9aa3ad]">
{{ form.logoText || '井' }}
</span>
</div>
<div>
<h3 class="text-base font-semibold text-[#15171a]">
로고
</h3>
<p class="mt-1 max-w-md text-sm leading-relaxed text-[#657080]">
1:1 비율 이미지로 등록합니다. 같은 이미지가 공개 로고와 파비콘으로 함께 사용됩니다.
</p>
</div>
</div>
<button
class="h-10 shrink-0 rounded-md border border-[#dce0e5] bg-white px-4 text-sm font-semibold text-[#15171a] transition-colors hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
:disabled="uploadingLogo"
@click="openLogoFilePicker"
>
{{ uploadingLogo ? '업로드 중' : form.logoUrl ? '로고 변경' : '로고 등록' }}
</button>
<input
ref="logoInputRef"
class="hidden"
type="file"
accept="image/jpeg,image/png,image/webp"
:disabled="uploadingLogo"
@change="uploadLogo"
>
</div>
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">사이트 URL</span>
<input
v-model="form.siteUrl"
class="rounded-md border border-[#dce0e5] bg-white px-3 py-2 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="url"
required
>
</label>
<label class="admin-settings-screen__field grid gap-2 text-sm">
<span class="font-medium text-[#3f4650]">저작권 문구</span>
<input
v-model="form.copyrightText"
class="rounded-md border border-[#dce0e5] bg-white px-3 py-2 text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
type="text"
required
>
</label>
</div>
<div class="admin-settings-screen__actions mt-8 flex justify-end border-t border-[#eceff2] pt-6">
<button
class="rounded-md bg-[#15171a] px-5 py-2.5 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
type="submit"
:disabled="saving"
>
{{ saving ? '저장 중' : '설정 저장' }}
</button>
</div>
</section>
<h2 class="admin-settings-screen__section-heading z-20 mb-px pt-10 text-2xl font-bold tracking-tight text-[#15171a]">
사이트
</h2>
<section
id="admin-settings-section-announcement"
class="admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
>
<div class="admin-settings-screen__card-head mb-2">
<h2 class="text-lg font-semibold text-[#15171a]">
어나운스
</h2>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
사이트 상단 공지 배너 문구와 링크를 설정합니다. (준비 )
</p>
</div>
<div class="admin-settings-screen__placeholder mt-4 rounded-lg border border-dashed border-[#dce0e5] bg-[#f7f8fa] px-4 py-8 text-center text-sm text-[#657080]">
이후 버전에서 노출 조건·스타일 옵션과 함께 제공합니다.
</div>
</section>
<h2 class="admin-settings-screen__section-heading z-20 mb-px pt-10 text-2xl font-bold tracking-tight text-[#15171a]">
콘텐츠·안전
</h2>
<section
id="admin-settings-section-import-export"
class="admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
>
<div class="admin-settings-screen__card-head mb-2">
<h2 class="text-lg font-semibold text-[#15171a]">
게시물 Import/Export
</h2>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
마크다운 형식으로 게시물을 가져오거나 보냅니다. (준비 )
</p>
</div>
<div class="admin-settings-screen__placeholder mt-4 rounded-lg border border-dashed border-[#dce0e5] bg-[#f7f8fa] px-4 py-8 text-center text-sm text-[#657080]">
이후 버전에서 일괄 가져오기·보내기 도구를 제공합니다.
</div>
</section>
<section
id="admin-settings-section-spam"
class="admin-settings-screen__card rounded-xl border border-[#e6e8eb] bg-white p-6 shadow-[0_1px_2px_rgba(15,23,42,0.04)]"
>
<div class="admin-settings-screen__card-head mb-2">
<h2 class="text-lg font-semibold text-[#15171a]">
스팸 필터
</h2>
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
댓글·가입 등에서 스팸을 줄이기 위한 규칙을 설정합니다. (준비 )
</p>
</div>
<div class="admin-settings-screen__placeholder mt-4 rounded-lg border border-dashed border-[#dce0e5] bg-[#f7f8fa] px-4 py-8 text-center text-sm text-[#657080]">
이후 버전에서 키워드·링크 제한 옵션을 제공합니다.
</div>
</section>
</form>
</div>
</section>
<label class="admin-settings__field grid gap-2 text-sm">
<span class="admin-settings__label font-medium">사이트 이름</span>
<input
v-model="form.title"
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
type="text"
required
>
</label>
<label class="admin-settings__field grid gap-2 text-sm">
<span class="admin-settings__label font-medium">사이트 설명</span>
<textarea
v-model="form.description"
class="admin-settings__textarea min-h-28 resize-y rounded border border-line bg-white px-3 py-2"
required
/>
</label>
<label class="admin-settings__field grid gap-2 text-sm">
<span class="admin-settings__label font-medium">사이트 URL</span>
<input
v-model="form.siteUrl"
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
type="url"
required
>
</label>
<label class="admin-settings__field grid gap-2 text-sm">
<span class="admin-settings__label font-medium">저작권 문구</span>
<input
v-model="form.copyrightText"
class="admin-settings__input rounded border border-line bg-white px-3 py-2"
type="text"
required
>
</label>
<div class="admin-settings__preview rounded-xl border border-line bg-white p-5">
<p class="admin-settings__preview-label text-xs font-semibold uppercase text-muted">
공개 화면 미리보기
</p>
<div class="admin-settings__preview-body mt-4 flex items-center gap-3">
<div class="admin-settings__preview-logo grid h-12 w-12 place-items-center overflow-hidden rounded-xl bg-[#15171a] text-2xl font-bold text-white">
<img
v-if="form.logoUrl"
class="h-full w-full object-cover"
:src="form.logoUrl"
alt=""
>
<span v-else>{{ form.logoText || '' }}</span>
</div>
<div>
<p class="admin-settings__preview-title font-semibold">
{{ form.title || 'sori.studio' }}
</p>
<p class="admin-settings__preview-description text-sm text-muted">
{{ form.description || '사이트 설명이 공개 화면에 표시됩니다.' }}
</p>
</div>
</div>
</div>
<div class="admin-settings__actions flex justify-end border-t border-line pt-5">
<button
class="admin-settings__submit rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
type="submit"
:disabled="saving"
>
{{ saving ? '저장 중' : '설정 저장' }}
</button>
</div>
</form>
</main>
</div>
</div>
<div
v-if="toast"
class="admin-settings__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
class="admin-settings-screen__toast fixed right-5 top-24 z-[60] rounded-md border px-4 py-3 text-sm font-semibold shadow-lg md:top-28"
:class="{
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
'border-line bg-white text-ink': toast.type === 'info'
'border-[#e6e8eb] bg-white text-[#15171a]': toast.type === 'info'
}"
role="status"
>
{{ toast.message }}
</div>
</section>
</div>
</template>

View File

@@ -2,6 +2,6 @@ import { getPublicNavigation } from '../repositories/content-repository'
/**
* 공개 네비게이션 API
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} `primary`는 트리(`children` 선택), `footer`는 평면
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>, recommended: Array<Object>}>} `primary`는 트리(`children` 선택), `footer`·`recommended`는 평면
*/
export default defineEventHandler(() => getPublicNavigation())

View File

@@ -894,7 +894,7 @@ export const listNavigationItems = async ({ visibleOnly = false } = {}) => {
/**
* 공개 네비게이션 조회
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} 위치별 공개 네비게이션
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>, recommended: Array<Object>}>} 위치별 공개 네비게이션
*/
export const getPublicNavigation = async () => {
const flat = await listNavigationItems({ visibleOnly: true })
@@ -902,6 +902,9 @@ export const getPublicNavigation = async () => {
const footerFlat = flat
.filter((item) => item.location === 'footer' && !item.parentId)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
const recommendedFlat = flat
.filter((item) => item.location === 'recommended' && !item.parentId)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
return {
primary: buildPublicPrimaryTree(primaryFlat),
@@ -910,6 +913,12 @@ export const getPublicNavigation = async () => {
label: item.label,
url: item.url,
isVisible: item.isVisible
})),
recommended: recommendedFlat.map((item) => ({
id: item.id,
label: item.label,
url: item.url,
isVisible: item.isVisible
}))
}
}

View File

@@ -62,6 +62,7 @@ export default defineEventHandler(async (event) => {
renumberSortOrderByTree(items, 'primary')
renumberSortOrderByTree(items, 'footer')
renumberSortOrderByTree(items, 'recommended')
applyNavigationDerivedFlags(items)
try {

View File

@@ -4,17 +4,17 @@ export const adminNavigationItemInputSchema = z.object({
id: z.string().uuid(),
label: z.string().trim().min(1),
url: z.string().trim().min(1).regex(/^(\/|https?:\/\/|#)/),
location: z.enum(['primary', 'footer']),
location: z.enum(['primary', 'footer', 'recommended']),
sortOrder: z.coerce.number().int().min(0).default(0),
isVisible: z.boolean().default(true),
parentId: z.union([z.string().uuid(), z.null()]).optional(),
isFolder: z.boolean().default(false)
}).superRefine((data, ctx) => {
if (data.location === 'footer' && data.parentId) {
if ((data.location === 'footer' || data.location === 'recommended') && data.parentId) {
ctx.addIssue({
code: 'custom',
path: ['parentId'],
message: '하단 메뉴는 하위 항목을 가질 수 없습니다.'
message: '해당 위치 메뉴는 하위 항목을 가질 수 없습니다.'
})
}
})

View File

@@ -35,6 +35,13 @@ export const validateNavigationItems = (items) => {
}
}
if (loc === 'recommended') {
const p = item.parentId
if (p != null && String(p).trim() !== '') {
return { ok: false, message: '추천 사이트에는 하위 항목을 둘 수 없습니다.' }
}
}
const p = item.parentId
if (p != null && String(p).trim() !== '') {
const pid = String(p).trim()
@@ -43,7 +50,13 @@ export const validateNavigationItems = (items) => {
}
const parent = byId.get(pid)
if (parent.location !== loc) {
return { ok: false, message: '상위 메뉴는 같은 위치(상단/하단)에 있어야 합니다.' }
return { ok: false, message: '상위 메뉴는 같은 위치(상단/하단/추천)에 있어야 합니다.' }
}
if (loc === 'primary') {
const gp = parent.parentId
if (gp != null && String(gp).trim() !== '') {
return { ok: false, message: '상단 메뉴는 하위 한 단계까지만 허용됩니다.' }
}
}
}
}
@@ -136,7 +149,7 @@ export const orderNavigationItemsForInsert = (items) => {
/**
* location 기준 DFS로 sort_order를 10단위로 다시 부여한다.
* @param {Array<Object>} items - 전체 항목(변경됨)
* @param {'primary'|'footer'} location - 위치
* @param {'primary'|'footer'|'recommended'} location - 위치
* @returns {void}
*/
export const renumberSortOrderByTree = (items, location) => {
@@ -212,8 +225,14 @@ export const buildPublicPrimaryTree = (flatPrimary) => {
const p = row.parentId
const pid = p != null && String(p).trim() !== '' ? String(p).trim() : ''
if (pid && pid !== id && byId.has(pid)) {
byId.get(pid).children.push(node)
attachedAsChild.add(id)
const parentRow = list.find((r) => String(r.id) === pid)
const parentIsRoot =
parentRow &&
(parentRow.parentId == null || String(parentRow.parentId).trim() === '')
if (parentIsRoot) {
byId.get(pid).children.push(node)
attachedAsChild.add(id)
}
}
}