메뉴 관리: parent_id 오류 안내·표시/폴더 제거·태그형 드래그 UX (v0.0.95)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -38,6 +38,19 @@ const emit = defineEmits([
|
||||
'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 - 이벤트
|
||||
@@ -45,6 +58,10 @@ const emit = defineEmits([
|
||||
* @returns {void}
|
||||
*/
|
||||
const onDragStart = (event, itemId) => {
|
||||
if (shouldBlockRowDrag(event)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (!event.dataTransfer) {
|
||||
return
|
||||
}
|
||||
@@ -81,80 +98,117 @@ const onDrop = (event, itemId) => {
|
||||
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>
|
||||
<ul class="admin-nav-primary-branch space-y-2" :class="depth ? 'mt-1 border-l border-line pl-3' : ''">
|
||||
<li
|
||||
v-for="wrap in wraps"
|
||||
:key="wrap.item.id"
|
||||
class="admin-nav-primary-branch__row rounded border border-transparent bg-white px-2 py-2 transition-colors"
|
||||
:class="dragOverId === wrap.item.id ? 'border-ink/20 bg-[#f5f5f2]' : draggingId === wrap.item.id ? 'opacity-60' : ''"
|
||||
>
|
||||
<div class="admin-nav-primary-branch__controls flex flex-wrap items-center gap-2" :style="{ paddingLeft: `${depth * 4}px` }">
|
||||
<span
|
||||
class="admin-nav-primary-branch__handle cursor-grab select-none text-muted active:cursor-grabbing"
|
||||
draggable="true"
|
||||
title="드래그하여 순서 변경"
|
||||
@dragstart="onDragStart($event, wrap.item.id)"
|
||||
@dragover="onDragOver($event, wrap.item.id)"
|
||||
@drop="onDrop($event, wrap.item.id)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
::
|
||||
</span>
|
||||
<label class="admin-nav-primary-branch__visible flex items-center gap-1 text-xs text-muted">
|
||||
<input v-model="wrap.item.isVisible" class="h-4 w-4" type="checkbox">
|
||||
표시
|
||||
</label>
|
||||
<label class="admin-nav-primary-branch__folder flex items-center gap-1 text-xs text-muted">
|
||||
<input v-model="wrap.item.isFolder" class="h-4 w-4" type="checkbox">
|
||||
폴더
|
||||
</label>
|
||||
<input
|
||||
v-model="wrap.item.label"
|
||||
class="admin-nav-primary-branch__label min-w-[120px] flex-1 rounded border border-line px-2 py-1.5 text-sm"
|
||||
type="text"
|
||||
placeholder="라벨"
|
||||
required
|
||||
>
|
||||
<input
|
||||
v-model="wrap.item.url"
|
||||
class="admin-nav-primary-branch__url min-w-[160px] flex-1 rounded border border-line px-2 py-1.5 text-sm font-mono"
|
||||
type="text"
|
||||
placeholder="URL (# 또는 /경로)"
|
||||
required
|
||||
>
|
||||
<button
|
||||
class="admin-nav-primary-branch__add-child rounded border border-line px-2 py-1 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-2 py-1 text-xs font-semibold text-red-700"
|
||||
type="button"
|
||||
@click="emit('remove', wrap.item.id)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
<AdminNavPrimaryBranch
|
||||
v-if="wrap.children.length"
|
||||
class="admin-nav-primary-branch__children mt-2"
|
||||
: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)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-12 v0.0.95
|
||||
|
||||
### 메뉴 관리 단순화와 드래그 UX
|
||||
|
||||
`is_visible` 체크는 “숨기기” 용도였으나 목록에 두면 모두 표시해야 한다는 운영 기대와 맞지 않아 제거하고 항상 공개로 저장한다. `is_folder`는 자식 존재로만 서버가 채운다. 드래그는 태그 관리와 같은 행 단위 시각 피드백으로 맞췄다.
|
||||
|
||||
## 2026-05-12 v0.0.94
|
||||
|
||||
### 메뉴 관리 UX와 상단 네비 트리
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 하단 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 배지형 태그 입력(한글 유지), 로컬 자동 저장, 예약 발행 시각 입력, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리 편집(드래그·하위 추가·폴더 체크, 재귀) |
|
||||
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
|
||||
|
||||
## 콘텐츠 컴포넌트
|
||||
@@ -91,7 +91,7 @@
|
||||
| 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 | 메뉴 관리: 상단/하단 탭, 테이블+행 드래그(태그 메인과 동일 톤), `useAdminToast`, 마이그레이션 안내 |
|
||||
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬·일반 강등, 일반 태그 검색/메인 전환/삭제), 액션 피드백 토스트, 순서 변경 시에만 정렬 저장 활성 |
|
||||
| pages/admin/tags/new.vue | 태그 생성 |
|
||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||
|
||||
@@ -517,11 +517,12 @@ components/content/
|
||||
### 메뉴/네비게이션
|
||||
|
||||
- 네비게이션은 `navigation_items` 테이블로 관리한다.
|
||||
- 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, 상단 그룹 표시용), `location`(`primary`|`footer`), `sort_order`, `label`, `url`, `is_visible`. `(location, label, url)` 유니크 제약은 제거되었다.
|
||||
- 컬럼: `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)` 유니크 제약은 제거되었다.
|
||||
- `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. `footer`는 **평면** 배열(`parent_id` 없음)이며 `id`, `label`, `url`, `isVisible`만 내려간다.
|
||||
- `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 서버가 위치별 트리 DFS 순으로 다시 부여한다. `parent_id`는 `primary`에서만 허용되며 `footer` 항목은 항상 루트다.
|
||||
- `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 서버가 위치별 트리 DFS 순으로 다시 부여한다. `parent_id`는 `primary`에서만 허용되며 `footer` 항목은 항상 루트다. `is_visible`·`is_folder`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다.
|
||||
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다.
|
||||
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션** 탭으로 구분한다(위치 셀렉트 없음). 상단은 `AdminNavPrimaryBranch`로 트리 편집·같은 부모 내 드래그 순서 변경·하위 추가·폴더 체크를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다.
|
||||
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·번호 열에서 시작한다. 상단은 `AdminNavPrimaryBranch`로 트리·동일 부모 내 순서 변경·하위 추가를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다.
|
||||
- `parent_id` / `is_folder` 컬럼이 DB에 없을 때 저장은 실패한다. `npm run db:migrate:dev`로 `017_navigation_hierarchy.sql`을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다).
|
||||
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링하며, 하위가 있는 노드는 chevron으로 펼침·접기한다. 펼침 상태는 `localStorage` 키 `sori-primary-nav-expanded`에 저장된다.
|
||||
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.95
|
||||
|
||||
- 메뉴 관리: `표시`·`폴더` 체크 제거(항목은 항상 공개, `is_folder`는 저장 시 자식 유무로 서버 설정). 테이블+행 드래그 UX를 태그 메인 태그와 동일 톤(`bg-[#f9f9f7]`·`opacity-50`, 입력 위에서는 드래그 시작 안 함).
|
||||
- 태그 관리 메인 태그: 드래그 중인 행에 `opacity-50` 적용(메뉴 관리와 동일한 이탈 피드백).
|
||||
- `PUT /admin/api/navigation`: `parent_id`/`is_folder` 컬럼 부재 시 한국어 안내(503) 및 화면에 마이그레이션 안내 블록.
|
||||
|
||||
## v0.0.94
|
||||
|
||||
- 메뉴 관리: 상단/하단 탭 분리, 순서는 드래그(태그 관리와 유사). 상단은 `parent_id` 트리·하위 추가·폴더(`is_folder`)·동일 부모 내 순서 변경.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.94",
|
||||
"version": "0.0.95",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -16,7 +16,8 @@ const { data: navigationItems } = await useFetch('/admin/api/navigation', {
|
||||
const items = ref(navigationItems.value.map((item) => ({
|
||||
...item,
|
||||
parentId: item.parentId ?? null,
|
||||
isFolder: Boolean(item.isFolder)
|
||||
isFolder: Boolean(item.isFolder),
|
||||
isVisible: true
|
||||
})))
|
||||
|
||||
const navDraggingId = ref('')
|
||||
@@ -45,8 +46,6 @@ const serializeNavigationItems = (list) => JSON.stringify(
|
||||
url: String(item.url || '').trim(),
|
||||
location: item.location,
|
||||
sortOrder: Number(item.sortOrder || 0),
|
||||
isVisible: Boolean(item.isVisible),
|
||||
isFolder: Boolean(item.isFolder),
|
||||
parentId: item.parentId ? String(item.parentId).trim() : null
|
||||
}))
|
||||
)
|
||||
@@ -181,6 +180,19 @@ const onPrimaryDrop = ({ parentKey, 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'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 하단 행 드래그 시작
|
||||
* @param {DragEvent} event - 이벤트
|
||||
@@ -188,6 +200,10 @@ const onPrimaryDrop = ({ parentKey, targetId }) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const onFooterDragStart = (event, id) => {
|
||||
if (shouldBlockFooterRowDrag(event)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (!event.dataTransfer) {
|
||||
return
|
||||
}
|
||||
@@ -231,6 +247,21 @@ const onFooterDragEnd = () => {
|
||||
footerDragOverId.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 하단 행 하이라이트(태그 관리와 동일)
|
||||
* @param {string} id - 항목 id
|
||||
* @returns {string}
|
||||
*/
|
||||
const footerRowClass = (id) => {
|
||||
if (footerDragOverId.value === id) {
|
||||
return 'bg-[#f9f9f7]'
|
||||
}
|
||||
if (footerDraggingId.value === id) {
|
||||
return 'opacity-50'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 상단 루트 항목 추가
|
||||
* @returns {void}
|
||||
@@ -313,7 +344,7 @@ const saveNavigation = async () => {
|
||||
url: item.url,
|
||||
location: item.location,
|
||||
sortOrder: Number(item.sortOrder || 0),
|
||||
isVisible: Boolean(item.isVisible),
|
||||
isVisible: true,
|
||||
parentId: item.parentId ?? null,
|
||||
isFolder: Boolean(item.isFolder)
|
||||
}))
|
||||
@@ -323,12 +354,14 @@ const saveNavigation = async () => {
|
||||
items.value = savedItems.map((item) => ({
|
||||
...item,
|
||||
parentId: item.parentId ?? null,
|
||||
isFolder: Boolean(item.isFolder)
|
||||
isFolder: Boolean(item.isFolder),
|
||||
isVisible: true
|
||||
}))
|
||||
navigationBaseline.value = serializeNavigationItems(items.value)
|
||||
showToast('success', '네비게이션이 저장되었습니다.')
|
||||
} catch (error) {
|
||||
showToast('error', error?.data?.message || '네비게이션을 저장하지 못했습니다.')
|
||||
const msg = error?.data?.message || error?.message || '네비게이션을 저장하지 못했습니다.'
|
||||
showToast('error', msg)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -346,7 +379,10 @@ 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>와 같이 행을 드래그합니다(번호·여백에서 잡고 이동). 입력란 위에서는 드래그가 시작되지 않습니다. 하위 메뉴가 있으면 공개 사이드바에서 접는 그룹으로 보입니다.
|
||||
</p>
|
||||
<p class="admin-navigation__migrate-hint mt-2 max-w-xl rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
저장 시 <code class="rounded bg-white/80 px-1">parent_id</code> 오류가 나면 DB에 마이그레이션이 아직 없는 것입니다. 로컬에서 <code class="rounded bg-white/80 px-1">npm run db:migrate:dev</code>로 <code class="rounded bg-white/80 px-1">017_navigation_hierarchy.sql</code>을 적용하세요.
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-navigation__header-actions flex flex-wrap gap-2">
|
||||
@@ -425,51 +461,70 @@ const saveNavigation = async () => {
|
||||
하단 메뉴가 없습니다.
|
||||
</div>
|
||||
|
||||
<ul v-else class="admin-navigation__footer-list space-y-2 rounded border border-line bg-white p-4">
|
||||
<li
|
||||
v-for="item in footerItemsSorted"
|
||||
:key="item.id"
|
||||
class="admin-navigation__footer-row flex flex-wrap items-center gap-2 rounded border border-transparent px-2 py-2 transition-colors"
|
||||
:class="footerDragOverId === item.id ? 'border-ink/20 bg-[#f5f5f2]' : ''"
|
||||
>
|
||||
<span
|
||||
class="admin-navigation__footer-handle cursor-grab select-none text-muted active:cursor-grabbing"
|
||||
draggable="true"
|
||||
title="드래그하여 순서 변경"
|
||||
@dragstart="onFooterDragStart($event, item.id)"
|
||||
@dragover="onFooterDragOver($event, item.id)"
|
||||
@drop="onFooterDrop($event, item.id)"
|
||||
@dragend="onFooterDragEnd"
|
||||
>
|
||||
::
|
||||
</span>
|
||||
<label class="flex items-center gap-1 text-xs text-muted">
|
||||
<input v-model="item.isVisible" class="h-4 w-4" type="checkbox">
|
||||
표시
|
||||
</label>
|
||||
<input
|
||||
v-model="item.label"
|
||||
class="min-w-[100px] flex-1 rounded border border-line px-2 py-1.5 text-sm"
|
||||
type="text"
|
||||
placeholder="라벨"
|
||||
required
|
||||
>
|
||||
<input
|
||||
v-model="item.url"
|
||||
class="min-w-[140px] flex-1 rounded border border-line px-2 py-1.5 text-sm font-mono"
|
||||
type="text"
|
||||
placeholder="URL"
|
||||
required
|
||||
>
|
||||
<button
|
||||
class="rounded border border-red-200 px-2 py-1 text-xs font-semibold text-red-700"
|
||||
type="button"
|
||||
@click="removeItemCascade(item.id)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="admin-navigation__footer-table overflow-hidden rounded border border-line">
|
||||
<table class="admin-navigation__footer-table-inner w-full border-collapse text-left text-sm">
|
||||
<thead class="admin-navigation__footer-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||
<tr>
|
||||
<th class="admin-navigation__footer-cell px-4 py-3">
|
||||
#
|
||||
</th>
|
||||
<th class="admin-navigation__footer-cell px-4 py-3">
|
||||
라벨
|
||||
</th>
|
||||
<th class="admin-navigation__footer-cell px-4 py-3">
|
||||
URL
|
||||
</th>
|
||||
<th class="admin-navigation__footer-cell px-4 py-3">
|
||||
관리
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="admin-navigation__footer-body divide-y divide-line bg-white">
|
||||
<tr
|
||||
v-for="(item, index) in footerItemsSorted"
|
||||
:key="item.id"
|
||||
class="admin-navigation__footer-row cursor-move"
|
||||
:class="footerRowClass(item.id)"
|
||||
draggable="true"
|
||||
@dragstart="onFooterDragStart($event, item.id)"
|
||||
@dragover="onFooterDragOver($event, item.id)"
|
||||
@drop="onFooterDrop($event, item.id)"
|
||||
@dragend="onFooterDragEnd"
|
||||
>
|
||||
<td class="admin-navigation__footer-cell px-4 py-4 text-muted">
|
||||
{{ index + 1 }}
|
||||
</td>
|
||||
<td class="admin-navigation__footer-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__footer-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="URL"
|
||||
required
|
||||
>
|
||||
</td>
|
||||
<td class="admin-navigation__footer-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
|
||||
|
||||
@@ -354,7 +354,10 @@ onBeforeUnmount(() => {
|
||||
v-for="(tag, index) in managedTags"
|
||||
:key="tag.id"
|
||||
class="admin-tags__row cursor-move"
|
||||
:class="dragOverTagId === tag.id ? 'bg-[#f9f9f7]' : ''"
|
||||
:class="[
|
||||
dragOverTagId === tag.id ? 'bg-[#f9f9f7]' : '',
|
||||
draggingTagId === tag.id ? 'opacity-50' : ''
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, tag.id)"
|
||||
@dragover="handleDragOver($event, tag.id)"
|
||||
|
||||
@@ -4,6 +4,26 @@ import { parseAdminNavigationInput } from '../../../utils/admin-navigation-input
|
||||
import { updateNavigationItems } from '../../../repositories/content-repository'
|
||||
import { renumberSortOrderByTree, validateNavigationItems } from '../../../utils/navigation-tree'
|
||||
|
||||
/**
|
||||
* 저장 직전에 항목별 표시·폴더 플래그를 정리한다(관리자 UI는 항상 노출, 폴더는 자식 유무로만).
|
||||
* @param {Array<Object>} items - 항목 목록(변경됨)
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyNavigationDerivedFlags = (items) => {
|
||||
for (const item of items) {
|
||||
item.isVisible = true
|
||||
const id = String(item.id).trim()
|
||||
item.isFolder =
|
||||
item.location === 'primary' &&
|
||||
items.some(
|
||||
(o) =>
|
||||
o.location === 'primary' &&
|
||||
o.parentId != null &&
|
||||
String(o.parentId).trim() === id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 네비게이션 일괄 저장 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
@@ -27,9 +47,9 @@ export default defineEventHandler(async (event) => {
|
||||
url: row.url.trim(),
|
||||
location: row.location,
|
||||
sortOrder: row.sortOrder,
|
||||
isVisible: row.isVisible,
|
||||
isVisible: true,
|
||||
parentId: row.parentId ?? null,
|
||||
isFolder: Boolean(row.isFolder)
|
||||
isFolder: false
|
||||
}))
|
||||
|
||||
const checked = validateNavigationItems(items)
|
||||
@@ -42,6 +62,19 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
renumberSortOrderByTree(items, 'primary')
|
||||
renumberSortOrderByTree(items, 'footer')
|
||||
applyNavigationDerivedFlags(items)
|
||||
|
||||
return updateNavigationItems(items)
|
||||
try {
|
||||
return await updateNavigationItems(items)
|
||||
} catch (err) {
|
||||
const msg = err?.message != null ? String(err.message) : String(err)
|
||||
const code = err?.code != null ? String(err.code) : ''
|
||||
if (msg.includes('parent_id') || msg.includes('is_folder') || code === '42703') {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
message: 'DB에 navigation_items 확장 컬럼이 없습니다. 프로젝트 루트에서 npm run db:migrate:dev 로 017_navigation_hierarchy.sql을 적용한 뒤 다시 저장하세요.'
|
||||
})
|
||||
}
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user