관리자 모달 프레임 공통화

This commit is contained in:
2026-07-23 17:27:10 +09:00
parent 57c4363db2
commit 60512ef07d
8 changed files with 422 additions and 387 deletions

View File

@@ -0,0 +1,44 @@
<script setup>
const props = defineProps({
/** 모달 제목 요소 ID */
labelledBy: {
type: String,
required: true
},
/** 오버레이에 추가할 화면별 클래스 */
rootClass: {
type: String,
default: ''
},
/** 패널에 추가할 화면별 클래스 */
panelClass: {
type: String,
default: ''
},
/** 오버레이 z-index 클래스 */
zClass: {
type: String,
default: 'z-[60]'
}
})
const emit = defineEmits(['close'])
</script>
<template>
<div
class="admin-modal-frame fixed inset-0 grid place-items-center bg-black/40 px-5 py-8"
:class="[zClass, rootClass]"
role="dialog"
aria-modal="true"
:aria-labelledby="props.labelledBy"
@click.self="emit('close')"
>
<section
class="admin-modal-frame__panel max-h-[90vh] w-full overflow-y-auto rounded border border-line bg-white p-5 shadow-xl"
:class="panelClass"
>
<slot />
</section>
</div>
</template>

View File

@@ -189,6 +189,7 @@
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
| components/admin/AdminConfirmModal.vue | 관리자 공통 확인 모달(제목·본문 슬롯·확인/취소 버튼, primary/danger 톤) |
| components/admin/AdminModalFrame.vue | 관리자 공통 일반 모달 프레임(오버레이, dialog 접근성 속성, 기본 패널 스타일, 바깥 클릭 닫기) |
| components/admin/AdminToast.vue | 관리자 공통 우측 상단 토스트 표시(성공·오류·안내 톤, 화면별 상단 위치 조정) |
| components/admin/AdminSearchInput.vue | 관리자 공통 검색 입력(돋보기 아이콘, IME 검색 이벤트 전달, 외부 focus/select 노출) |
| components/admin/AdminSelectInput.vue | 관리자 공통 select 입력(문자·숫자 옵션 값, 기본 필터·정렬 옵션, chevron 아이콘, `v-model` 전달) |

View File

@@ -9,7 +9,7 @@
- `components/admin/AdminPostForm.vue`: 태그 입력, 대표 이미지 선택, 발행/초안 저장 상태, 우측 설정 패널 책임을 분리.
- `pages/admin/media/index.vue`: 미디어 목록, 폴더 트리, 상세 모달, 재생성 액션을 작은 단위로 분리.
- 관리자 UI 공통 컴포넌트 정리.
- 모달 패널, 필터 팝오버의 radius·shadow·z-index·색상 규칙을 공통화.
- 필터 팝오버의 radius·shadow·z-index·색상 규칙을 공통화.
- 멤버 고급 필터와 설정 화면 select 입력의 공통 컴포넌트 적용 범위 검토.
- 최근 브라우저 회귀 테스트 추가.
- 태그 추천 선택 후 다음 태그 후보가 포커스 해제 없이 유지되는지 확인.

View File

