메뉴 관리 기능 추가
This commit is contained in:
@@ -1053,7 +1053,6 @@ const activateBlock = (block) => {
|
||||
* @returns {boolean} placeholder 표시 여부
|
||||
*/
|
||||
const shouldShowPlaceholder = (block, index) => !block.text && (
|
||||
activeBlockId.value === block.id ||
|
||||
(index === 0 && editorBlocks.value.length === 1) ||
|
||||
index === editorBlocks.value.length - 1
|
||||
)
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
const { data: tags } = await useFetch('/api/tags', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const { data: navigation } = await useFetch('/api/navigation', {
|
||||
default: () => ({
|
||||
primary: [],
|
||||
footer: []
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -9,29 +16,13 @@ const { data: tags } = await useFetch('/api/tags', {
|
||||
<div class="left-sidebar__scroll min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="left-sidebar__block site-sidebar-section py-3 pl-0 pr-3">
|
||||
<nav class="left-sidebar__nav grid gap-1 text-[15px]">
|
||||
<NuxtLink class="left-sidebar__nav-link flex items-center justify-between py-2 pl-3" to="/">
|
||||
<span>Home pages</span>
|
||||
<span>⌄</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="left-sidebar__nav-link py-2 pl-3" to="/tags">
|
||||
Tags
|
||||
</NuxtLink>
|
||||
<NuxtLink class="left-sidebar__nav-link py-2 pl-3" to="/pages/about">
|
||||
Authors
|
||||
</NuxtLink>
|
||||
<NuxtLink class="left-sidebar__nav-link py-2 pl-3" to="/post/hello-sori-studio">
|
||||
Style
|
||||
</NuxtLink>
|
||||
<NuxtLink class="left-sidebar__nav-link flex items-center justify-between py-2 pl-3" to="/post/custom-writing-tool">
|
||||
<span>Post types</span>
|
||||
<span>⌄</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="left-sidebar__nav-link flex items-center justify-between py-2 pl-3" to="/pages/contact">
|
||||
<span>Members</span>
|
||||
<span>⌄</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="left-sidebar__nav-link py-2 pl-3" to="/pages/projects">
|
||||
Landing pages
|
||||
<NuxtLink
|
||||
v-for="item in navigation.primary"
|
||||
:key="item.id"
|
||||
class="left-sidebar__nav-link py-2 pl-3"
|
||||
:to="item.url"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -74,14 +65,12 @@ const { data: tags } = await useFetch('/api/tags', {
|
||||
|
||||
<footer class="left-sidebar__footer flex items-center justify-between px-1 py-4 text-xs">
|
||||
<nav class="left-sidebar__footer-nav flex gap-4">
|
||||
<NuxtLink to="/pages/links">
|
||||
Portal
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/pages/about">
|
||||
Docs
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/pages/projects">
|
||||
Projects
|
||||
<NuxtLink
|
||||
v-for="item in navigation.footer"
|
||||
:key="item.id"
|
||||
:to="item.url"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
<span class="left-sidebar__theme-dot">☾</span>
|
||||
|
||||
29
db/migrations/005_add_navigation_items.sql
Normal file
29
db/migrations/005_add_navigation_items.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE IF NOT EXISTS navigation_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
label TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
location TEXT NOT NULL DEFAULT 'primary',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_visible BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (location, label, url),
|
||||
CONSTRAINT navigation_items_location_check CHECK (location IN ('primary', 'footer'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS navigation_items_location_sort_order_idx
|
||||
ON navigation_items (location, sort_order ASC, label ASC);
|
||||
|
||||
INSERT INTO navigation_items (label, url, location, sort_order, is_visible)
|
||||
VALUES
|
||||
('Home pages', '/', 'primary', 10, true),
|
||||
('Tags', '/tags', 'primary', 20, true),
|
||||
('Authors', '/pages/about', 'primary', 30, true),
|
||||
('Style', '/post/hello-sori-studio', 'primary', 40, true),
|
||||
('Post types', '/post/custom-writing-tool', 'primary', 50, true),
|
||||
('Members', '/pages/contact', 'primary', 60, true),
|
||||
('Landing pages', '/pages/projects', 'primary', 70, true),
|
||||
('Portal', '/pages/links', 'footer', 10, true),
|
||||
('Docs', '/pages/about', 'footer', 20, true),
|
||||
('Projects', '/pages/projects', 'footer', 30, true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -1,5 +1,13 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-02 v0.0.25
|
||||
|
||||
### 빈 문단 placeholder 표시와 네비게이션 관리 범위 결정
|
||||
|
||||
블록 에디터의 `/` 안내 문구는 첫 빈 화면이거나 마지막 보조 입력 블록일 때만 표시한다. 사용자가 중간에 의도적으로 만든 빈 문단에도 같은 안내가 반복되면 작성 중인 여백이 오류처럼 보이고, 실제 내용보다 placeholder가 더 강하게 눈에 들어오기 때문이다.
|
||||
|
||||
네비게이션 관리는 1차로 공개 왼쪽 사이드바의 상단 메뉴와 하단 링크를 대상으로 한다. 기존 화면에서 이미 해당 영역이 메뉴 역할을 하고 있으므로 새 UI 영역을 만들기보다 하드코딩된 항목을 `navigation_items` 테이블로 옮겨 관리자에서 라벨, URL, 위치, 순서, 표시 여부를 조정할 수 있게 한다.
|
||||
|
||||
## 2026-05-02 v0.0.24
|
||||
|
||||
### 빈 줄 입력 보존과 사이트 설정 범위 결정
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단 |
|
||||
| components/site/LeftSidebar.vue | 메인 화면 왼쪽 |
|
||||
| components/site/LeftSidebar.vue | 메인 화면 왼쪽, 네비게이션과 태그 목록 |
|
||||
| components/site/RightSidebar.vue | 메인 화면 오른쪽, 사이트 설정 표시 |
|
||||
| components/site/MainColumn.vue | 메인 화면 중앙 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일 |
|
||||
@@ -64,6 +64,7 @@
|
||||
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
|
||||
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
|
||||
| pages/admin/media/index.vue | 미디어 관리 |
|
||||
| pages/admin/navigation/index.vue | 메뉴/네비게이션 관리 |
|
||||
| pages/admin/tags/index.vue | 태그 관리 |
|
||||
| pages/admin/tags/new.vue | 태그 생성 |
|
||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||
@@ -92,6 +93,7 @@
|
||||
| server/api/pages/[slug].get.js | 고정 페이지 상세 샘플 API |
|
||||
| server/api/tags.get.js | 태그 목록 샘플 API |
|
||||
| server/api/site-settings.get.js | 공개 사이트 설정 API |
|
||||
| server/api/navigation.get.js | 공개 네비게이션 API |
|
||||
| server/routes/admin/api/auth/login.post.js | 관리자 로그인 API |
|
||||
| server/routes/admin/api/auth/logout.post.js | 관리자 로그아웃 API |
|
||||
| server/routes/admin/api/auth/me.get.js | 관리자 세션 조회 API |
|
||||
@@ -116,14 +118,18 @@
|
||||
| server/routes/admin/api/tags/[id].delete.js | 관리자 태그 삭제 API |
|
||||
| server/routes/admin/api/settings.get.js | 관리자 사이트 설정 조회 API |
|
||||
| server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API |
|
||||
| server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API |
|
||||
| server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API |
|
||||
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
||||
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
||||
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
|
||||
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
|
||||
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
|
||||
| server/utils/admin-navigation-input.js | 관리자 네비게이션 입력값 검증 스키마 |
|
||||
| server/utils/admin-tag-input.js | 관리자 태그 입력값 검증 스키마 |
|
||||
| server/utils/site-settings.js | 사이트 설정 기본값 유틸리티 |
|
||||
| server/utils/navigation-items.js | 네비게이션 기본값과 그룹 유틸리티 |
|
||||
| server/utils/media-library.js | 업로드 미디어 파일 관리 유틸리티 |
|
||||
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
|
||||
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
|
||||
@@ -136,6 +142,7 @@
|
||||
| db/migrations/002_seed_development.sql | 개발용 샘플 데이터 |
|
||||
| db/migrations/003_add_tag_display_fields.sql | 태그 표시 순서와 색상 필드 추가 |
|
||||
| db/migrations/004_add_site_settings.sql | 사이트 설정 테이블 추가 |
|
||||
| db/migrations/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 |
|
||||
|
||||
## 설정/배포
|
||||
|
||||
|
||||
24
docs/spec.md
24
docs/spec.md
@@ -166,6 +166,19 @@ components/content/
|
||||
| copyright_text | String | 저작권 문구 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
### NavigationItems
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | UUID | Primary Key |
|
||||
| label | String | 메뉴 표시 이름 |
|
||||
| url | String | 내부 경로 또는 외부 URL |
|
||||
| location | Enum | primary/footer |
|
||||
| sort_order | Integer | 표시 순서 |
|
||||
| is_visible | Boolean | 공개 화면 표시 여부 |
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
### PostTags (다대다)
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
@@ -198,6 +211,7 @@ components/content/
|
||||
- `GET /api/pages/:slug` - 고정 페이지 상세
|
||||
- `GET /api/tags` - 태그 목록
|
||||
- `GET /api/site-settings` - 공개 사이트 설정
|
||||
- `GET /api/navigation` - 공개 네비게이션
|
||||
|
||||
### 관리자 API (`/admin/api/`)
|
||||
|
||||
@@ -225,6 +239,8 @@ components/content/
|
||||
- `DELETE /admin/api/tags/:id` - 태그 삭제
|
||||
- `GET /admin/api/settings` - 사이트 설정 조회
|
||||
- `PUT /admin/api/settings` - 사이트 설정 수정
|
||||
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
||||
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
||||
|
||||
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
||||
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
||||
@@ -284,6 +300,14 @@ components/content/
|
||||
- 공개 헤더와 오른쪽 사이드바는 공개 사이트 설정 API 값을 사용한다.
|
||||
- DB 연결이 없는 환경에서는 환경 변수와 기본값 기반 설정을 사용한다.
|
||||
|
||||
### 메뉴/네비게이션
|
||||
|
||||
- 네비게이션은 `navigation_items` 테이블로 관리한다.
|
||||
- 관리자는 메뉴 라벨, URL, 위치, 순서, 표시 여부를 수정할 수 있다.
|
||||
- 공개 왼쪽 사이드바의 상단 메뉴는 `primary` 위치 항목을 사용한다.
|
||||
- 공개 왼쪽 사이드바 하단 메뉴는 `footer` 위치 항목을 사용한다.
|
||||
- URL은 `/`로 시작하는 내부 경로 또는 `http://`, `https://` 외부 URL을 허용한다.
|
||||
|
||||
### 관리자 인증
|
||||
|
||||
- 초기 관리자 인증은 `ADMIN_EMAIL`, `ADMIN_PASSWORD` 환경 변수를 사용
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
- [ ] 페이지 관리 브라우저 수동 QA: 생성, 수정, 삭제, 공개 보기 확인
|
||||
- [ ] 사이트 설정 브라우저 수동 QA: 저장, 공개 헤더/사이드바 반영, 잘못된 URL 실패 확인
|
||||
- [ ] 메뉴/네비게이션 관리
|
||||
- [ ] 메뉴/네비게이션 브라우저 수동 QA: 항목 추가, 삭제, 숨김, 정렬, 공개 사이드바 반영 확인
|
||||
- [ ] 미디어 라이브러리 카테고리 분류
|
||||
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.25
|
||||
|
||||
- 관리자 블록 에디터에서 빈 문단 placeholder를 마지막 보조 입력 블록에만 표시하도록 수정.
|
||||
- 네비게이션 항목 데이터베이스 테이블 추가.
|
||||
- 공개 네비게이션 조회 API 추가.
|
||||
- 관리자 네비게이션 조회/일괄 저장 API 추가.
|
||||
- 관리자 메뉴 관리 화면 추가.
|
||||
- 공개 왼쪽 사이드바 상단/하단 메뉴를 네비게이션 API와 연결.
|
||||
- 패키지 버전을 0.0.25로 갱신.
|
||||
|
||||
## v0.0.24
|
||||
|
||||
- 관리자 블록 에디터에서 마지막 빈 문단 Enter 입력 시 연속 빈 줄이 유지되도록 수정.
|
||||
|
||||
@@ -30,6 +30,9 @@ const logoutAdmin = async () => {
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/media">
|
||||
미디어
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/navigation">
|
||||
메뉴
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 hover:bg-white/10 hover:text-white" to="/admin/settings">
|
||||
설정
|
||||
</NuxtLink>
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.24",
|
||||
"version": "0.0.25",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.24",
|
||||
"version": "0.0.25",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.24",
|
||||
"version": "0.0.25",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
207
pages/admin/navigation/index.vue
Normal file
207
pages/admin/navigation/index.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const toast = ref(null)
|
||||
let toastTimer = null
|
||||
|
||||
const { data: navigationItems } = await useFetch('/admin/api/navigation', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const items = ref(navigationItems.value.map((item) => ({ ...item })))
|
||||
|
||||
/**
|
||||
* 저장 상태 토스트 표시
|
||||
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||
* @param {string} message - 표시 메시지
|
||||
* @returns {void}
|
||||
*/
|
||||
const showToast = (type, message) => {
|
||||
window.clearTimeout(toastTimer)
|
||||
toast.value = { type, message }
|
||||
toastTimer = window.setTimeout(() => {
|
||||
toast.value = null
|
||||
}, 3200)
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 네비게이션 항목 추가
|
||||
* @param {'primary'|'footer'} location - 표시 위치
|
||||
* @returns {void}
|
||||
*/
|
||||
const addNavigationItem = (location = 'primary') => {
|
||||
items.value.push({
|
||||
id: `new-${Date.now()}`,
|
||||
label: '',
|
||||
url: '/',
|
||||
location,
|
||||
sortOrder: items.value.length * 10 + 10,
|
||||
isVisible: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 항목 삭제
|
||||
* @param {number} index - 항목 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeNavigationItem = (index) => {
|
||||
items.value.splice(index, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 항목 목록 저장
|
||||
* @returns {Promise<void>} 저장 결과
|
||||
*/
|
||||
const saveNavigation = async () => {
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
showToast('info', '네비게이션을 저장하는 중입니다.')
|
||||
|
||||
try {
|
||||
const savedItems = await $fetch('/admin/api/navigation', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
items: items.value.map((item) => ({
|
||||
label: item.label,
|
||||
url: item.url,
|
||||
location: item.location,
|
||||
sortOrder: Number(item.sortOrder || 0),
|
||||
isVisible: Boolean(item.isVisible)
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
items.value = savedItems.map((item) => ({ ...item }))
|
||||
showToast('success', '네비게이션이 저장되었습니다.')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '네비게이션을 저장하지 못했습니다.'
|
||||
showToast('error', errorMessage.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(toastTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-navigation bg-paper p-6">
|
||||
<div class="admin-navigation__header mb-8 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="admin-navigation__eyebrow text-xs font-semibold uppercase text-muted">
|
||||
Navigation
|
||||
</p>
|
||||
<h1 class="admin-navigation__title mt-2 text-3xl font-semibold">
|
||||
메뉴 관리
|
||||
</h1>
|
||||
</div>
|
||||
<div class="admin-navigation__header-actions 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="addNavigationItem('primary')">
|
||||
상단 메뉴 추가
|
||||
</button>
|
||||
<button class="admin-navigation__add rounded border border-line bg-white px-4 py-2 text-sm font-semibold" type="button" @click="addNavigationItem('footer')">
|
||||
하단 메뉴 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="admin-navigation__error mb-5 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<form class="admin-navigation__form grid gap-5" @submit.prevent="saveNavigation">
|
||||
<div class="admin-navigation__table overflow-hidden border border-line bg-white">
|
||||
<table class="admin-navigation__table-inner w-full border-collapse text-left text-sm">
|
||||
<thead class="admin-navigation__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||
<tr>
|
||||
<th class="admin-navigation__cell px-4 py-3">표시</th>
|
||||
<th class="admin-navigation__cell px-4 py-3">라벨</th>
|
||||
<th class="admin-navigation__cell px-4 py-3">URL</th>
|
||||
<th class="admin-navigation__cell px-4 py-3">위치</th>
|
||||
<th class="admin-navigation__cell px-4 py-3">순서</th>
|
||||
<th class="admin-navigation__cell px-4 py-3">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="admin-navigation__table-body divide-y divide-line">
|
||||
<tr v-for="(item, index) in items" :key="item.id || index" class="admin-navigation__row">
|
||||
<td class="admin-navigation__cell px-4 py-3">
|
||||
<input v-model="item.isVisible" class="admin-navigation__checkbox h-4 w-4" type="checkbox">
|
||||
</td>
|
||||
<td class="admin-navigation__cell px-4 py-3">
|
||||
<input
|
||||
v-model="item.label"
|
||||
class="admin-navigation__input w-full rounded border border-line px-3 py-2"
|
||||
type="text"
|
||||
required
|
||||
>
|
||||
</td>
|
||||
<td class="admin-navigation__cell px-4 py-3">
|
||||
<input
|
||||
v-model="item.url"
|
||||
class="admin-navigation__input w-full rounded border border-line px-3 py-2"
|
||||
type="text"
|
||||
required
|
||||
pattern="^(\/|https?:\/\/).*"
|
||||
>
|
||||
</td>
|
||||
<td class="admin-navigation__cell px-4 py-3">
|
||||
<select v-model="item.location" class="admin-navigation__select rounded border border-line px-3 py-2">
|
||||
<option value="primary">상단</option>
|
||||
<option value="footer">하단</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="admin-navigation__cell px-4 py-3">
|
||||
<input
|
||||
v-model.number="item.sortOrder"
|
||||
class="admin-navigation__sort w-24 rounded border border-line px-3 py-2"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
>
|
||||
</td>
|
||||
<td class="admin-navigation__cell px-4 py-3">
|
||||
<button class="admin-navigation__remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700" type="button" @click="removeNavigationItem(index)">
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p v-if="items.length === 0" class="admin-navigation__empty rounded border border-dashed border-line bg-white p-8 text-center text-sm text-muted">
|
||||
메뉴 항목이 없습니다.
|
||||
</p>
|
||||
|
||||
<div class="admin-navigation__actions flex justify-end border-t border-line pt-5">
|
||||
<button
|
||||
class="admin-navigation__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>
|
||||
|
||||
<div
|
||||
v-if="toast"
|
||||
class="admin-navigation__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||
: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'
|
||||
}"
|
||||
role="status"
|
||||
>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
7
server/api/navigation.get.js
Normal file
7
server/api/navigation.get.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { getPublicNavigation } from '../repositories/content-repository'
|
||||
|
||||
/**
|
||||
* 공개 네비게이션 API
|
||||
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} 위치별 네비게이션 항목
|
||||
*/
|
||||
export default defineEventHandler(() => getPublicNavigation())
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getSamplePosts,
|
||||
getSampleTags
|
||||
} from '../utils/sample-content'
|
||||
import { getDefaultNavigationItems, groupNavigationItems } from '../utils/navigation-items'
|
||||
import { getDefaultSiteSettings } from '../utils/site-settings'
|
||||
import { getPostgresClient } from './postgres-client'
|
||||
|
||||
@@ -70,6 +71,22 @@ const mapSiteSettingsRow = (row) => ({
|
||||
updatedAt: row.updated_at.toISOString()
|
||||
})
|
||||
|
||||
/**
|
||||
* 네비게이션 행을 API 응답 구조로 변환
|
||||
* @param {Object} row - 네비게이션 행
|
||||
* @returns {Object} 네비게이션 응답
|
||||
*/
|
||||
const mapNavigationItemRow = (row) => ({
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
url: row.url,
|
||||
location: row.location,
|
||||
sortOrder: row.sort_order,
|
||||
isVisible: row.is_visible,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
updatedAt: row.updated_at.toISOString()
|
||||
})
|
||||
|
||||
/**
|
||||
* 태그 슬러그 목록 정규화
|
||||
* @param {Array<string>} tags - 태그 슬러그 목록
|
||||
@@ -569,6 +586,82 @@ export const updateSiteSettings = async (input) => {
|
||||
return mapSiteSettingsRow(rows[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 항목 목록 조회
|
||||
* @param {Object} options - 조회 옵션
|
||||
* @param {boolean} options.visibleOnly - 표시 항목만 조회할지 여부
|
||||
* @returns {Promise<Array>} 네비게이션 항목 목록
|
||||
*/
|
||||
export const listNavigationItems = async ({ visibleOnly = false } = {}) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return getDefaultNavigationItems()
|
||||
.filter((item) => !visibleOnly || item.isVisible)
|
||||
}
|
||||
|
||||
const rows = visibleOnly
|
||||
? await sql`
|
||||
SELECT *
|
||||
FROM navigation_items
|
||||
WHERE is_visible = true
|
||||
ORDER BY location ASC, sort_order ASC, label ASC
|
||||
`
|
||||
: await sql`
|
||||
SELECT *
|
||||
FROM navigation_items
|
||||
ORDER BY location ASC, sort_order ASC, label ASC
|
||||
`
|
||||
|
||||
return rows.map(mapNavigationItemRow)
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 네비게이션 조회
|
||||
* @returns {Promise<{primary: Array<Object>, footer: Array<Object>}>} 위치별 공개 네비게이션
|
||||
*/
|
||||
export const getPublicNavigation = async () => groupNavigationItems(await listNavigationItems({ visibleOnly: true }))
|
||||
|
||||
/**
|
||||
* 관리자 네비게이션 항목 일괄 저장
|
||||
* @param {Array<Object>} items - 저장할 네비게이션 항목 목록
|
||||
* @returns {Promise<Array>} 저장된 네비게이션 항목 목록
|
||||
*/
|
||||
export const updateNavigationItems = async (items) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
throw new Error('DATABASE_REQUIRED')
|
||||
}
|
||||
|
||||
await sql.begin(async (transaction) => {
|
||||
await transaction`
|
||||
DELETE FROM navigation_items
|
||||
`
|
||||
|
||||
for (const item of items) {
|
||||
await transaction`
|
||||
INSERT INTO navigation_items (
|
||||
label,
|
||||
url,
|
||||
location,
|
||||
sort_order,
|
||||
is_visible
|
||||
)
|
||||
VALUES (
|
||||
${item.label},
|
||||
${item.url},
|
||||
${item.location},
|
||||
${item.sortOrder},
|
||||
${item.isVisible}
|
||||
)
|
||||
`
|
||||
}
|
||||
})
|
||||
|
||||
return listNavigationItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 태그 상세 조회
|
||||
* @param {string} id - 태그 ID
|
||||
|
||||
13
server/routes/admin/api/navigation.get.js
Normal file
13
server/routes/admin/api/navigation.get.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { listNavigationItems } from '../../../repositories/content-repository'
|
||||
|
||||
/**
|
||||
* 관리자 네비게이션 목록 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Array>} 네비게이션 항목 목록
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
return listNavigationItems()
|
||||
})
|
||||
24
server/routes/admin/api/navigation.put.js
Normal file
24
server/routes/admin/api/navigation.put.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createError, readBody } from 'h3'
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { parseAdminNavigationInput } from '../../../utils/admin-navigation-input'
|
||||
import { updateNavigationItems } from '../../../repositories/content-repository'
|
||||
|
||||
/**
|
||||
* 관리자 네비게이션 일괄 저장 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Array>} 저장된 네비게이션 항목 목록
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const parsedBody = parseAdminNavigationInput(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '네비게이션 입력 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return updateNavigationItems(parsedBody.data.items)
|
||||
})
|
||||
21
server/utils/admin-navigation-input.js
Normal file
21
server/utils/admin-navigation-input.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const adminNavigationItemInputSchema = z.object({
|
||||
id: z.string().optional().nullable(),
|
||||
label: z.string().trim().min(1),
|
||||
url: z.string().trim().min(1).regex(/^(\/|https?:\/\/)/),
|
||||
location: z.enum(['primary', 'footer']),
|
||||
sortOrder: z.coerce.number().int().min(0).default(0),
|
||||
isVisible: z.boolean().default(true)
|
||||
})
|
||||
|
||||
export const adminNavigationInputSchema = z.object({
|
||||
items: z.array(adminNavigationItemInputSchema)
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 네비게이션 입력값 정리
|
||||
* @param {unknown} body - 요청 본문
|
||||
* @returns {import('zod').SafeParseReturnType<unknown, Object>} 검증 결과
|
||||
*/
|
||||
export const parseAdminNavigationInput = (body) => adminNavigationInputSchema.safeParse(body)
|
||||
26
server/utils/navigation-items.js
Normal file
26
server/utils/navigation-items.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 기본 네비게이션 항목 반환
|
||||
* @returns {Array<Object>} 기본 네비게이션 항목
|
||||
*/
|
||||
export const getDefaultNavigationItems = () => [
|
||||
{ id: 'default-primary-home', label: 'Home pages', url: '/', location: 'primary', sortOrder: 10, isVisible: true },
|
||||
{ id: 'default-primary-tags', label: 'Tags', url: '/tags', location: 'primary', sortOrder: 20, isVisible: true },
|
||||
{ id: 'default-primary-authors', label: 'Authors', url: '/pages/about', location: 'primary', sortOrder: 30, isVisible: true },
|
||||
{ id: 'default-primary-style', label: 'Style', url: '/post/hello-sori-studio', location: 'primary', sortOrder: 40, isVisible: true },
|
||||
{ id: 'default-primary-post-types', label: 'Post types', url: '/post/custom-writing-tool', location: 'primary', sortOrder: 50, isVisible: true },
|
||||
{ id: 'default-primary-members', label: 'Members', url: '/pages/contact', location: 'primary', sortOrder: 60, isVisible: true },
|
||||
{ id: 'default-primary-landing', label: 'Landing pages', url: '/pages/projects', location: 'primary', sortOrder: 70, isVisible: true },
|
||||
{ id: 'default-footer-portal', label: 'Portal', url: '/pages/links', location: 'footer', sortOrder: 10, isVisible: true },
|
||||
{ id: 'default-footer-docs', label: 'Docs', url: '/pages/about', location: 'footer', sortOrder: 20, isVisible: true },
|
||||
{ id: 'default-footer-projects', label: 'Projects', url: '/pages/projects', location: 'footer', sortOrder: 30, isVisible: true }
|
||||
]
|
||||
|
||||
/**
|
||||
* 네비게이션 항목을 위치별로 묶기
|
||||
* @param {Array<Object>} items - 네비게이션 항목 목록
|
||||
* @returns {{primary: Array<Object>, footer: Array<Object>}} 위치별 네비게이션 항목
|
||||
*/
|
||||
export const groupNavigationItems = (items) => ({
|
||||
primary: items.filter((item) => item.location === 'primary'),
|
||||
footer: items.filter((item) => item.location === 'footer')
|
||||
})
|
||||
Reference in New Issue
Block a user