관리자 모달 프레임 공통화

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

@@ -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>