@@ -1,5 +1,10 @@
# 업데이트 이력
## v1.11.11
- 관리자 일반 모달 오버레이·패널을 공용 `AdminModalFrame` 컴포넌트로 분리.
- 미디어 폴더 생성과 네비게이션 생성·페이지 섹션·페이지 순서·추천 사이트 편집 모달의 반복 wrapper 제거.
## v1.11.10
- 관리자 페이지네이션 계산 로직을 `lib/admin-pagination.js` 순수 함수로 분리.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.11.10",
"version": "1.11.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.11.10",
"version": "1.11.11",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.11.10",
"version": "1.11.11",
"private": true,
"type": "module",
"imports": {

View File

@@ -1663,46 +1663,43 @@ watch(() => mediaPagination.value.page, (page) => {
</div>
</div>
<div
<AdminModalFrame
v-if="isCreateFolderModalOpen"
class="admin-media__folder-create-modal fixed inset-0 z-[60] grid place-items-center bg-black/40 px-5 py-8"
role="dialog"
aria-modal="true"
aria-labelledby="media-folder-modal-title"
@click.self="closeCreateFolderModal"
labelled-by="media-folder-modal-title"
root-class="admin-media__folder-create-modal"
panel-class="admin-media__folder-create-panel max-w-sm"
@close="closeCreateFolderModal"
>
<section class="admin-media__folder-create-panel w-full max-w-sm rounded border border-line bg-white p-5 shadow-xl">
<h3 id="media-folder-modal-title" class="text-lg font-semibold text-ink">
새 폴더
</h3>
<p class="mt-1 text-xs text-muted">
{{ activeFolder ? `${activeFolder} 아래에 하위 폴더를 만듭니다.` : '최상위 폴더를 만듭니다.' }}
</p>
<label class="mt-4 grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">폴더 이름</span>
<input
v-model="createFolderModalName"
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="폴더 이름"
@keydown.enter.prevent="submitCreateFolderModal"
>
</label>
<div class="mt-5 flex justify-end gap-2">
<button class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink" type="button" @click="closeCreateFolderModal">
취소
</button>
<button
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
type="button"
:disabled="!createFolderModalName.trim()"
@click="submitCreateFolderModal"
>
만들기
</button>
</div>
</section>
</div>
<h3 id="media-folder-modal-title" class="text-lg font-semibold text-ink">
새 폴더
</h3>
<p class="mt-1 text-xs text-muted">
{{ activeFolder ? `${activeFolder} 아래에 하위 폴더를 만듭니다.` : '최상위 폴더를 만듭니다.' }}
</p>
<label class="mt-4 grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">폴더 이름</span>
<input
v-model="createFolderModalName"
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="폴더 이름"
@keydown.enter.prevent="submitCreateFolderModal"
>
</label>
<div class="mt-5 flex justify-end gap-2">
<button class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink" type="button" @click="closeCreateFolderModal">
취소
</button>
<button
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
type="button"
:disabled="!createFolderModalName.trim()"
@click="submitCreateFolderModal"
>
만들기
</button>
</div>
</AdminModalFrame>
<div
v-if="selectedMedia"

View File

@@ -2101,133 +2101,127 @@ const saveNavigation = async () => {
</ul>
</div>
<div
<AdminModalFrame
v-if="navCreateModalType"
class="admin-navigation__create-modal fixed inset-0 z-[60] grid place-items-center bg-black/40 px-5 py-8"
role="dialog"
aria-modal="true"
aria-labelledby="admin-navigation-create-title"
@click.self="closeNavCreateModal"
labelled-by="admin-navigation-create-title"
root-class="admin-navigation__create-modal"
panel-class="admin-navigation__create-modal-panel max-w-lg"
@close="closeNavCreateModal"
>
<section class="admin-navigation__create-modal-panel max-h-[90vh] w-full max-w-lg overflow-y-auto rounded border border-line bg-white p-5 shadow-xl">
<div class="admin-navigation__create-modal-head">
<h3 id="admin-navigation-create-title" class="text-lg font-semibold text-ink">
{{ navCreateModalTitle }}
</h3>
<p class="mt-1 text-xs text-muted">
제목과 URL을 입력한 뒤 추가하면 목록에 반영됩니다. 최종 반영은 네비게이션 저장으로 완료합니다.
</p>
</div>
<div class="admin-navigation__create-modal-head">
<h3 id="admin-navigation-create-title" class="text-lg font-semibold text-ink">
{{ navCreateModalTitle }}
</h3>
<p class="mt-1 text-xs text-muted">
제목과 URL을 입력한 뒤 추가하면 목록에 반영됩니다. 최종 반영은 네비게이션 저장으로 완료합니다.
</p>
</div>
<div class="admin-navigation__create-modal-fields mt-5 grid gap-4">
<label class="grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">라벨</span>
<input
v-model="navCreateDraft.label"
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="메뉴 이름"
required
>
</label>
<label class="grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">URL</span>
<div class="admin-navigation__create-url flex flex-col gap-2 sm:flex-row">
<span class="admin-navigation__create-url-prefix-wrap relative shrink-0">
<select
v-model="navCreateDraft.urlPrefix"
class="admin-navigation__create-url-prefix h-[38px] appearance-none rounded border border-line bg-white py-2 pl-3 pr-10 text-sm outline-none focus:border-[#8e9cac]"
>
<option
v-for="option in navigationUrlPrefixOptions"
:key="option.label"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-[#15171a]" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
<input
v-model="navCreateDraft.urlBody"
class="admin-navigation__create-url-body min-w-0 flex-1 rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
type="text"
:placeholder="getNavigationUrlBodyPlaceholder(navCreateDraft.urlPrefix)"
required
@keydown.enter.prevent="applyNavCreate"
>
</div>
<span v-if="getNavCreateDraftUrl()" class="break-all pl-2 text-[11px] font-mono text-muted">
{{ getNavCreateDraftUrl() }}
</span>
</label>
</div>
<div class="admin-navigation__create-modal-actions mt-6 flex justify-end gap-2">
<button class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink" type="button" @click="closeNavCreateModal">
취소
</button>
<button
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
type="button"
:disabled="!navCreateDraft.label.trim() || !getNavCreateDraftUrl()"
@click="applyNavCreate"
>
추가
</button>
</div>
</section>
</div>
<div
v-if="pageSectionCreateModalOpen"
class="admin-navigation__page-section-create-modal fixed inset-0 z-[60] grid place-items-center bg-black/40 px-5 py-8"
role="dialog"
aria-modal="true"
aria-labelledby="admin-navigation-page-section-create-title"
@click.self="closePageSectionCreateModal"
>
<section class="admin-navigation__page-section-create-modal-panel max-h-[90vh] w-full max-w-lg overflow-y-auto rounded border border-line bg-white p-5 shadow-xl">
<div class="admin-navigation__page-section-create-modal-head">
<h3 id="admin-navigation-page-section-create-title" class="text-lg font-semibold text-ink">
페이지 섹션 추가
</h3>
<p class="mt-1 text-xs text-muted">
홈 Pages 진열장에 사용할 섹션 이름을 입력합니다.
</p>
</div>
<label class="mt-5 grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">섹션명</span>
<div class="admin-navigation__create-modal-fields mt-5 grid gap-4">
<label class="grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">라벨</span>
<input
v-model="pageSectionCreateName"
v-model="navCreateDraft.label"
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
maxlength="80"
placeholder="예: 게임"
placeholder="메뉴 이름"
required
@keydown.enter.prevent="addPageSection"
>
</label>
<label class="grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">URL</span>
<div class="admin-navigation__create-url flex flex-col gap-2 sm:flex-row">
<span class="admin-navigation__create-url-prefix-wrap relative shrink-0">
<select
v-model="navCreateDraft.urlPrefix"
class="admin-navigation__create-url-prefix h-[38px] appearance-none rounded border border-line bg-white py-2 pl-3 pr-10 text-sm outline-none focus:border-[#8e9cac]"
>
<option
v-for="option in navigationUrlPrefixOptions"
:key="option.label"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-[#15171a]" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
<input
v-model="navCreateDraft.urlBody"
class="admin-navigation__create-url-body min-w-0 flex-1 rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
type="text"
:placeholder="getNavigationUrlBodyPlaceholder(navCreateDraft.urlPrefix)"
required
@keydown.enter.prevent="applyNavCreate"
>
</div>
<span v-if="getNavCreateDraftUrl()" class="break-all pl-2 text-[11px] font-mono text-muted">
{{ getNavCreateDraftUrl() }}
</span>
</label>
</div>
<div class="admin-navigation__page-section-create-modal-actions mt-6 flex justify-end gap-2">
<button class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink" type="button" @click="closePageSectionCreateModal">
취소
</button>
<button
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
type="button"
:disabled="savingPageSectionId === 'new' || !pageSectionCreateName.trim()"
@click="addPageSection"
>
{{ savingPageSectionId === 'new' ? '추가 중' : '추가' }}
</button>
</div>
</section>
</div>
<div class="admin-navigation__create-modal-actions mt-6 flex justify-end gap-2">
<button class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink" type="button" @click="closeNavCreateModal">
취소
</button>
<button
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
type="button"
:disabled="!navCreateDraft.label.trim() || !getNavCreateDraftUrl()"
@click="applyNavCreate"
>
추가
</button>
</div>
</AdminModalFrame>
<AdminModalFrame
v-if="pageSectionCreateModalOpen"
labelled-by="admin-navigation-page-section-create-title"
root-class="admin-navigation__page-section-create-modal"
panel-class="admin-navigation__page-section-create-modal-panel max-w-lg"
@close="closePageSectionCreateModal"
>
<div class="admin-navigation__page-section-create-modal-head">
<h3 id="admin-navigation-page-section-create-title" class="text-lg font-semibold text-ink">
페이지 섹션 추가
</h3>
<p class="mt-1 text-xs text-muted">
홈 Pages 진열장에 사용할 섹션 이름을 입력합니다.
</p>
</div>
<label class="mt-5 grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">섹션명</span>
<input
v-model="pageSectionCreateName"
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
maxlength="80"
placeholder="예: 게임"
required
@keydown.enter.prevent="addPageSection"
>
</label>
<div class="admin-navigation__page-section-create-modal-actions mt-6 flex justify-end gap-2">
<button class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink" type="button" @click="closePageSectionCreateModal">
취소
</button>
<button
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
type="button"
:disabled="savingPageSectionId === 'new' || !pageSectionCreateName.trim()"
@click="addPageSection"
>
{{ savingPageSectionId === 'new' ? '추가 중' : '추가' }}
</button>
</div>
</AdminModalFrame>
<AdminConfirmModal
:open="recommendedDiscardModalOpen"
@@ -2254,253 +2248,247 @@ const saveNavigation = async () => {
</p>
</AdminConfirmModal>
<div
<AdminModalFrame
v-if="pageOrderModalSection"
class="admin-navigation__page-order-modal fixed inset-0 z-[60] grid place-items-center bg-black/40 px-5 py-8"
role="dialog"
aria-modal="true"
aria-labelledby="admin-navigation-page-order-title"
@click.self="closePageOrderModal"
labelled-by="admin-navigation-page-order-title"
root-class="admin-navigation__page-order-modal"
panel-class="admin-navigation__page-order-modal-panel max-w-4xl"
@close="closePageOrderModal"
>
<section class="admin-navigation__page-order-modal-panel max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded border border-line bg-white p-5 shadow-xl">
<div class="admin-navigation__page-order-modal-head flex items-start justify-between gap-4">
<div>
<h3 id="admin-navigation-page-order-title" class="text-lg font-semibold text-ink">
페이지 순서 변경
</h3>
<p class="mt-1 text-xs text-muted">
{{ pageOrderModalSection.name }} 섹션의 홈 표시 순서를 조정합니다.
</p>
</div>
<button class="grid size-8 place-items-center rounded border border-line text-muted transition-colors hover:bg-[#f5f5f2] hover:text-ink" type="button" aria-label="닫기" @click="closePageOrderModal">
×
</button>
<div class="admin-navigation__page-order-modal-head flex items-start justify-between gap-4">
<div>
<h3 id="admin-navigation-page-order-title" class="text-lg font-semibold text-ink">
페이지 순서 변경
</h3>
<p class="mt-1 text-xs text-muted">
{{ pageOrderModalSection.name }} 섹션의 홈 표시 순서를 조정합니다.
</p>
</div>
<button class="grid size-8 place-items-center rounded border border-line text-muted transition-colors hover:bg-[#f5f5f2] hover:text-ink" type="button" aria-label="닫기" @click="closePageOrderModal">
×
</button>
</div>
<div v-if="pageOrderDraftPages.length === 0" class="admin-navigation__page-order-empty mt-5 rounded border border-dashed border-line p-6 text-center text-sm text-muted">
이 섹션에 표시 중인 페이지가 없습니다.
</div>
<div v-if="pageOrderDraftPages.length === 0" class="admin-navigation__page-order-empty mt-5 rounded border border-dashed border-line p-6 text-center text-sm text-muted">
이 섹션에 표시 중인 페이지가 없습니다.
</div>
<div v-else class="admin-navigation__page-order-list mt-5 flex gap-3 overflow-x-auto rounded border border-line bg-[#f9f9f7] p-3">
<article
v-for="(page, index) in pageOrderDraftPages"
:key="page.id"
class="admin-navigation__page-order-card group relative flex w-[150px] shrink-0 flex-col rounded-[8px] border border-line bg-white p-2 shadow-sm transition-colors hover:border-[#b9c0c7]"
:class="pageOrderRowClass(page.id)"
draggable="true"
@dragstart="onPageOrderDragStart($event, page.id)"
@dragover="onPageOrderDragOver($event, page.id)"
@drop="onPageOrderDrop($event, page.id)"
@dragend="onPageOrderDragEnd"
<div v-else class="admin-navigation__page-order-list mt-5 flex gap-3 overflow-x-auto rounded border border-line bg-[#f9f9f7] p-3">
<article
v-for="(page, index) in pageOrderDraftPages"
:key="page.id"
class="admin-navigation__page-order-card group relative flex w-[150px] shrink-0 flex-col rounded-[8px] border border-line bg-white p-2 shadow-sm transition-colors hover:border-[#b9c0c7]"
:class="pageOrderRowClass(page.id)"
draggable="true"
@dragstart="onPageOrderDragStart($event, page.id)"
@dragover="onPageOrderDragOver($event, page.id)"
@drop="onPageOrderDrop($event, page.id)"
@dragend="onPageOrderDragEnd"
>
<span class="admin-navigation__page-order-index absolute left-3 top-3 z-10 rounded-full bg-white/90 px-2 py-0.5 text-[11px] font-semibold tabular-nums text-ink shadow-sm">
{{ index + 1 }}
</span>
<span
v-if="page.isFeatured"
class="admin-navigation__page-order-featured absolute right-3 top-3 z-10 rounded-full bg-[#15171a] px-2 py-0.5 text-[10px] font-semibold text-white shadow-sm"
>
<span class="admin-navigation__page-order-index absolute left-3 top-3 z-10 rounded-full bg-white/90 px-2 py-0.5 text-[11px] font-semibold tabular-nums text-ink shadow-sm">
{{ index + 1 }}
</span>
<span
v-if="page.isFeatured"
class="admin-navigation__page-order-featured absolute right-3 top-3 z-10 rounded-full bg-[#15171a] px-2 py-0.5 text-[10px] font-semibold text-white shadow-sm"
추천
</span>
<figure class="admin-navigation__page-order-cover aspect-[3/4] overflow-hidden rounded-[6px] border border-line bg-[#f5f5f2]">
<img
v-if="getPageOrderCardImage(page)"
class="h-full w-full object-cover"
:src="getPageOrderCardImage(page)"
:alt="page.listingTitle || page.title || '페이지 썸네일'"
loading="lazy"
>
추천
</span>
<figure class="admin-navigation__page-order-cover aspect-[3/4] overflow-hidden rounded-[6px] border border-line bg-[#f5f5f2]">
<img
v-if="getPageOrderCardImage(page)"
class="h-full w-full object-cover"
:src="getPageOrderCardImage(page)"
:alt="page.listingTitle || page.title || '페이지 썸네일'"
loading="lazy"
>
<div v-else class="flex h-full w-full items-center justify-center p-3 text-center text-xs font-semibold leading-tight text-muted">
{{ page.listingTitle || page.title || '제목 없음' }}
</div>
</figure>
<h4 class="admin-navigation__page-order-title mt-2 line-clamp-2 min-h-[2rem] text-xs font-semibold leading-tight text-ink">
<div v-else class="flex h-full w-full items-center justify-center p-3 text-center text-xs font-semibold leading-tight text-muted">
{{ page.listingTitle || page.title || '제목 없음' }}
</h4>
<p class="admin-navigation__page-order-slug mt-1 truncate font-mono text-[10px] text-muted">
/pages/{{ page.slug }}
</p>
<button
class="admin-navigation__page-order-remove mt-2 rounded border border-line px-2 py-1.5 text-[11px] font-semibold text-muted transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700"
type="button"
@click.stop="removePageFromOrderSection(page)"
>
섹션에서 제거
</button>
</article>
</div>
</div>
</figure>
<h4 class="admin-navigation__page-order-title mt-2 line-clamp-2 min-h-[2rem] text-xs font-semibold leading-tight text-ink">
{{ page.listingTitle || page.title || '제목 없음' }}
</h4>
<p class="admin-navigation__page-order-slug mt-1 truncate font-mono text-[10px] text-muted">
/pages/{{ page.slug }}
</p>
<button
class="admin-navigation__page-order-remove mt-2 rounded border border-line px-2 py-1.5 text-[11px] font-semibold text-muted transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700"
type="button"
@click.stop="removePageFromOrderSection(page)"
>
섹션에서 제거
</button>
</article>
</div>
<p v-if="pageOrderRemovedCount > 0" class="admin-navigation__page-order-remove-note mt-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
{{ pageOrderRemovedCount }}개 페이지는 저장하면 이 섹션에서 제거됩니다.
<p v-if="pageOrderRemovedCount > 0" class="admin-navigation__page-order-remove-note mt-3 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
{{ pageOrderRemovedCount }}개 페이지는 저장하면 이 섹션에서 제거됩니다.
</p>
<div class="admin-navigation__page-order-actions mt-6 flex justify-end gap-2">
<button class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink" type="button" @click="closePageOrderModal">
취소
</button>
<button
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
type="button"
:disabled="savingPageOrder || (pageOrderDraftPages.length === 0 && pageOrderRemovedCount === 0)"
@click="savePageOrder"
>
{{ savingPageOrder ? '저장 중' : '저장' }}
</button>
</div>
</AdminModalFrame>
<AdminModalFrame
v-if="recommendedEditId && recommendedEditDraft"
labelled-by="admin-navigation-recommended-edit-title"
root-class="admin-navigation__recommended-modal"
panel-class="admin-navigation__recommended-modal-panel max-w-lg"
@close="requestCloseRecommendedEdit"
>
<div class="admin-navigation__recommended-modal-head">
<h3 id="admin-navigation-recommended-edit-title" class="text-lg font-semibold text-ink">
추천 사이트 편집
</h3>
<p class="mt-1 text-xs text-muted">
공개 Recommended 카드와 동일한 정보를 편집합니다.
</p>
</div>
<div class="admin-navigation__page-order-actions mt-6 flex justify-end gap-2">
<button class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink" type="button" @click="closePageOrderModal">
<div class="admin-navigation__recommended-modal-preview mt-5 flex items-center gap-3 rounded border border-line bg-[#f9f9f7] p-3">
<span class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg border border-line bg-white text-sm font-bold text-ink">
<img
v-if="recommendedEditPreviewItem && getRecommendedIconPreviewUrl(recommendedEditPreviewItem)"
class="h-full w-full object-cover"
:src="getRecommendedIconPreviewUrl(recommendedEditPreviewItem)"
width="40"
height="40"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
>
<span v-else class="px-1 text-center leading-none">{{ (recommendedEditDraft.label || '?').slice(0, 1) }}</span>
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-ink">
{{ recommendedEditDraft.label || '제목 없음' }}
</p>
<p class="mt-0.5 truncate text-xs text-muted">
{{ recommendedEditPreviewItem ? getRecommendedPreviewSubtitle(recommendedEditPreviewItem) : '' }}
</p>
<p class="mt-1 text-[11px] text-muted">
{{ recommendedEditItem ? getRecommendedFaviconCachedLabel(recommendedEditItem) : '캐시 없음' }}
</p>
</div>
</div>
<div class="admin-navigation__recommended-modal-fields mt-5 grid gap-4">
<label class="grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">제목</span>
<input
v-model="recommendedEditDraft.label"
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="사이트 이름"
required
>
</label>
<label class="grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">URL</span>
<div class="admin-navigation__recommended-url flex flex-col gap-2 sm:flex-row">
<span class="admin-navigation__recommended-url-prefix-wrap relative shrink-0">
<select
v-model="recommendedEditDraft.urlPrefix"
class="admin-navigation__recommended-url-prefix h-[38px] appearance-none rounded border border-line bg-white py-2 pl-3 pr-10 text-sm outline-none focus:border-[#8e9cac]"
>
<option
v-for="option in navigationUrlPrefixOptions"
:key="option.label"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-[#15171a]" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
<input
v-model="recommendedEditDraft.urlBody"
class="admin-navigation__recommended-url-body min-w-0 flex-1 rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
type="text"
:placeholder="getNavigationUrlBodyPlaceholder(recommendedEditDraft.urlPrefix)"
required
>
</div>
<span
v-if="getRecommendedDraftUrl(recommendedEditDraft)"
class="break-all pl-2 text-[11px] font-mono text-muted"
>
{{ getRecommendedDraftUrl(recommendedEditDraft) }}
</span>
</label>
<label class="grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">대체 텍스트</span>
<input
v-model="recommendedEditDraft.descriptionText"
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="URL 대신 표시할 문구"
>
</label>
<label class="grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">썸네일 URL</span>
<textarea
v-model="recommendedEditDraft.thumbnailUrl"
class="min-h-24 resize-y rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
placeholder="/uploads/…"
/>
<span class="pl-2 text-[11px] text-muted">비워 두면 서버 캐시 파비콘을 사용합니다.</span>
</label>
</div>
<div class="admin-navigation__recommended-modal-tools mt-4 flex flex-wrap items-center gap-2">
<button
class="rounded border border-line bg-white px-3 py-2 text-xs font-semibold text-ink transition-colors hover:bg-[#f5f5f2] disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="!recommendedEditItem || faviconRefreshingIds.has(String(recommendedEditItem.id))"
@click="refreshRecommendedEditFavicon"
>
{{ recommendedEditItem && faviconRefreshingIds.has(String(recommendedEditItem.id)) ? '파비콘 갱신 중…' : '파비콘 새로고침' }}
</button>
</div>
<div class="admin-navigation__recommended-modal-actions mt-6 flex flex-wrap items-center justify-between gap-2">
<button
class="rounded border border-red-200 px-3 py-2 text-xs font-semibold text-red-700"
type="button"
@click="removeRecommendedFromModal"
>
삭제
</button>
<div class="flex flex-wrap gap-2">
<button
class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink"
type="button"
@click="requestCloseRecommendedEdit"
>
취소
</button>
<button
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
type="button"
:disabled="savingPageOrder || (pageOrderDraftPages.length === 0 && pageOrderRemovedCount === 0)"
@click="savePageOrder"
:disabled="!recommendedEditDraft.label.trim() || !getRecommendedDraftUrl(recommendedEditDraft)"
@click="applyRecommendedEdit"
>
{{ savingPageOrder ? '저장 중' : '저장' }}
적용
</button>
</div>
</section>
</div>
<div
v-if="recommendedEditId && recommendedEditDraft"
class="admin-navigation__recommended-modal fixed inset-0 z-[60] grid place-items-center bg-black/40 px-5 py-8"
role="dialog"
aria-modal="true"
aria-labelledby="admin-navigation-recommended-edit-title"
@click.self="requestCloseRecommendedEdit"
>
<section class="admin-navigation__recommended-modal-panel max-h-[90vh] w-full max-w-lg overflow-y-auto rounded border border-line bg-white p-5 shadow-xl">
<div class="admin-navigation__recommended-modal-head">
<h3 id="admin-navigation-recommended-edit-title" class="text-lg font-semibold text-ink">
추천 사이트 편집
</h3>
<p class="mt-1 text-xs text-muted">
공개 Recommended 카드와 동일한 정보를 편집합니다.
</p>
</div>
<div class="admin-navigation__recommended-modal-preview mt-5 flex items-center gap-3 rounded border border-line bg-[#f9f9f7] p-3">
<span class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg border border-line bg-white text-sm font-bold text-ink">
<img
v-if="recommendedEditPreviewItem && getRecommendedIconPreviewUrl(recommendedEditPreviewItem)"
class="h-full w-full object-cover"
:src="getRecommendedIconPreviewUrl(recommendedEditPreviewItem)"
width="40"
height="40"
alt=""
loading="lazy"
referrerpolicy="no-referrer"
>
<span v-else class="px-1 text-center leading-none">{{ (recommendedEditDraft.label || '?').slice(0, 1) }}</span>
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-ink">
{{ recommendedEditDraft.label || '제목 없음' }}
</p>
<p class="mt-0.5 truncate text-xs text-muted">
{{ recommendedEditPreviewItem ? getRecommendedPreviewSubtitle(recommendedEditPreviewItem) : '' }}
</p>
<p class="mt-1 text-[11px] text-muted">
{{ recommendedEditItem ? getRecommendedFaviconCachedLabel(recommendedEditItem) : '캐시 없음' }}
</p>
</div>
</div>
<div class="admin-navigation__recommended-modal-fields mt-5 grid gap-4">
<label class="grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">제목</span>
<input
v-model="recommendedEditDraft.label"
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="사이트 이름"
required
>
</label>
<label class="grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">URL</span>
<div class="admin-navigation__recommended-url flex flex-col gap-2 sm:flex-row">
<span class="admin-navigation__recommended-url-prefix-wrap relative shrink-0">
<select
v-model="recommendedEditDraft.urlPrefix"
class="admin-navigation__recommended-url-prefix h-[38px] appearance-none rounded border border-line bg-white py-2 pl-3 pr-10 text-sm outline-none focus:border-[#8e9cac]"
>
<option
v-for="option in navigationUrlPrefixOptions"
:key="option.label"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-[#15171a]" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
<input
v-model="recommendedEditDraft.urlBody"
class="admin-navigation__recommended-url-body min-w-0 flex-1 rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
type="text"
:placeholder="getNavigationUrlBodyPlaceholder(recommendedEditDraft.urlPrefix)"
required
>
</div>
<span
v-if="getRecommendedDraftUrl(recommendedEditDraft)"
class="break-all pl-2 text-[11px] font-mono text-muted"
>
{{ getRecommendedDraftUrl(recommendedEditDraft) }}
</span>
</label>
<label class="grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">대체 텍스트</span>
<input
v-model="recommendedEditDraft.descriptionText"
class="rounded border border-line px-3 py-2 text-sm outline-none focus:border-[#8e9cac]"
type="text"
placeholder="URL 대신 표시할 문구"
>
</label>
<label class="grid gap-1.5 text-sm">
<span class="text-xs font-semibold text-muted">썸네일 URL</span>
<textarea
v-model="recommendedEditDraft.thumbnailUrl"
class="min-h-24 resize-y rounded border border-line px-3 py-2 font-mono text-sm outline-none focus:border-[#8e9cac]"
placeholder="/uploads/…"
/>
<span class="text-[11px] pl-2 text-muted">비워 두면 서버 캐시 파비콘을 사용합니다.</span>
</label>
</div>
<div class="admin-navigation__recommended-modal-tools mt-4 flex flex-wrap items-center gap-2">
<button
class="rounded border border-line bg-white px-3 py-2 text-xs font-semibold text-ink transition-colors hover:bg-[#f5f5f2] disabled:cursor-not-allowed disabled:opacity-50"
type="button"
:disabled="!recommendedEditItem || faviconRefreshingIds.has(String(recommendedEditItem.id))"
@click="refreshRecommendedEditFavicon"
>
{{ recommendedEditItem && faviconRefreshingIds.has(String(recommendedEditItem.id)) ? '파비콘 갱신 중…' : '파비콘 새로고침' }}
</button>
</div>
<div class="admin-navigation__recommended-modal-actions mt-6 flex flex-wrap items-center justify-between gap-2">
<button
class="rounded border border-red-200 px-3 py-2 text-xs font-semibold text-red-700"
type="button"
@click="removeRecommendedFromModal"
>
삭제
</button>
<div class="flex flex-wrap gap-2">
<button
class="rounded border border-line px-3 py-2 text-xs font-semibold text-ink"
type="button"
@click="requestCloseRecommendedEdit"
>
취소
</button>
<button
class="rounded bg-[#15171a] px-3 py-2 text-xs font-semibold text-white disabled:opacity-50"
type="button"
:disabled="!recommendedEditDraft.label.trim() || !getRecommendedDraftUrl(recommendedEditDraft)"
@click="applyRecommendedEdit"
>
적용
</button>
</div>
</div>
</section>
</div>
</div>
</AdminModalFrame>
<AdminToast :toast="toast" />
</section>