From 8b8a80034d786efa0203ba13901c40c73b830638 Mon Sep 17 00:00:00 2001 From: zenn Date: Tue, 12 May 2026 11:53:39 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EA=B4=80=EB=A6=AC:=20pare?= =?UTF-8?q?nt=5Fid=20=EC=98=A4=EB=A5=98=20=EC=95=88=EB=82=B4=C2=B7?= =?UTF-8?q?=ED=91=9C=EC=8B=9C/=ED=8F=B4=EB=8D=94=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=C2=B7=ED=83=9C=EA=B7=B8=ED=98=95=20=EB=93=9C=EB=9E=98=EA=B7=B8?= =?UTF-8?q?=20UX=20(v0.0.95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- components/admin/AdminNavPrimaryBranch.vue | 200 +++++++++++++-------- docs/history.md | 6 + docs/map.md | 4 +- docs/spec.md | 7 +- docs/update.md | 6 + package.json | 2 +- pages/admin/navigation/index.vue | 159 ++++++++++------ pages/admin/tags/index.vue | 5 +- server/routes/admin/api/navigation.put.js | 39 +++- 9 files changed, 293 insertions(+), 135 deletions(-) diff --git a/components/admin/AdminNavPrimaryBranch.vue b/components/admin/AdminNavPrimaryBranch.vue index 5ccb101..b50c753 100644 --- a/components/admin/AdminNavPrimaryBranch.vue +++ b/components/admin/AdminNavPrimaryBranch.vue @@ -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 '' +} diff --git a/docs/history.md b/docs/history.md index d0c6e35..8cfaa7b 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-12 v0.0.95 + +### 메뉴 관리 단순화와 드래그 UX + +`is_visible` 체크는 “숨기기” 용도였으나 목록에 두면 모두 표시해야 한다는 운영 기대와 맞지 않아 제거하고 항상 공개로 저장한다. `is_folder`는 자식 존재로만 서버가 채운다. 드래그는 태그 관리와 같은 행 단위 시각 피드백으로 맞췄다. + ## 2026-05-12 v0.0.94 ### 메뉴 관리 UX와 상단 네비 트리 diff --git a/docs/map.md b/docs/map.md index a8496e9..0e56a97 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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 | 태그 수정 | diff --git a/docs/spec.md b/docs/spec.md index a95c201..b7153d9 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다. diff --git a/docs/update.md b/docs/update.md index 1445c9f..afcb86d 100644 --- a/docs/update.md +++ b/docs/update.md @@ -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`)·동일 부모 내 순서 변경. diff --git a/package.json b/package.json index 2bc4d9a..c4fb252 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.94", + "version": "0.0.95", "private": true, "type": "module", "imports": { diff --git a/pages/admin/navigation/index.vue b/pages/admin/navigation/index.vue index 8dcc4c6..7820d24 100644 --- a/pages/admin/navigation/index.vue +++ b/pages/admin/navigation/index.vue @@ -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 () => { 메뉴 관리

- 상단·하단을 탭으로 나누어 편집합니다. 상단은 드래그(::)로 같은 단계끼리 순서를 바꿀 수 있고, 폴더·하위 메뉴를 둘 수 있습니다. 하단은 한 줄 목록만 지원합니다. + 상단·하단을 탭으로 나눕니다. 순서는 태그 관리의 메인 태그와 같이 행을 드래그합니다(번호·여백에서 잡고 이동). 입력란 위에서는 드래그가 시작되지 않습니다. 하위 메뉴가 있으면 공개 사이드바에서 접는 그룹으로 보입니다. +

+

+ 저장 시 parent_id 오류가 나면 DB에 마이그레이션이 아직 없는 것입니다. 로컬에서 npm run db:migrate:dev017_navigation_hierarchy.sql을 적용하세요.

@@ -425,51 +461,70 @@ const saveNavigation = async () => { 하단 메뉴가 없습니다.
- +
{ 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)" diff --git a/server/routes/admin/api/navigation.put.js b/server/routes/admin/api/navigation.put.js index 9096cbf..976f3c6 100644 --- a/server/routes/admin/api/navigation.put.js +++ b/server/routes/admin/api/navigation.put.js @@ -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} 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 + } })