메뉴 관리 개편, 추천 사이트·1뎁스 네비, 우측 Recommended 연동(v1.1.13)
상단 네비는 평면 테이블·드래그로 편집하고 한 단계 하위만 허용한다. 추천 사이트 탭·location recommended·공개 API와 우측 사이드 카드·파비콘 URL 유틸을 추가한다. 문서·배포 마이그레이션 안내·관리자 레이아웃·설정 화면 등 누적 변경을 반영한다.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -15,7 +15,8 @@ const { data: tags } = await useFetch('/api/tags', {
|
||||
const { data: navigation } = await useFetch('/api/navigation', {
|
||||
default: () => ({
|
||||
primary: [],
|
||||
footer: []
|
||||
footer: [],
|
||||
recommended: []
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
7
db/migrations/024_navigation_recommended_location.sql
Normal file
7
db/migrations/024_navigation_recommended_location.sql
Normal 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'));
|
||||
@@ -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
|
||||
|
||||
- 관리자 글 목록에 상태·태그·정렬 필터와 댓글 수 표시를 추가.
|
||||
|
||||
@@ -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 네트워크 충돌 대응
|
||||
|
||||
@@ -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
|
||||
|
||||
### 추천 글을 저장 필드로 분리
|
||||
|
||||
13
docs/map.md
13
docs/map.md
@@ -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 | 네비게이션 항목 테이블 추가 |
|
||||
|
||||
13
docs/spec.md
13
docs/spec.md
@@ -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로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
|
||||
|
||||
@@ -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`) 저장 필드와 글쓰기 사이드바 추천 토글 추가.
|
||||
|
||||
@@ -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 />
|
||||
|
||||
32
lib/external-favicon-url.js
Normal file
32
lib/external-favicon-url.js
Normal 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 ''
|
||||
}
|
||||
}
|
||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.1.9",
|
||||
"version": "1.1.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
renumberSortOrderByTree(items, 'primary')
|
||||
renumberSortOrderByTree(items, 'footer')
|
||||
renumberSortOrderByTree(items, 'recommended')
|
||||
applyNavigationDerivedFlags(items)
|
||||
|
||||
try {
|
||||
|
||||
@@ -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: '해당 위치 메뉴는 하위 항목을 가질 수 없습니다.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user