페이지 섹션·공개 목록 설정 누적 변경 일괄 반영

페이지 섹션 관리와 홈 Pages 진열장 연동, 공개 목록 표시개수 설정 관련 누적 로컬 변경을 기능 단위로 정리해 반영한다.
문서 이력과 패키지 버전을 v1.9.23으로 맞춰 코드와 문서 상태를 동기화한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 18:48:42 +09:00
parent 6a3cc5d7ec
commit 00999404a4
37 changed files with 2681 additions and 123 deletions

View File

@@ -55,15 +55,20 @@
- 가로 카드 트랙은 `overflow-x-auto`와 `snap-x`/`snap-mandatory`로 슬라이드 느낌을 낸다.
- Featured 영역은 추천 글(`isFeatured=true`)이 1개 이상 있을 때만 표시한다.
- 표시 개수는 사이트 설정 POST 설정의 `featuredPostLimit` 값을 따른다. 기본값은 6개다.
- Featured 글에 대표 이미지가 없으면 목록 썸네일과 동일하게 카드 안에 게시물 제목을 표시하는 placeholder를 사용한다.
- Featured 대표 이미지가 게시물 업로드 이미지이고 카드 썸네일이 생성되어 있으면 원본 대신 `featuredImageThumbnail`을 우선 표시한다.
- 모바일 터치: `touch-pan-x`, `-webkit-overflow-scrolling: touch`, `overscroll-x-contain`으로 가로 스크롤 우선·부모로의 스크롤 전파 완화.
- 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다.
- 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다. Pages 선반은 각 선반의 스크롤 컨테이너와 카드 크기 변화를 감시해 이미지 로딩·반응형 폭 변화 후에도 버튼 상태를 다시 계산한다.
- 마우스/트랙패드 환경에서는 카드 트랙을 직접 드래그해 넘길 수 있고, 드래그 종료 시 가까운 카드 위치로 스냅한다. 트랙 안의 이미지 기본 드래그와 텍스트 선택은 막아 캐러셀 조작을 우선하며, 작은 드래그도 일정 거리 이상이면 다음·이전 카드 위치로 붙는다. 드래그 중 포인터가 카드 밖으로 이동해도 문서 레벨 포인터 추적으로 조작을 유지한다. 드래그 직후에는 카드 링크 클릭을 억제해 의도치 않은 이동을 막는다.
### 홈 Latest 피드
- 기본 보기 방식은 `compact`이며, Default 선택 시에도 `compact`로 복원한다.
- 홈 Latest는 사이트 설정 POST 설정의 `homeLatestPostLimit` 값만큼 서버에서 최신 글을 가져온다. 기본값은 20개다.
- 홈 Latest 하단에는 전체 게시물 목록(`/posts`)으로 이동하는 더보기 링크를 표시한다.
- `compact`는 썸네일을 포함한 짧은 행 형태이며 설명은 최대 2줄로 제한한다. 모바일 compact 썸네일은 오른쪽 제목·요약·메타 높이에 맞춘 80px 정사각형으로 표시하고, `sm` 이상에서는 기존 비율형 썸네일을 사용한다. 대표 이미지가 없는 placeholder 제목은 모바일 3줄, `sm` 이상 4줄로 제한하며 긴 단어는 썸네일 안에서 줄바꿈한다. `list`는 텍스트 중심 목록 형태로 설명은 최대 2줄, `cards`는 카드 그리드 형태로 표시한다.
- 홈 Latest·게시물 목록·태그 목록의 썸네일 영역은 대표 이미지 유무와 관계없이 같은 크기를 유지한다.
- 홈 Latest·게시물 목록·태그 목록의 카드 이미지는 `featuredImageThumbnail`을 우선 사용하고, 값이 없으면 기존 `featuredImage` 원본 URL로 대체한다.
- Latest 섹션은 게시물이 적어도 보기 방식 선택 메뉴가 아래쪽에서 잘리지 않도록 최소 높이를 둔다.
- 사이트 설정 Ads의 메인 피드 광고 코드가 있으면 Featured 영역과 Latest 목록 사이에 광고 슬롯을 표시한다. 메인 인피드 광고 코드가 있으면 Latest 게시물 목록 사이 한 곳에 브라우저 렌더 시점 기준으로 무작위 삽입한다. 비어 있으면 슬롯 자체를 렌더링하지 않는다.
@@ -112,20 +117,27 @@
- About, Projects, Links, Contact, 서비스 소개 페이지 등 고정 콘텐츠에 사용
- 기본 게시물 목록에는 노출하지 않음
- 페이지 설정의 `홈에 표시`가 켜진 공개 페이지는 홈 화면의 Pages 진열장 영역에 노출한다. 진열장은 `섹션명` 기준으로 여러 선반을 만들고, 각 페이지는 도서/게임 라이브러리처럼 세로형 커버 카드로 표시한다. 각 선반은 Featured와 같은 이전·다음 버튼과 드래그 슬라이드·스냅 이동을 지원하며, 선반별로 독립 동작한다. 카드 폭은 데스크톱 기준 약 164px로 여러 페이지가 있을 때 가로 탐색이 명확히 생기도록 한다. 섹션별 표시 개수는 사이트 설정 POST 설정의 `pageSectionItemLimit` 값을 따른다. 기본값은 12개다.
- Pages 진열장 커버는 3:4 세로 비율을 기준으로 표시한다. 제작 권장 크기는 `900×1200px`이며, 고해상도 원본을 준비할 때는 `1200×1600px`을 권장한다. 최소 권장 크기는 `600×800px`이다.
- 페이지 섹션 생성·이름 변경·삭제·표시 순서는 관리자 네비게이션 관리의 `페이지 섹션` 탭에서 다룬다. 섹션 추가는 모달에서 이름을 입력해 생성하고, 이름 변경과 섹션 표시 순서는 탭 안의 `페이지 섹션 저장`으로 한 번에 저장한다. 페이지 작성·수정 화면에서는 등록된 섹션을 검색형 입력으로 선택한다.
- 섹션 내부 페이지 표시 순서는 관리자 네비게이션 관리의 `페이지 섹션` 탭에서 섹션별 `페이지 순서` 모달로 조정하며, 페이지 작성·수정 화면에서는 직접 입력하지 않는다. 순서 모달은 페이지 썸네일 카드형 가로 목록으로 표시하고, 추천 페이지를 먼저 보여 주며, 추천 페이지가 비추천 페이지 뒤로 드롭되면 즉시 추천 우선 순서로 보정한다. 페이지를 해당 섹션에서 바로 제거할 수 있다.
- `추천으로 강조`된 페이지는 같은 섹션 안에서 먼저 정렬한다.
- 목록 설명은 홈 카드에는 표시하지 않고 공개 검색 결과와 관리용 메타로 사용한다.
- 헤더와 사이드바를 사용하지 않고 본문 중심 전체 화면으로 표시
- 페이지는 `renderMode`로 렌더링 방식을 구분한다. 기본값은 `html_document`이며 관리자에서 붙여넣은 전체 HTML 문서를 공개 `/pages/:slug` 요청에서 `text/html` 원문으로 응답한다. `markdown`은 관리자 UI에서 `일반 텍스트`로 표시하며 기존 Markdown 콘텐츠 렌더러를 사용한다.
- HTML 문서 모드는 관리자만 저장하는 신뢰 콘텐츠를 전제로 하며, `<head>`, `<style>`, `<body>`를 포함한 단일 랜딩 페이지 용도로 사용한다.
- HTML 문서 모드 페이지로 Nuxt 클라이언트 라우팅을 통해 진입한 경우 현재 URL을 `reload()`해 서버 미들웨어의 원문 HTML 응답으로 재진입한다. 브라우저 뒤로가기는 직전 Nuxt 화면을 복원해야 한다.
- 진입 경로는 추후 메뉴/링크 설정을 통해 연결
### 공개 URL 구조
- `/posts` - 게시물 전체 목록
- `/posts` - 게시물 전체 목록. 사이트 설정 POST 설정의 `postListPageSize` 값 기준으로 서버 페이지네이션을 사용하며, 페이지 번호가 바뀌면 화면을 상단으로 이동한다.
- `/post/:slug` - 개별 게시물 상세
- `/sitemap.xml` - 공개 sitemap XML. 홈, 게시물 목록, 태그 목록, 공개 발행글, 공개 고정 페이지, 글이 있는 태그 URL을 요청 시점 DB 기준으로 자동 생성한다. 멤버십·비공개·초안·예약 대기 글과 `noindex` 글은 포함하지 않는다.
- `/robots.txt` - 크롤러 안내 파일. 관리자·API·회원 설정·로그인 경로를 제외하고 `Sitemap: /sitemap.xml` 절대 URL을 제공한다.
- `/rss.xml`, `/feed.xml`, `/rss` - 최근 공개 발행글 RSS 2.0 피드. 멤버십·비공개·초안·예약 대기 글은 포함하지 않으며, 최신순 최대 50개를 XML로 응답한다. 게시물에 대표 이미지 또는 OG 이미지가 있으면 절대 URL로 변환해 `media:thumbnail`과 `media:content medium="image"`를 함께 포함한다.
- `/tags` - 태그 전체 목록
- `/tag/:slug` - 태그별 게시물 목록
- `/tag/:slug` - 태그별 게시물 목록. 사이트 설정 POST 설정의 `tagPostListPageSize` 값 기준으로 서버 태그 필터와 페이지네이션을 사용하며, 페이지 번호가 바뀌면 화면을 상단으로 이동한다.
- 태그명에 `/`를 입력하면 태그명은 그대로 저장하고 슬러그에서는 `/`를 `-`로 변환한다. 예: `Xenoblade3/Hero` 태그명은 `xenoblade3-hero` 슬러그로 저장한다.
- `/tag/:slug` 화면의 헤더와 게시물 목록은 공개 목록 공통 섹션 패딩(`site-section-header`, `site-section-body`)을 사용해 메인 컬럼 좌우 여백을 다른 목록 화면과 맞춘다.
- 태그에 `headerImageUrl`이 있으면 `/tag/:slug` 상단에는 기본 태그명·설명 대신 태그 헤더 이미지를 표시한다. `headerTitle`, `headerText`는 이미지 위 오버레이로 표시하며, 둘 다 비어 있으면 이미지 단독으로 표시한다.
@@ -397,11 +409,27 @@ components/content/
| slug | String | URL 슬러그 |
| content | Text | HTML 문서 원문 또는 일반 텍스트 콘텐츠 |
| render_mode | String | 렌더링 방식(`html_document`, `markdown`) |
| featured_image | String nullable | 레거시 컬럼, 관리자 페이지 작성 UI에서는 사용하지 않음 |
| featured_image | String nullable | 홈 Pages 진열장 커버 이미지 |
| is_listed | Boolean | 홈 Pages 진열장 노출 여부 |
| is_featured | Boolean | 홈 Pages 진열장 추천 표시 및 우선 정렬 |
| listing_section | String | 홈 Pages 진열장 섹션명 |
| listing_title | String | 홈 Pages 진열장·검색 결과용 제목(비어 있으면 페이지 제목 사용) |
| listing_description | String | 검색 결과·관리용 목록 설명 |
| listing_sort_order | Integer | 섹션 내 정렬 순서 |
| status | Enum | `published` / `draft` / `private` |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
### Page Listing Sections
| 필드 | 타입 | 설명 |
|------|------|------|
| id | UUID | 섹션 ID |
| name | String | 홈 Pages 진열장 섹션명. 페이지의 `listing_section`과 매칭 |
| sort_order | Integer | 홈 Pages 진열장 섹션 표시 순서 |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
### Tags
| 필드 | 타입 | 설명 |
@@ -533,15 +561,15 @@ components/content/
### 공개 API (`/api/`)
- `GET /api/posts` - 게시물 목록
- `GET /api/posts` - 게시물 목록. `limit`, `featured=1`, `tag=:slug` 쿼리로 서버 목록 범위를 제한할 수 있다. `paginated=1&page=N&limit=N`이면 `{ items, pagination }` 형태로 응답하고, 없으면 기존처럼 배열을 반환한다.
- `GET /api/posts/:slug` - 게시물 상세
- `GET /api/posts/:slug/comments` - 게시물 댓글 목록
- `POST /api/posts/:slug/comments` - 게시물 댓글 작성(회원 세션 필요, 대댓글 1단)
- `POST /api/posts/:slug/comments/:commentId/like` - 댓글 좋아요 토글(회원 세션 필요)
- `GET /api/pages` - 고정 페이지 목록
- `GET /api/pages` - 공개 고정 페이지 목록. `?listed=1`이면 홈 Pages 진열장에 노출할 공개 페이지만 반환하며, 홈 목록 성능을 위해 `content`는 빈 문자열로 내려준다. `listed=1&limit=N`이면 섹션별 최대 N개만 반환한다.
- `GET /api/pages/:slug` - 고정 페이지 상세
- `GET /api/tags` - 태그 목록. 기본값은 메인 태그(`managed`)만 반환하며, `?tagType=all`은 일반 태그를 포함한 전체 태그 메타를 반환한다.
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
- `GET /api/tags` - 태그 목록. 기본값은 메인 태그(`managed`)만 반환하며, `?tagType=all`은 일반 태그를 포함한 전체 태그 메타를 반환한다. `postCount`는 공개 발행 게시물 기준이다.
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content`, 홈 표시 페이지의 `listingSection`·`listingTitle`·`listingDescription`·`content` 부분 일치)
- `GET /api/site-settings` - 공개 사이트 설정(어나운스 바·홈 커버 등 포함)
- `GET /api/homepage-widget` - gethomepage customapi용 공개 블로그 통계. 기본 필드 `title`, `updatedAt`, `todayVisitors`, `totalVisitors`, `todayPageViews`, `totalPageViews`, `visitorsLast7Days`, `visitorsLast30Days`, `visitorsLast180Days`, `visitorsLast365Days`, `pageViewsLast30Days`, `onlineNow`, `loggedInNow`, `avgEngagedSeconds`, `totalPosts`, `totalComments`, `totalPages`, `totalTags`, `totalMembers`, `items[]`를 반환한다. 기간별 방문자는 최근 7·30·180·365일의 일별 순방문자 합계다. `totalVisitors`는 날짜별 순방문자 수의 누적 합계라 동일 방문자가 다른 날짜에 다시 포함될 수 있다. 게시글·댓글·페이지 수는 현재 공개 가능한 항목만 집계한다.
- `POST /api/analytics/pageview` - 공개 방문·게시물·페이지 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `pageSlug`(페이지일 때), `referrer`, `currentUrl`, `read`(읽음 이벤트). 발행된 게시물과 공개 페이지만 개별 집계한다. 응답 `{ ok: true }`. 관리자 세션 요청은 저장하지 않고 `{ ok: true }`만 반환한다. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록하되, 관리자 세션은 제외한다.
@@ -602,6 +630,12 @@ components/content/
- `GET /admin/api/pages/:id` - 고정 페이지 상세
- `PUT /admin/api/pages/:id` - 고정 페이지 수정
- `DELETE /admin/api/pages/:id` - 고정 페이지 삭제
- `GET /admin/api/page-sections` - 홈 Pages 진열장 페이지 섹션 목록
- `POST /admin/api/page-sections` - 페이지 섹션 생성
- `PUT /admin/api/page-sections/:id` - 페이지 섹션 이름 수정. 연결된 페이지의 `listing_section`도 함께 변경
- `DELETE /admin/api/page-sections/:id` - 미사용 페이지 섹션 삭제
- `PUT /admin/api/page-sections/reorder` - 페이지 섹션 표시 순서 저장
- `PUT /admin/api/page-sections/:id/pages/reorder` - 페이지 섹션 내부의 홈 표시 페이지 순서 저장. `removedPageIds`를 함께 보내면 해당 페이지의 섹션 연결을 해제
- `GET /admin/api/media` - 업로드 미디어 목록(게시물용 이미지와 회원 아바타 포함; 회원 아바타에는 `avatarOwner` 요약이 붙을 수 있음)
- `POST /admin/api/media/thumbnail` - 게시물 대표 이미지 원본 URL에서 목록 카드용 썸네일을 생성 또는 재생성
- `PUT /admin/api/media` - 업로드 미디어 파일명 변경 또는 단일/복수 미디어 폴더 변경(회원 아바타 URL은 논리 폴더 `썸네일`만 허용, 일반 미디어를 `썸네일`로 옮기는 것은 거부; 썸네일 파일명 변경은 `users.avatar_url`이 해당 URL을 참조할 때만 거부)
@@ -775,7 +809,7 @@ components/content/
- 페이지 상태, 페이지 형식, Page URL, HTML 자산 업로드, 삭제 액션은 오른쪽 설정 패널에서 관리한다.
- 페이지 형식 선택은 HTML 문서/일반 텍스트 모드와 무관하게 항상 표시한다. HTML 자산 업로드는 HTML 문서 모드에서만 표시한다.
- HTML 자산 업로드는 기존 관리자 업로드 API(`/admin/api/uploads`)를 사용하며, 성공한 파일 URL을 HTML textarea 현재 커서 위치에 삽입한다. 업로드 파일은 현재 에디터 업로드 정책에 따라 `/uploads/posts/YYYY/MM/` 아래 저장되고 미디어 라이브러리 논리 폴더는 `미분류`로 기록된다.
- 고정 페이지는 제목, 슬러그, 렌더링 방식, 본문을 저장한다. 대표 이미지는 페이지 작성 UI에서 사용하지 않는다.
- 고정 페이지는 제목, 슬러그, 렌더링 방식, 본문을 저장한다. 썸네일 등록 이미지는 홈 Pages 진열장 커버로 사용한다.
- 고정 페이지는 게시물 목록과 태그 목록에 노출하지 않는다.
- 고정 페이지 공개 보기 경로는 `/pages/:slug`를 사용한다.
- 고정 페이지 상태는 `published`, `draft`, `private`를 사용한다. 공개 목록·상세·HTML 문서 미들웨어는 `published` 상태만 응답하고, `draft`와 `private`는 공개 URL에서 찾을 수 없는 페이지로 처리한다.
@@ -788,7 +822,7 @@ components/content/
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
- **메인 화면**(`home_cover_image_url`, `home_cover_dark_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 라이트 이미지는 기본 커버이며, 다크 이미지가 있으면 시스템 다크모드 또는 `html[data-theme='dark']`에서 다크 이미지를 표시한다. 다크 이미지가 없으면 라이트 이미지를 그대로 사용한다. 이미지가 있을 때만 `HomeHero`를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 오버레이 본문은 textarea에서 입력한 줄바꿈(`\n`)을 저장·표시하며, `HomeHero` 본문은 `whitespace-pre-line`으로 여러 줄을 렌더링한다. 관리자 UI에서는 라이트모드와 다크모드 프리뷰를 상하로 모두 표시하고, 각 모드 제목 오른쪽의 이미지 변경·삭제 버튼으로 개별 이미지를 관리한다. 이미지가 비어 있는 모드는 점선 드롭존으로 표시하며 파일 선택과 드래그 앤 드롭 업로드를 지원한다. 커버 파일 업로드·제목·본문은 편집 뒤 **저장** 한 번에 `PUT /admin/api/settings`로 반영한다. 파일 업로드 API는 디스크에만 올리고 URL을 돌려준다(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`).
- **POST 설정**(`show_post_updated_at`, `post_tag_limit`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록에 수정 시각 보조 줄을 표시할지 여부와 게시물별 태그 최대 개수를 관리한다. `post_tag_limit` 기본값은 5이며 1~10 범위로 제한한다. 공개 게시글 상세에는 수정 시각을 표시하지 않는다.
- **POST 설정**(`show_post_updated_at`, `post_tag_limit`, `home_latest_post_limit`, `post_list_page_size`, `tag_post_list_page_size`, `featured_post_limit`, `page_section_item_limit`): 발행 후 본문·메타 수정이 있을 때 관리자 글 목록에 수정 시각 보조 줄을 표시할지 여부와 게시물별 태그 최대 개수를 관리한다. `post_tag_limit` 기본값은 5이며 1~10 범위로 제한한다. 표시개수 설정은 Latest, Posts, 태그 게시물, Featured, Pages 섹션의 공개 목록 로드·표시 개수를 관리하며 1~50 범위의 셀렉트 값만 사용한다. 공개 게시글 상세에는 수정 시각을 표시하지 않는다.
- **가입 금지 닉네임**(`signup_blocked_usernames`, JSON 문자열): 회원가입·회원 프로필 닉네임 변경 시 닉네임에 목록 단어가 포함되면 거부한다(대소문자 무시, 부분 일치). 안내 문구는 `{단어}은 사용할 수 없는 단어입니다.` 형식이다. 기본값: `admin`, `master`, `zenn`, `sori`, `sori.studio`.
- **어나운스 바**(`announcement_enabled`, `announcement_text`, `announcement_url`, `announcement_background_color`): `announcement_enabled`가 true이고 문구가 비어 있지 않으면 공개 레이아웃(`default`·`post`) 헤더 위에 전체 너비 배너를 표시한다. `announcement_url`이 있으면 문구를 링크로 감싼다(내부 `/…` 또는 `http(s)://`). 배경색은 `#15171a`·`#ffffff`·`#ff4f2e` 중 하나. 방문자는 **이번 방문 동안 닫기**(X, `sessionStorage`) 또는 **7일간 보지 않기**(`localStorage`, 만료 시각 저장)를 선택할 수 있다. 공지 내용이 바뀌어 `updatedAt`이 달라지면 다시 노출된다.
- **사이트 코드**(`ads_txt`, `custom_head_code`, `custom_footer_code`): `ads_txt`는 루트 `/ads.txt`에서 `text/plain`으로 응답한다. `custom_head_code`는 공개 Nuxt HTML 응답의 `head` 끝에, `custom_footer_code`는 `body` 끝에 원문 HTML로 삽입한다. 관리자 페이지, `/api`, `/uploads`, `/_nuxt`, `/ads.txt` 응답에는 삽입하지 않는다.
@@ -805,7 +839,7 @@ components/content/
- `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**. **상단은 루트 직속 자식만** 트리에 붙이며(부모의 부모가 있는 행은 자식으로 무시), `footer`·`recommended`는 **평면** 배열(`parent_id` 없음)이다. `recommended`는 `id`, `label`, `url`, `descriptionText`, `thumbnailUrl`, `cachedFaviconUrl`, `isVisible`을 내려준다. 관리자 `GET /admin/api/navigation`에는 추가로 `faviconCachedAt`을 포함한다.
- `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, 폴더 전용 자리 표시 `#`를 허용한다. 저장 시 `uniare.sori.studio`처럼 프로토콜 없는 외부 호스트는 `https://`를 자동으로 붙인다. 관리자 추천 사이트 편집 모달은 URL 접두사(`https://`·`http://`·내부 경로) 선택과 본문 입력으로 나눈다.
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션**·**추천 사이트** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 상단은 `buildNavigationEditorTree` 결과를 `flattenNavigationEditorWrappers`로 한 테이블에 평면 표시하고, **같은 행의 위 1/3·가운데·아래 1/3**에 드롭하면 각각 대상 **앞**(동일 부모)·**하위**(대상의 자식, **루트 행에만**)·**뒤**(동일 부모)로 이동한다. **상단은 루트→자식 한 단계만** 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 **형제 앞·뒤**를 파란 가로 끝선·연한 파란 배경, **하위 편입**을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에 `앞에 끼움` / `뒤에 끼움` / `하위로 넣기` 짧은 문구를 덧붙인다. 개요 열 표기는 `1`, `2.1`, `2.2`, `3`처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼 `padding-left`로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 하단·추천 사이트는 평면 목록만 드래그 정렬한다. **추천 사이트** 탭 목록은 공개 Recommended와 같은 **카드**(아이콘·제목·설명 또는 URL) 형태이며, 캐시 갱신 시각은 카드 오른쪽 끝에 표시한다. 추천 사이트 드래그는 카드 **위·아래 절반**에 드롭하면 각각 대상 **앞·뒤**로 삽입되며, 파란 가로 삽입선·`앞에 삽입` / `뒤에 삽입` 안내를 표시한다. 추천 사이트는 공개 화면 **왼쪽 사이드바** Categories 아래 Recommended 영역에 카드로 노출되며, 대체 텍스트가 있으면 URL 대신 대체 텍스트를 표시한다. 아이콘 우선순위는 **수동 썸네일 URL > 서버 캐시 파비콘(`cached_favicon_url`) > 외부 faviconV2 프록시**이다. 서버는 외부 `https://` URL의 파비콘을 `/uploads/system/recommended-favicon-*.png`로 저장하고 `favicon_cached_at`을 기록한다. 자동 갱신은 **매월 1일 03:00(KST)** 에 전체 추천 사이트를 대상으로 수행하며, **관리자 추천 사이트 탭**은 공개와 같은 **카드 목록**으로 미리보기하고 **편집** 버튼으로 모달에서 제목·URL·대체 텍스트·썸네일 URL 전체를 확인·수정한다. 삭제·취소(배경 클릭 포함)는 브라우저 `confirm`으로 한 번 더 확인하며, 모달에서 **적용** 전 입력 변경이 있으면 닫기 전에 안내한다. 편집 모달에서 `POST /admin/api/navigation/:id/favicon-refresh`로 파비콘을 수동 갱신할 수 있다. 네비게이션 저장 시 캐시 없는 추천 사이트는 백그라운드로 최초 캐시를 시도한다. 운영 수동 일괄 갱신은 `npm run navigation:refresh-recommended-favicons`를 사용한다. 오른쪽 사이드바는 추천 사이트를 렌더하지 않고 광고와 게시물 목차 영역에 집중한다.
- 관리자 메뉴 화면은 **메인 네비게이션**·**페이지 섹션**·**하단 네비게이션**·**추천 사이트** 탭으로 구분한다. 메인·하단·추천 사이트 추가는 모두 추가 버튼 클릭 후 모달에서 필요한 정보를 입력하고 적용해야 목록에 반영된다. 각 탭 작업줄은 왼쪽에 추가 버튼, 오른쪽에 저장 버튼을 두며, 메인·하단·추천 사이트 변경사항은 `네비게이션 저장`, 페이지 섹션 변경사항은 `페이지 섹션 저장`으로 저장한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 메인 네비게이션은 `buildNavigationEditorTree` 결과를 `flattenNavigationEditorWrappers`로 한 테이블에 평면 표시하고, **같은 행의 위 1/3·가운데·아래 1/3**에 드롭하면 각각 대상 **앞**(동일 부모)·**하위**(대상의 자식, **루트 행에만**)·**뒤**(동일 부모)로 이동한다. **메인 네비게이션은 루트→자식 한 단계만** 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 **형제 앞·뒤**를 파란 가로 끝선·연한 파란 배경, **하위 편입**을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에 `앞에 끼움` / `뒤에 끼움` / `하위로 넣기` 짧은 문구를 덧붙인다. 개요 열 표기는 `1`, `2.1`, `2.2`, `3`처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼 `padding-left`로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 페이지 섹션·하단·추천 사이트는 평면 목록만 드래그 정렬한다. **추천 사이트** 탭 목록은 공개 Recommended와 같은 **카드**(아이콘·제목·설명 또는 URL) 형태이며, 캐시 갱신 시각은 카드 오른쪽 끝에 표시한다. 추천 사이트 드래그는 카드 **위·아래 절반**에 드롭하면 각각 대상 **앞·뒤**로 삽입되며, 파란 가로 삽입선·`앞에 삽입` / `뒤에 삽입` 안내를 표시한다. 추천 사이트는 공개 화면 **왼쪽 사이드바** Categories 아래 Recommended 영역에 카드로 노출되며, 대체 텍스트가 있으면 URL 대신 대체 텍스트를 표시한다. 아이콘 우선순위는 **수동 썸네일 URL > 서버 캐시 파비콘(`cached_favicon_url`) > 외부 faviconV2 프록시**이다. 서버는 외부 `https://` URL의 파비콘을 `/uploads/system/recommended-favicon-*.png`로 저장하고 `favicon_cached_at`을 기록한다. 자동 갱신은 **매월 1일 03:00(KST)** 에 전체 추천 사이트를 대상으로 수행하며, **관리자 추천 사이트 탭**은 공개와 같은 **카드 목록**으로 미리보기하고 **편집** 버튼으로 모달에서 제목·URL·대체 텍스트·썸네일 URL 전체를 확인·수정한다. 삭제·취소(배경 클릭 포함)는 브라우저 `confirm`으로 한 번 더 확인하며, 모달에서 **적용** 전 입력 변경이 있으면 닫기 전에 안내한다. 편집 모달에서 `POST /admin/api/navigation/:id/favicon-refresh`로 파비콘을 수동 갱신할 수 있다. 네비게이션 저장 시 캐시 없는 추천 사이트는 백그라운드로 최초 캐시를 시도한다. 운영 수동 일괄 갱신은 `npm run navigation:refresh-recommended-favicons`를 사용한다. 오른쪽 사이드바는 추천 사이트를 렌더하지 않고 광고와 게시물 목차 영역에 집중한다.
- `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로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.